commit 9825912290ea866bfc1c7fd38430ca527af00ca0
parent 9e40d0077404c225a81cba01a4d9669a4f2da1a9
Author: Christian Grothoff <grothoff@gnunet.org>
Date: Sun, 16 Aug 2026 23:26:34 +0200
add DB tests organized by primary table
Diffstat:
57 files changed, 28581 insertions(+), 0 deletions(-)
diff --git a/src/exchangedb/meson.build b/src/exchangedb/meson.build
@@ -327,6 +327,108 @@ test(
)
+# One test binary per database table, each covering the exported functions
+# whose primary test table it is. The '@brief' comment of every function in
+# src/include/exchange-database/ names the table it belongs to. They all
+# share test_common.c and the test_table.sh driver, which gives each of them
+# a scratch database of its own.
+
+test_table_sh = configure_file(
+ input: 'test_table.sh',
+ output: 'test_table.sh',
+ copy: true,
+)
+
+exchangedb_table_tests = [
+ 'account_merges',
+ 'aggregation_deferrals',
+ 'aggregation_tracking',
+ 'aggregation_transient',
+ 'aml_history',
+ 'aml_staff',
+ 'auditor_denom_sigs',
+ 'auditors',
+ 'batch_deposits',
+ 'close_requests',
+ 'coin_deposits',
+ 'coin_history',
+ 'contracts',
+ 'denomination_revocations',
+ 'denominations',
+ 'exchange_sign_keys',
+ 'global_fee',
+ 'known_coins',
+ 'legitimization_measures',
+ 'legitimization_outcomes',
+ 'legitimization_processes',
+ 'kyc_alerts',
+ 'kyc_attributes',
+ 'kyc_events',
+ 'kyc_targets',
+ 'kycauths_in',
+ 'misc',
+ 'partners',
+ 'prewire',
+ 'profit_drains',
+ 'recoup',
+ 'refunds',
+ 'recoup_refresh',
+ 'refresh',
+ 'replication',
+ 'reserves',
+ 'purse_decision',
+ 'purse_deletion',
+ 'purse_deposits',
+ 'purse_merges',
+ 'purse_requests',
+ 'reserve_history',
+ 'reserves_close',
+ 'reserves_in',
+ 'reserves_open_deposits',
+ 'reserves_open_requests',
+ 'revolving_work_shards',
+ 'signkey_revocations',
+ 'wire_accounts',
+ 'withdraw',
+ 'wire_fee',
+ 'wire_out',
+ 'work_shards',
+]
+
+foreach t : exchangedb_table_tests
+ test_bin = executable(
+ 'test_' + t,
+ ['test_' + t + '.c', 'test_common.c'],
+ install_rpath: rpath_option,
+ dependencies: [
+ libtalerexchangedb_dep,
+ libtalerkyclogic_dep,
+ libtalerutil_dep,
+ libtalerjson_dep,
+ libtalerpq_dep,
+ gnunetutil_dep,
+ gnunetjson_dep,
+ gnunetpq_dep,
+ pq_dep,
+ json_dep,
+ ],
+ include_directories: [incdir, configuration_inc],
+ install: false,
+ )
+ test(
+ 'test_' + t,
+ test_table_sh,
+ args: ['test_' + t],
+ workdir: meson.current_build_dir(),
+ suite: ['exchangedb'],
+ depends: [test_bin, exchangedb_sql_targets],
+ env: {'TALER_BUILD_ROOT': meson.project_build_root()},
+ is_parallel: false,
+ timeout: 300,
+ )
+endforeach
+
+
# [oec 20250430] disable test for now
# check_PROGRAMS = \
# test-exchangedb
diff --git a/src/exchangedb/test_account_merges.c b/src/exchangedb/test_account_merges.c
@@ -0,0 +1,728 @@
+/*
+ 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/test_account_merges.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `account_merges`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_reserve_purse(),
+ * #TALER_EXCHANGEDB_iterate_account_merges_above_serial_id() and
+ * #TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check().
+ *
+ * `account_merges` is the account holder's side of a merge: the request
+ * the wallet signs to have a purse land in its reserve. do_reserve_purse()
+ * writes it (together with the `purse_merges` row) and declines when the
+ * reserve is unknown, when its purse quota is used up, and when the purse
+ * was already merged into a different reserve.
+ */
+#include "test_common.h"
+#include "exchange-database/do_reserve_purse.h"
+#include "exchange-database/insert_purse_request.h"
+#include "exchange-database/iterate_account_merges_above_serial_id.h"
+#include "exchange-database/iterate_merge_amounts_for_kyc_check.h"
+#include "exchange-database/rollback.h"
+#include "exchange-database/start.h"
+
+
+/**
+ * Wallet account the reserves of the checks belong to.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #merge_cb().
+ */
+struct MergeContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Purse we are looking for, NULL to match nothing.
+ */
+ const struct TALER_PurseContractPublicKeyP *purse_pub;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Reserve reported for it.
+ */
+ struct TALER_ReservePublicKeyP reserve_pub;
+
+ /**
+ * Amount reported for it.
+ */
+ struct TALER_Amount amount;
+
+ /**
+ * Purse fee reported for it.
+ */
+ struct TALER_Amount purse_fee;
+};
+
+
+/**
+ * Callback for
+ * #TALER_EXCHANGEDB_iterate_account_merges_above_serial_id().
+ *
+ * @param cls a `struct MergeContext *`
+ * @param rowid row of the request
+ * @param reserve_pub reserve the purse is to land in
+ * @param purse_pub the purse
+ * @param h_contract_terms contract of the purse
+ * @param purse_expiration when the purse expires
+ * @param amount target amount of the purse
+ * @param min_age age limit of the purse
+ * @param flags flags of the purse
+ * @param purse_fee fee charged to the reserve for the purse
+ * @param merge_timestamp when the request was made
+ * @param reserve_sig signature of the account holder
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+merge_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ const struct TALER_PurseContractPublicKeyP *purse_pub,
+ const struct TALER_PrivateContractHashP *h_contract_terms,
+ struct GNUNET_TIME_Timestamp purse_expiration,
+ const struct TALER_Amount *amount,
+ uint32_t min_age,
+ enum TALER_WalletAccountMergeFlags flags,
+ const struct TALER_Amount *purse_fee,
+ struct GNUNET_TIME_Timestamp merge_timestamp,
+ const struct TALER_ReserveSignatureP *reserve_sig)
+{
+ struct MergeContext *ctx = cls;
+
+ (void) rowid;
+ (void) h_contract_terms;
+ (void) purse_expiration;
+ (void) min_age;
+ (void) flags;
+ (void) merge_timestamp;
+ (void) reserve_sig;
+ ctx->total++;
+ if ( (NULL != ctx->purse_pub) &&
+ (0 == GNUNET_memcmp (purse_pub,
+ ctx->purse_pub)) )
+ {
+ ctx->matched++;
+ ctx->reserve_pub = *reserve_pub;
+ ctx->amount = *amount;
+ ctx->purse_fee = *purse_fee;
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Closure for #amount_cb().
+ */
+struct AmountContext
+{
+ /**
+ * How many amounts did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Sum of the whole-unit parts of the amounts seen.
+ */
+ uint64_t value_sum;
+
+ /**
+ * Return this from the callback.
+ */
+ enum GNUNET_GenericReturnValue ret;
+};
+
+
+/**
+ * Callback for
+ * #TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check().
+ *
+ * @param cls a `struct AmountContext *`
+ * @param amount how much was merged
+ * @param date when it was merged
+ * @return what @e ret of the closure says
+ */
+static enum GNUNET_GenericReturnValue
+amount_cb (void *cls,
+ const struct TALER_Amount *amount,
+ struct GNUNET_TIME_Absolute date)
+{
+ struct AmountContext *ctx = cls;
+
+ (void) date;
+ ctx->total++;
+ ctx->value_sum += amount->value;
+ return ctx->ret;
+}
+
+
+/**
+ * Outcome of a reserve-purse request.
+ */
+struct PurseStatus
+{
+ /**
+ * Was the purse already merged into another reserve?
+ */
+ bool in_conflict;
+
+ /**
+ * Is the reserve unknown?
+ */
+ bool no_reserve;
+
+ /**
+ * Has the reserve run out of purses?
+ */
+ bool insufficient_funds;
+};
+
+
+/**
+ * Ask for a purse to be merged into a reserve.
+ *
+ * @param pg the database context
+ * @param purse purse to merge
+ * @param reserve_pub reserve to merge it into
+ * @param seed seed for the signatures
+ * @param purse_fee fee to charge the reserve, NULL to use the quota
+ * @param[out] st set to the outcome
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+run_reserve_purse (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Purse *purse,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ uint32_t seed,
+ const struct TALER_Amount *purse_fee,
+ struct PurseStatus *st)
+{
+ struct TALER_PurseMergeSignatureP merge_sig;
+ struct TALER_ReserveSignatureP reserve_sig;
+
+ TDB_fill (&merge_sig,
+ sizeof (merge_sig),
+ seed);
+ TDB_fill (&reserve_sig,
+ sizeof (reserve_sig),
+ seed);
+ memset (st,
+ 0,
+ sizeof (*st));
+ return TALER_EXCHANGEDB_do_reserve_purse (pg,
+ &purse->purse_pub,
+ &merge_sig,
+ ts (1600000000),
+ &reserve_sig,
+ purse_fee,
+ reserve_pub,
+ &st->in_conflict,
+ &st->no_reserve,
+ &st->insufficient_funds);
+}
+
+
+/**
+ * Create a purse that records a purse fee, the way the /reserves/$RP/purse
+ * endpoint does when the account holder offers to pay for the purse.
+ * TDB_purse() always records a zero fee, and the auditor's view reports
+ * the fee the *purse* recorded, not the one do_reserve_purse() charged.
+ *
+ * @param pg the database context
+ * @param seed seed for the purse's keys and signature
+ * @param amount target amount of the purse, e.g. "5"
+ * @param fee purse fee the account holder offers, e.g. "1"
+ * @param[out] purse set to the purse
+ */
+static void
+make_purse (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ const char *amount,
+ const char *fee,
+ struct TDB_Purse *purse)
+{
+ struct TALER_Amount purse_fee = TDB_amount (fee);
+ enum GNUNET_DB_QueryStatus qs;
+ bool in_conflict = false;
+
+ memset (purse,
+ 0,
+ sizeof (*purse));
+ TDB_fill (&purse->purse_pub,
+ sizeof (purse->purse_pub),
+ seed);
+ TDB_fill (&purse->merge_pub,
+ sizeof (purse->merge_pub),
+ seed);
+ TDB_fill (&purse->h_contract_terms,
+ sizeof (purse->h_contract_terms),
+ seed);
+ TDB_fill (&purse->purse_sig,
+ sizeof (purse->purse_sig),
+ seed);
+ purse->purse_expiration = ts (1700000000);
+ purse->amount = TDB_amount (amount);
+ qs = TALER_EXCHANGEDB_insert_purse_request (
+ pg,
+ &purse->purse_pub,
+ &purse->merge_pub,
+ purse->purse_expiration,
+ &purse->h_contract_terms,
+ 0,
+ TALER_WAMF_MODE_CREATE_WITH_PURSE_FEE,
+ &purse_fee,
+ &purse->amount,
+ &purse->purse_sig,
+ &in_conflict);
+ GNUNET_assert (0 <= qs);
+ GNUNET_assert (! in_conflict);
+}
+
+
+/**
+ * Nothing is reported while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct MergeContext ctx = { 0 };
+ struct AmountContext actx = { 0 };
+
+ TDB_account (pg,
+ 10,
+ &account);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_account_merges_above_serial_id (
+ pg,
+ 0,
+ &merge_cb,
+ &ctx));
+ actx.ret = GNUNET_OK;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check (
+ pg,
+ &account.h_normalized,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &actx));
+ FAILIF (0 != ctx.total);
+ FAILIF (0 != actx.total);
+ return 0;
+}
+
+
+/**
+ * A reserve that does not exist cannot take a purse.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_no_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_Amount purse_fee = TDB_amount ("1");
+ struct PurseStatus st;
+
+ TDB_purse (pg,
+ 9,
+ "5",
+ ts (1700000000),
+ &purse);
+ TDB_FILL (reserve_pub,
+ 99);
+ /* A non-zero purse fee is what makes an unknown reserve fatal: with a
+ zero fee the function creates the reserve instead. The `purse_merges`
+ row goes in before the reserve is looked at, so the caller has to roll
+ back -- which is what the /purses/.../merge handler does. */
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "test-account-merges-no-reserve"));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_reserve_purse (pg,
+ &purse,
+ &reserve_pub,
+ 9,
+ &purse_fee,
+ &st),
+ TALER_EXCHANGEDB_rollback (pg));
+ TALER_EXCHANGEDB_rollback (pg);
+ FAILIF (! st.no_reserve);
+ FAILIF (! st.insufficient_funds);
+ FAILIF (0 != TDB_count (pg,
+ "FROM account_merges"));
+ FAILIF (0 != TDB_count (pg,
+ "FROM purse_merges"));
+ return 0;
+}
+
+
+/**
+ * Paying the purse fee from the reserve creates the merge request.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_reserve_purse (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_Amount purse_fee = TDB_amount ("1");
+ struct TALER_Amount expect_balance = TDB_amount ("9");
+ struct MergeContext ctx;
+ struct PurseStatus st;
+ char *hex;
+
+ make_purse (pg,
+ 10,
+ "5",
+ "1",
+ &purse);
+ TDB_reserve (pg,
+ 10,
+ "10",
+ &reserve_pub);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_reserve_purse (pg,
+ &purse,
+ &reserve_pub,
+ 10,
+ &purse_fee,
+ &st));
+ FAILIF (st.no_reserve);
+ FAILIF (st.in_conflict);
+ FAILIF (st.insufficient_funds);
+ FAILIF (1 != TDB_count (pg,
+ "FROM account_merges"));
+ /* the merge is also recorded from the purse's side */
+ FAILIF (1 != TDB_count (pg,
+ "FROM purse_merges"));
+ /* the reserve paid the purse fee */
+ hex = TDB_hex (&reserve_pub,
+ sizeof (reserve_pub));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM reserves"
+ " WHERE reserve_pub=decode('%s','hex')"
+ " AND current_balance=ROW(9,0)::taler_amount",
+ hex),
+ GNUNET_free (hex));
+ GNUNET_free (hex);
+ (void) expect_balance;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.purse_pub = &purse.purse_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_account_merges_above_serial_id (
+ pg,
+ 0,
+ &merge_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (0 != GNUNET_memcmp (&ctx.reserve_pub,
+ &reserve_pub));
+ FAILIF (0 != TALER_amount_cmp (&ctx.amount,
+ &purse.amount));
+ FAILIF (0 != TALER_amount_cmp (&ctx.purse_fee,
+ &purse_fee));
+
+ /* the same purse into a different reserve is a conflict */
+ {
+ struct TALER_ReservePublicKeyP other;
+
+ TDB_reserve (pg,
+ 11,
+ "10",
+ &other);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_reserve_purse (pg,
+ &purse,
+ &other,
+ 11,
+ &purse_fee,
+ &st));
+ FAILIF (! st.in_conflict);
+ FAILIF (1 != TDB_count (pg,
+ "FROM account_merges"));
+ }
+ return 0;
+}
+
+
+/**
+ * A reserve that cannot pay the purse fee is refused.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insufficient_funds (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_Amount purse_fee = TDB_amount ("5");
+ struct PurseStatus st;
+
+ TDB_purse (pg,
+ 12,
+ "5",
+ ts (1700000000),
+ &purse);
+ TDB_reserve (pg,
+ 12,
+ "1",
+ &reserve_pub);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_reserve_purse (pg,
+ &purse,
+ &reserve_pub,
+ 12,
+ &purse_fee,
+ &st));
+ FAILIF (! st.insufficient_funds);
+ FAILIF (1 != TDB_count (pg,
+ "FROM account_merges"));
+ return 0;
+}
+
+
+/**
+ * The wallet's KYC view sums the merges that landed in its account.
+ *
+ * The account in question is the reserve's own `taler-reserve://` account,
+ * which is what do_reserve_purse() records in `wallet_h_payto` -- not a
+ * bank account.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_kyc_amounts (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct AmountContext ctx;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_NormalizedPayto payto;
+ struct TALER_NormalizedPaytoHashP h_payto;
+ struct TALER_NormalizedPaytoHashP other;
+
+ TDB_fill (&reserve_pub,
+ sizeof (reserve_pub),
+ 10);
+ payto = TALER_reserve_make_payto (pg->exchange_url,
+ &reserve_pub);
+ TALER_normalized_payto_hash (payto,
+ &h_payto);
+ GNUNET_free (payto.normalized_payto);
+
+ /* the merge of check_reserve_purse() is charged to our account, but the
+ purse has not been decided yet, so the KYC view has nothing */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_OK;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check (
+ pg,
+ &h_payto,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* once the purse is decided in favour of the reserve, it counts */
+ FAILIF (GNUNET_OK !=
+ TDB_exec (pg,
+ "INSERT INTO purse_decision"
+ " (purse_pub,action_timestamp,refunded)"
+ " SELECT purse_pub,1000000,FALSE"
+ " FROM account_merges;"));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_OK;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check (
+ pg,
+ &h_payto,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &ctx));
+ FAILIF (0 == ctx.total);
+ FAILIF (0 == ctx.value_sum);
+
+ /* an account nobody merged into has nothing */
+ TDB_FILL (other,
+ 97);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_OK;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check (
+ pg,
+ &other,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* a time limit past the merges hides them */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_OK;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check (
+ pg,
+ &h_payto,
+ ts (1700000000).abs_time,
+ &amount_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* and an aborting callback is not an error */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_NO;
+ FAILIF (0 >
+ TALER_EXCHANGEDB_iterate_merge_amounts_for_kyc_check (
+ pg,
+ &h_payto,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The iterator's bound and abort return behave as documented.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct MergeContext ctx;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_account_merges_above_serial_id (
+ pg,
+ 1000,
+ &merge_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_account_merges_above_serial_id (
+ pg,
+ 0,
+ &merge_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "account-merges-empty",
+ &check_empty },
+ { "account-merges-no-reserve",
+ &check_no_reserve },
+ { "account-merges-reserve-purse",
+ &check_reserve_purse },
+ { "account-merges-insufficient-funds",
+ &check_insufficient_funds },
+ { "account-merges-kyc-amounts",
+ &check_kyc_amounts },
+ { "account-merges-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-account-merges",
+ "Tests for the exchangedb `account_merges' table",
+ tests);
+ TDB_account_free (&account);
+ return ret;
+}
+
+
+/* end of test_account_merges.c */
diff --git a/src/exchangedb/test_aggregation_deferrals.c b/src/exchangedb/test_aggregation_deferrals.c
@@ -0,0 +1,281 @@
+/*
+ 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/test_aggregation_deferrals.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `aggregation_deferrals`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_aggregation_deferral() and
+ * #TALER_EXCHANGEDB_get_aggregation_deferral_by_wtid().
+ *
+ * The table is the exchange's append-only account of why it did not make a
+ * wire transfer it had already decided on. Each row hangs off the deposit
+ * of the aggregate that is last to reach its wire deadline, so the insert
+ * finds nothing to attach to until `aggregation_tracking` has been written
+ * -- which is the first thing the checks pin down. The lookup returns the
+ * most recent claim, so the checks defer twice.
+ */
+#include "test_common.h"
+#include "exchange-database/do_aggregate.h"
+#include "exchange-database/get_aggregation_deferral_by_wtid.h"
+#include "exchange-database/insert_aggregation_deferral.h"
+
+
+/**
+ * Account the merchants of the checks are paid at.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Denomination the checks deposit.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * The wire transfer the checks aggregate into.
+ */
+static struct TALER_WireTransferIdentifierRawP wtid;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Without an aggregation to hang off, nothing is recorded and nothing is
+ * found.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_no_aggregation (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_Amount amount = TDB_amount ("1");
+ struct TALER_Amount got;
+ struct GNUNET_TIME_Timestamp deferral_time;
+ enum TALER_EXCHANGEDB_DeferralReason reason;
+ uint64_t requirement_row;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_FILL (wtid,
+ 10);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_aggregation_deferral_by_wtid (pg,
+ &wtid,
+ &got,
+ &reason,
+ &requirement_row,
+ &deferral_time));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_insert_aggregation_deferral (
+ pg,
+ &wtid,
+ &account.h_full,
+ &amount,
+ TALER_EXCHANGEDB_DR_AMOUNT_TOO_SMALL,
+ 0,
+ ts (1600000000)));
+ FAILIF (0 != TDB_count (pg,
+ "FROM aggregation_deferrals"));
+ return 0;
+}
+
+
+/**
+ * With an aggregation on file, the deferral is recorded and read back.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_defer (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TDB_Deposit dep;
+ struct TALER_Amount total;
+ struct TALER_Amount amount = TDB_amount ("0.9");
+ struct TALER_Amount got;
+ struct GNUNET_TIME_Timestamp deferral_time;
+ enum TALER_EXCHANGEDB_DeferralReason reason;
+ uint64_t requirement_row = 42;
+
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ NULL);
+ TDB_deposit (pg,
+ &account,
+ &coin,
+ 20,
+ "1",
+ "0.1",
+ ts (1600000000),
+ ts (1600000000),
+ &dep);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_aggregate (pg,
+ &account.h_full,
+ &dep.merchant_pub,
+ &wtid,
+ &total),
+ TDB_coin_free (&coin));
+ TDB_coin_free (&coin);
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_aggregation_deferral (
+ pg,
+ &wtid,
+ &account.h_full,
+ &amount,
+ TALER_EXCHANGEDB_DR_AMOUNT_TOO_SMALL,
+ 0,
+ ts (1600000000)));
+ FAILIF (1 != TDB_count (pg,
+ "FROM aggregation_deferrals"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_aggregation_deferral_by_wtid (pg,
+ &wtid,
+ &got,
+ &reason,
+ &requirement_row,
+ &deferral_time));
+ FAILIF (0 != TALER_amount_cmp (&got,
+ &amount));
+ FAILIF (TALER_EXCHANGEDB_DR_AMOUNT_TOO_SMALL != reason);
+ FAILIF (0 != requirement_row);
+ FAILIF (GNUNET_TIME_timestamp_cmp (deferral_time,
+ !=,
+ ts (1600000000)));
+
+ /* a wire transfer nobody deferred is still unknown */
+ {
+ struct TALER_WireTransferIdentifierRawP other;
+
+ TDB_FILL (other,
+ 99);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_aggregation_deferral_by_wtid (
+ pg,
+ &other,
+ &got,
+ &reason,
+ &requirement_row,
+ &deferral_time));
+ }
+ return 0;
+}
+
+
+/**
+ * The table is append-only: a second deferral is a second row, and the
+ * lookup returns the newer one.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_append_only (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_Amount amount = TDB_amount ("0.9");
+ struct TALER_Amount got;
+ struct GNUNET_TIME_Timestamp deferral_time;
+ enum TALER_EXCHANGEDB_DeferralReason reason;
+ uint64_t requirement_row = 0;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_aggregation_deferral (
+ pg,
+ &wtid,
+ &account.h_full,
+ &amount,
+ TALER_EXCHANGEDB_DR_KYC,
+ 7,
+ ts (1600003600)));
+ FAILIF (2 != TDB_count (pg,
+ "FROM aggregation_deferrals"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_aggregation_deferral_by_wtid (pg,
+ &wtid,
+ &got,
+ &reason,
+ &requirement_row,
+ &deferral_time));
+ FAILIF (TALER_EXCHANGEDB_DR_KYC != reason);
+ FAILIF (7 != requirement_row);
+ FAILIF (GNUNET_TIME_timestamp_cmp (deferral_time,
+ !=,
+ ts (1600003600)));
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "aggregation-deferrals-no-aggregation",
+ &check_no_aggregation },
+ { "aggregation-deferrals-defer",
+ &check_defer },
+ { "aggregation-deferrals-append-only",
+ &check_append_only },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-aggregation-deferrals",
+ "Tests for the exchangedb `aggregation_deferrals' table",
+ tests);
+ TDB_account_free (&account);
+ TDB_denom_free (&denom);
+ return ret;
+}
+
+
+/* end of test_aggregation_deferrals.c */
diff --git a/src/exchangedb/test_aggregation_tracking.c b/src/exchangedb/test_aggregation_tracking.c
@@ -0,0 +1,729 @@
+/*
+ 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/test_aggregation_tracking.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `aggregation_tracking`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_aggregate(),
+ * #TALER_EXCHANGEDB_get_pending_aggregation(),
+ * #TALER_EXCHANGEDB_get_transfer_by_deposit(),
+ * #TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id(),
+ * #TALER_EXCHANGEDB_iterate_aggregations_above_serial_id() and
+ * #TALER_EXCHANGEDB_iterate_wire_transfers().
+ *
+ * `aggregation_tracking` records which deposit went into which wire
+ * transfer. do_aggregate() writes it, netting deposits against refunds and
+ * charging the deposit fee only for coins that were not refunded in full --
+ * the checks below pin that arithmetic down.
+ */
+#include "test_common.h"
+#include "exchange-database/do_aggregate.h"
+#include "exchange-database/do_refund.h"
+#include "exchange-database/get_pending_aggregation.h"
+#include "exchange-database/get_transfer_by_deposit.h"
+#include "exchange-database/insert_wire_out.h"
+#include "exchange-database/iterate_aggregation_wtids_above_serial_id.h"
+#include "exchange-database/iterate_aggregations_above_serial_id.h"
+#include "exchange-database/iterate_wire_transfers.h"
+
+
+/**
+ * Account the merchants of the checks are paid at.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Denomination the checks deposit.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #wtid_cb().
+ */
+struct WtidContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Wire transfer we are looking for, NULL to match nothing.
+ */
+ const struct TALER_WireTransferIdentifierRawP *wtid;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Was the last matching row reported as pending?
+ */
+ bool pending;
+};
+
+
+/**
+ * Callback for
+ * #TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id().
+ *
+ * @param cls a `struct WtidContext *`
+ * @param rowid row of the tracking entry
+ * @param wtid wire transfer the deposit was aggregated into
+ * @param wire_target_h_payto where the transfer goes
+ * @param pending whether the transfer has not been made yet
+ * @return #GNUNET_OK
+ */
+static enum GNUNET_GenericReturnValue
+wtid_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ const struct TALER_FullPaytoHashP *wire_target_h_payto,
+ bool pending)
+{
+ struct WtidContext *ctx = cls;
+
+ (void) rowid;
+ (void) wire_target_h_payto;
+ ctx->total++;
+ if ( (NULL != ctx->wtid) &&
+ (0 == GNUNET_memcmp (wtid,
+ ctx->wtid)) )
+ {
+ ctx->matched++;
+ ctx->pending = pending;
+ }
+ return GNUNET_OK;
+}
+
+
+/**
+ * Closure for #aggregation_cb() and #transfer_cb().
+ */
+struct SumContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Sum of the whole-unit parts of the amounts seen.
+ */
+ uint64_t value_sum;
+
+ /**
+ * Row of the first entry seen.
+ */
+ uint64_t first_row;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_aggregations_above_serial_id().
+ *
+ * @param cls a `struct SumContext *`
+ * @param amount how much was aggregated
+ * @param tracking_serial_id row of the tracking entry
+ * @param batch_deposit_serial_id deposit that was aggregated
+ */
+static void
+aggregation_cb (void *cls,
+ const struct TALER_Amount *amount,
+ uint64_t tracking_serial_id,
+ uint64_t batch_deposit_serial_id)
+{
+ struct SumContext *ctx = cls;
+
+ (void) batch_deposit_serial_id;
+ if (0 == ctx->total++)
+ ctx->first_row = tracking_serial_id;
+ ctx->value_sum += amount->value;
+}
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_wire_transfers().
+ *
+ * @param cls a `struct SumContext *`
+ * @param rowid row of the tracking entry
+ * @param merchant_pub merchant that was paid
+ * @param account_payto_uri account of the merchant
+ * @param h_payto hash of that account
+ * @param exchange_payto_uri account of the exchange
+ * @param exec_time when the transfer was made
+ * @param h_contract_terms contract that was paid
+ * @param denom_pub denomination of the coin
+ * @param coin_pub the coin that was deposited
+ * @param coin_value how much the coin contributed
+ * @param coin_fee how much the exchange charged for it
+ */
+static void
+transfer_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_MerchantPublicKeyP *merchant_pub,
+ const struct TALER_FullPayto account_payto_uri,
+ const struct TALER_FullPaytoHashP *h_payto,
+ const struct TALER_FullPayto exchange_payto_uri,
+ struct GNUNET_TIME_Timestamp exec_time,
+ const struct TALER_PrivateContractHashP *h_contract_terms,
+ const struct TALER_DenominationPublicKey *denom_pub,
+ const struct TALER_CoinSpendPublicKeyP *coin_pub,
+ const struct TALER_Amount *coin_value,
+ const struct TALER_Amount *coin_fee)
+{
+ struct SumContext *ctx = cls;
+
+ (void) merchant_pub;
+ (void) account_payto_uri;
+ (void) h_payto;
+ (void) exchange_payto_uri;
+ (void) exec_time;
+ (void) h_contract_terms;
+ (void) denom_pub;
+ (void) coin_pub;
+ (void) coin_fee;
+ if (0 == ctx->total++)
+ ctx->first_row = rowid;
+ ctx->value_sum += coin_value->value;
+}
+
+
+/**
+ * Nothing is reported while the table is empty, and aggregating an
+ * account with no deposits comes out at zero.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_MerchantPublicKeyP merchant_pub;
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct TALER_FullPaytoHashP h_payto;
+ struct TALER_FullPayto payto_uri = { NULL };
+ struct TALER_Amount total;
+ struct TALER_Amount zero = TDB_amount ("0");
+ struct TALER_Amount deposited;
+ struct TALER_Amount refunded;
+ struct TALER_Amount fee;
+ struct WtidContext ctx = { 0 };
+ struct SumContext sctx = { 0 };
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_FILL (merchant_pub,
+ 1);
+ TDB_FILL (wtid,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_aggregate (pg,
+ &account.h_full,
+ &merchant_pub,
+ &wtid,
+ &total));
+ FAILIF (0 != TALER_amount_cmp (&total,
+ &zero));
+ FAILIF (0 != TDB_count (pg,
+ "FROM aggregation_tracking"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_pending_aggregation (pg,
+ &wtid,
+ &h_payto,
+ &payto_uri,
+ &deposited,
+ &refunded,
+ &fee));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id (
+ pg,
+ 0,
+ &wtid_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aggregations_above_serial_id (
+ pg,
+ 0,
+ &aggregation_cb,
+ &sctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_wire_transfers (pg,
+ &wtid,
+ &transfer_cb,
+ &sctx));
+ FAILIF (0 != ctx.total);
+ FAILIF (0 != sctx.total);
+ return 0;
+}
+
+
+/**
+ * Aggregating a deposit records it and nets out the deposit fee.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_aggregate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TDB_Deposit dep;
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct TALER_FullPaytoHashP h_payto;
+ struct TALER_FullPayto payto_uri = { NULL };
+ struct TALER_Amount total;
+ struct TALER_Amount deposited;
+ struct TALER_Amount refunded;
+ struct TALER_Amount fee;
+ struct TALER_Amount expect_total = TDB_amount ("0.9");
+ struct TALER_Amount expect_deposited = TDB_amount ("1");
+ struct TALER_Amount expect_refunded = TDB_amount ("0");
+ struct TALER_Amount expect_fee = TDB_amount ("0.1");
+ struct WtidContext ctx;
+
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ NULL);
+ TDB_deposit (pg,
+ &account,
+ &coin,
+ 20,
+ "1",
+ "0.1",
+ ts (1600000000),
+ ts (1600000000),
+ &dep);
+ TDB_FILL (wtid,
+ 20);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_aggregate (pg,
+ &account.h_full,
+ &dep.merchant_pub,
+ &wtid,
+ &total),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != TALER_amount_cmp (&total,
+ &expect_total),
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM aggregation_tracking"),
+ TDB_coin_free (&coin));
+ /* the deposit is now marked as done */
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM batch_deposits WHERE done"),
+ TDB_coin_free (&coin));
+
+ /* the transfer has not been made yet, so it is pending */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_pending_aggregation (pg,
+ &wtid,
+ &h_payto,
+ &payto_uri,
+ &deposited,
+ &refunded,
+ &fee),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != GNUNET_memcmp (&h_payto,
+ &account.h_full),
+ GNUNET_free (payto_uri.full_payto); TDB_coin_free (&coin));
+ FAILIF_C (0 != strcmp (payto_uri.full_payto,
+ account.payto.full_payto),
+ GNUNET_free (payto_uri.full_payto); TDB_coin_free (&coin));
+ GNUNET_free (payto_uri.full_payto);
+ FAILIF_C (0 != TALER_amount_cmp (&deposited,
+ &expect_deposited),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != TALER_amount_cmp (&refunded,
+ &expect_refunded),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != TALER_amount_cmp (&fee,
+ &expect_fee),
+ TDB_coin_free (&coin));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.wtid = &wtid;
+ FAILIF_C (0 >=
+ TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id (
+ pg,
+ 0,
+ &wtid_cb,
+ &ctx),
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != ctx.matched,
+ TDB_coin_free (&coin));
+ FAILIF_C (! ctx.pending,
+ TDB_coin_free (&coin));
+ TDB_coin_free (&coin);
+ return 0;
+}
+
+
+/**
+ * A deposit that was refunded in full is aggregated at zero and is not
+ * charged a deposit fee.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_full_refund (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TDB_Deposit dep;
+ struct TALER_EXCHANGEDB_Refund refund;
+ struct TALER_Amount deposit_fee = TDB_amount ("0.1");
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct TALER_Amount total;
+ struct TALER_Amount zero = TDB_amount ("0");
+ bool not_found;
+ bool refund_ok;
+ bool gone;
+ bool conflict;
+
+ TDB_coin (pg,
+ &denom,
+ 21,
+ &coin,
+ NULL);
+ TDB_deposit (pg,
+ &account,
+ &coin,
+ 21,
+ "1",
+ "0.1",
+ ts (1600000000),
+ ts (1600000000),
+ &dep);
+ memset (&refund,
+ 0,
+ sizeof (refund));
+ refund.coin = coin;
+ refund.details.merchant_pub = dep.merchant_pub;
+ TDB_FILL (refund.details.merchant_sig,
+ 21);
+ refund.details.h_contract_terms = dep.h_contract_terms;
+ refund.details.rtransaction_id = 1;
+ refund.details.refund_amount = TDB_amount ("1");
+ refund.details.refund_fee = TDB_amount ("0");
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_refund (pg,
+ &refund,
+ &deposit_fee,
+ 0,
+ ¬_found,
+ &refund_ok,
+ &gone,
+ &conflict),
+ TDB_coin_free (&coin));
+ FAILIF_C (! refund_ok,
+ TDB_coin_free (&coin));
+
+ TDB_FILL (wtid,
+ 21);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_aggregate (pg,
+ &account.h_full,
+ &dep.merchant_pub,
+ &wtid,
+ &total),
+ TDB_coin_free (&coin));
+ /* deposit EUR:1 minus refund EUR:1, and no deposit fee on a coin that
+ was refunded in full */
+ FAILIF_C (0 != TALER_amount_cmp (&total,
+ &zero),
+ TDB_coin_free (&coin));
+ TDB_coin_free (&coin);
+ return 0;
+}
+
+
+/**
+ * Executing the wire transfer takes the aggregation out of "pending".
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_executed (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct TALER_FullPaytoHashP h_payto;
+ struct TALER_FullPayto payto_uri = { NULL };
+ struct TALER_FullPayto exchange_payto = {
+ .full_payto = (char *) "payto://x-taler-bank/localhost/exchange"
+ };
+ struct TALER_Amount amount = TDB_amount ("0.9");
+ struct TALER_Amount deposited;
+ struct TALER_Amount refunded;
+ struct TALER_Amount fee;
+ struct WtidContext ctx;
+
+ TDB_FILL (wtid,
+ 20);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_wire_out (pg,
+ ts (1600003600),
+ &wtid,
+ &account.h_full,
+ "exchange-account-1",
+ exchange_payto,
+ &amount,
+ NULL));
+ /* the aggregation is no longer pending... */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_pending_aggregation (pg,
+ &wtid,
+ &h_payto,
+ &payto_uri,
+ &deposited,
+ &refunded,
+ &fee));
+ /* ...and the wtid iterator says so too */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.wtid = &wtid;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aggregation_wtids_above_serial_id (
+ pg,
+ 0,
+ &wtid_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (ctx.pending);
+ return 0;
+}
+
+
+/**
+ * The deposit can be traced to the wire transfer it went into, and the
+ * transfer back to the deposits it paid.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_transfer_by_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinSpendPublicKeyP coin_pub;
+ struct TDB_Deposit dep;
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct TALER_WireTransferIdentifierRawP got_wtid;
+ struct GNUNET_TIME_Timestamp exec_time;
+ struct TALER_Amount amount_with_fee;
+ struct TALER_Amount deposit_fee;
+ struct TALER_Amount expect_amount = TDB_amount ("1");
+ struct TALER_EXCHANGEDB_KycStatus kyc;
+ union TALER_AccountPublicKeyP account_pub;
+ struct SumContext sctx;
+ bool pending = true;
+
+ /* the deposit of check_aggregate() */
+ TDB_FILL (coin_pub,
+ 20);
+ TDB_FILL (wtid,
+ 20);
+ memset (&dep,
+ 0,
+ sizeof (dep));
+ TDB_FILL (dep.merchant_pub,
+ 20);
+ TDB_FILL (dep.h_contract_terms,
+ 20);
+ TDB_FILL (dep.wire_salt,
+ 20);
+ TALER_merchant_wire_signature_hash (account.payto,
+ &dep.wire_salt,
+ &dep.h_wire);
+ memset (&kyc,
+ 0,
+ sizeof (kyc));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_transfer_by_deposit (pg,
+ &dep.h_contract_terms,
+ &dep.h_wire,
+ &coin_pub,
+ &dep.merchant_pub,
+ &pending,
+ &got_wtid,
+ &exec_time,
+ &amount_with_fee,
+ &deposit_fee,
+ &kyc,
+ &account_pub));
+ FAILIF (pending);
+ FAILIF (0 != GNUNET_memcmp (&got_wtid,
+ &wtid));
+ FAILIF (0 != TALER_amount_cmp (&amount_with_fee,
+ &expect_amount));
+
+ /* a contract nobody deposited is not found */
+ {
+ struct TALER_PrivateContractHashP other;
+
+ TDB_FILL (other,
+ 97);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_transfer_by_deposit (pg,
+ &other,
+ &dep.h_wire,
+ &coin_pub,
+ &dep.merchant_pub,
+ &pending,
+ &got_wtid,
+ &exec_time,
+ &amount_with_fee,
+ &deposit_fee,
+ &kyc,
+ &account_pub));
+ }
+
+ /* and the transfer lists the coins it paid for */
+ memset (&sctx,
+ 0,
+ sizeof (sctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_wire_transfers (pg,
+ &wtid,
+ &transfer_cb,
+ &sctx));
+ FAILIF (1 != sctx.total);
+ FAILIF (1 != sctx.value_sum);
+ {
+ struct TALER_WireTransferIdentifierRawP other;
+
+ TDB_FILL (other,
+ 96);
+ memset (&sctx,
+ 0,
+ sizeof (sctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_wire_transfers (pg,
+ &other,
+ &transfer_cb,
+ &sctx));
+ FAILIF (0 != sctx.total);
+ }
+ return 0;
+}
+
+
+/**
+ * The aggregation iterator walks the table by serial.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate_aggregations (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct SumContext ctx;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_aggregations_above_serial_id (
+ pg,
+ 0,
+ &aggregation_cb,
+ &ctx));
+ FAILIF (2 != ctx.total);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aggregations_above_serial_id (
+ pg,
+ 1000,
+ &aggregation_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "aggregation-tracking-empty",
+ &check_empty },
+ { "aggregation-tracking-aggregate",
+ &check_aggregate },
+ { "aggregation-tracking-full-refund",
+ &check_full_refund },
+ { "aggregation-tracking-executed",
+ &check_executed },
+ { "aggregation-tracking-transfer-by-deposit",
+ &check_transfer_by_deposit },
+ { "aggregation-tracking-iterate-aggregations",
+ &check_iterate_aggregations },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-aggregation-tracking",
+ "Tests for the exchangedb `aggregation_tracking' table",
+ tests);
+ TDB_account_free (&account);
+ TDB_denom_free (&denom);
+ return ret;
+}
+
+
+/* end of test_aggregation_tracking.c */
diff --git a/src/exchangedb/test_aggregation_transient.c b/src/exchangedb/test_aggregation_transient.c
@@ -0,0 +1,356 @@
+/*
+ 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/test_aggregation_transient.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `aggregation_transient`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_aggregation_transient(),
+ * #TALER_EXCHANGEDB_update_aggregation_transient(),
+ * #TALER_EXCHANGEDB_delete_aggregation_transient(),
+ * #TALER_EXCHANGEDB_get_aggregation_transient(),
+ * #TALER_EXCHANGEDB_get_aggregation_transient_by_wtid() and
+ * #TALER_EXCHANGEDB_get_aggregation_transient_by_normalized_payto().
+ *
+ * The table holds what the aggregator decided to hold back for later; it
+ * is updated in place and deleted on payout, so it has no serial ID. It
+ * references `wire_targets`, which TDB_account() creates. The three
+ * lookups reach the row by three different keys, which is what the checks
+ * exercise.
+ */
+#include "test_common.h"
+#include "exchange-database/delete_aggregation_transient.h"
+#include "exchange-database/get_aggregation_transient.h"
+#include "exchange-database/get_aggregation_transient_by_normalized_payto.h"
+#include "exchange-database/get_aggregation_transient_by_wtid.h"
+#include "exchange-database/insert_aggregation_transient.h"
+#include "exchange-database/update_aggregation_transient.h"
+
+
+/**
+ * Exchange bank account the checks aggregate for.
+ */
+#define SECTION "exchange-account-1"
+
+
+/**
+ * Account the merchants of the checks are paid at.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Nothing is found while the table is empty, and updating or deleting a
+ * row that is not there does nothing.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_MerchantPublicKeyP merchant_pub;
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct TALER_Amount total;
+ struct TALER_Amount one = TDB_amount ("1");
+ struct TALER_FullPayto payto_uri = { NULL };
+ uint64_t requirement_row;
+
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_FILL (merchant_pub,
+ 1);
+ TDB_FILL (wtid,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_aggregation_transient (pg,
+ &account.h_full,
+ &merchant_pub,
+ SECTION,
+ &wtid,
+ &total));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_aggregation_transient_by_wtid (
+ pg,
+ &account.h_full,
+ &wtid,
+ &total,
+ &requirement_row));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_aggregation_transient_by_normalized_payto (
+ pg,
+ &account.h_normalized,
+ &payto_uri,
+ &wtid,
+ &merchant_pub,
+ &total));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_aggregation_transient (pg,
+ &account.h_full,
+ &wtid,
+ 0,
+ &one));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_delete_aggregation_transient (pg,
+ &account.h_full,
+ &wtid));
+ FAILIF (0 != TDB_count (pg,
+ "FROM aggregation_transient"));
+ return 0;
+}
+
+
+/**
+ * An inserted row is found by all three lookups.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_MerchantPublicKeyP merchant_pub;
+ struct TALER_MerchantPublicKeyP got_merchant;
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct TALER_WireTransferIdentifierRawP got_wtid;
+ struct TALER_Amount total = TDB_amount ("3");
+ struct TALER_Amount got;
+ struct TALER_FullPayto payto_uri = { NULL };
+ uint64_t requirement_row = 42;
+
+ TDB_FILL (merchant_pub,
+ 10);
+ TDB_FILL (wtid,
+ 10);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_aggregation_transient (pg,
+ &account.h_full,
+ SECTION,
+ &merchant_pub,
+ &wtid,
+ 0,
+ &total));
+ FAILIF (1 != TDB_count (pg,
+ "FROM aggregation_transient"));
+
+ /* by (account, merchant, exchange account) */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_aggregation_transient (pg,
+ &account.h_full,
+ &merchant_pub,
+ SECTION,
+ &got_wtid,
+ &got));
+ FAILIF (0 != GNUNET_memcmp (&got_wtid,
+ &wtid));
+ FAILIF (0 != TALER_amount_cmp (&got,
+ &total));
+
+ /* by (account, wtid) */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_aggregation_transient_by_wtid (
+ pg,
+ &account.h_full,
+ &wtid,
+ &got,
+ &requirement_row));
+ FAILIF (0 != TALER_amount_cmp (&got,
+ &total));
+ /* no legitimization reason was recorded */
+ FAILIF (0 != requirement_row);
+
+ /* by normalized account */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_aggregation_transient_by_normalized_payto (
+ pg,
+ &account.h_normalized,
+ &payto_uri,
+ &got_wtid,
+ &got_merchant,
+ &got));
+ FAILIF_C (0 != strcmp (payto_uri.full_payto,
+ account.payto.full_payto),
+ GNUNET_free (payto_uri.full_payto));
+ GNUNET_free (payto_uri.full_payto);
+ FAILIF (0 != GNUNET_memcmp (&got_merchant,
+ &merchant_pub));
+ FAILIF (0 != GNUNET_memcmp (&got_wtid,
+ &wtid));
+
+ /* a different merchant on the same account has nothing */
+ {
+ struct TALER_MerchantPublicKeyP other;
+
+ TDB_FILL (other,
+ 99);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_aggregation_transient (pg,
+ &account.h_full,
+ &other,
+ SECTION,
+ &got_wtid,
+ &got));
+ }
+ /* and neither does a different exchange bank account */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_aggregation_transient (pg,
+ &account.h_full,
+ &merchant_pub,
+ "exchange-account-2",
+ &got_wtid,
+ &got));
+ return 0;
+}
+
+
+/**
+ * Updating a row changes the amount and the legitimization reason.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_update (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_MerchantPublicKeyP merchant_pub;
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct TALER_WireTransferIdentifierRawP got_wtid;
+ struct TALER_Amount total = TDB_amount ("7");
+ struct TALER_Amount got;
+ uint64_t requirement_row = 0;
+
+ TDB_FILL (merchant_pub,
+ 10);
+ TDB_FILL (wtid,
+ 10);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_aggregation_transient (pg,
+ &account.h_full,
+ &wtid,
+ 5,
+ &total));
+ FAILIF (1 != TDB_count (pg,
+ "FROM aggregation_transient"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_aggregation_transient (pg,
+ &account.h_full,
+ &merchant_pub,
+ SECTION,
+ &got_wtid,
+ &got));
+ FAILIF (0 != TALER_amount_cmp (&got,
+ &total));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_aggregation_transient_by_wtid (
+ pg,
+ &account.h_full,
+ &wtid,
+ &got,
+ &requirement_row));
+ FAILIF (5 != requirement_row);
+
+ /* a wire transfer identifier that is not on file is not created */
+ {
+ struct TALER_WireTransferIdentifierRawP other;
+
+ TDB_FILL (other,
+ 98);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_aggregation_transient (pg,
+ &account.h_full,
+ &other,
+ 0,
+ &total));
+ FAILIF (1 != TDB_count (pg,
+ "FROM aggregation_transient"));
+ }
+ return 0;
+}
+
+
+/**
+ * Deleting a row removes it, and a second delete does nothing.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_delete (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_MerchantPublicKeyP merchant_pub;
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct TALER_Amount got;
+
+ TDB_FILL (merchant_pub,
+ 10);
+ TDB_FILL (wtid,
+ 10);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_delete_aggregation_transient (pg,
+ &account.h_full,
+ &wtid));
+ FAILIF (0 != TDB_count (pg,
+ "FROM aggregation_transient"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_delete_aggregation_transient (pg,
+ &account.h_full,
+ &wtid));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_aggregation_transient (pg,
+ &account.h_full,
+ &merchant_pub,
+ SECTION,
+ &wtid,
+ &got));
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "aggregation-transient-empty",
+ &check_empty },
+ { "aggregation-transient-insert-and-lookup",
+ &check_insert_and_lookup },
+ { "aggregation-transient-update",
+ &check_update },
+ { "aggregation-transient-delete",
+ &check_delete },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-aggregation-transient",
+ "Tests for the exchangedb `aggregation_transient' table",
+ tests);
+ TDB_account_free (&account);
+ return ret;
+}
+
+
+/* end of test_aggregation_transient.c */
diff --git a/src/exchangedb/test_aml_history.c b/src/exchangedb/test_aml_history.c
@@ -0,0 +1,788 @@
+/*
+ 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/test_aml_history.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `aml_history`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_aml_decision(),
+ * #TALER_EXCHANGEDB_iterate_aml_history(),
+ * #TALER_EXCHANGEDB_iterate_aml_history_above_serial_id(),
+ * #TALER_EXCHANGEDB_iterate_aml_decisions() and
+ * #TALER_EXCHANGEDB_aml_history_builder().
+ *
+ * `aml_history` is what an AML officer signed: one row per decision, each
+ * pointing at the `legitimization_outcomes` row it produced. The insert
+ * refuses an officer who may not decide and a decision older than the one
+ * already on file, and both of those are checked here.
+ */
+#include "test_common.h"
+#include "exchange-database/account_history.h"
+#include "exchange-database/insert_aml_decision.h"
+#include "exchange-database/insert_aml_officer.h"
+#include "exchange-database/iterate_aml_decisions.h"
+#include "exchange-database/iterate_aml_history.h"
+#include "exchange-database/iterate_aml_history_above_serial_id.h"
+
+
+/**
+ * Account the checks decide about.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Officer who takes the decisions.
+ */
+static struct TALER_AmlOfficerPublicKeyP officer_pub;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Build a rule set naming @a name as its only rule.
+ *
+ * @param name name of the rule
+ * @return the rule set, to be freed with json_decref()
+ */
+static json_t *
+make_rules (const char *name)
+{
+ json_t *j;
+
+ j = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("rule_name",
+ name),
+ GNUNET_JSON_pack_array_steal ("rules",
+ json_array ()));
+ GNUNET_assert (NULL != j);
+ return j;
+}
+
+
+/**
+ * Closure for #history_cb() and #decision_cb().
+ */
+struct HistoryContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Justification of the last row seen, owned by this struct.
+ */
+ char *justification;
+
+ /**
+ * Whether the last row asked for an investigation.
+ */
+ bool to_investigate;
+
+ /**
+ * Whether the last row is the active decision.
+ */
+ bool is_active;
+
+ /**
+ * Whether the last row named an officer.
+ */
+ bool have_officer;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_aml_history().
+ *
+ * @param cls a `struct HistoryContext *`
+ * @param outcome_serial_id row of the outcome the decision produced
+ * @param decision_time when the decision was taken
+ * @param justification why it was taken
+ * @param decider_pub which officer took it
+ * @param jproperties new account properties
+ * @param jnew_rules new account rules
+ * @param to_investigate whether staff should investigate
+ * @param is_active whether this is the active decision
+ */
+static void
+history_cb (void *cls,
+ uint64_t outcome_serial_id,
+ struct GNUNET_TIME_Timestamp decision_time,
+ const char *justification,
+ const struct TALER_AmlOfficerPublicKeyP *decider_pub,
+ const json_t *jproperties,
+ const json_t *jnew_rules,
+ bool to_investigate,
+ bool is_active)
+{
+ struct HistoryContext *ctx = cls;
+
+ (void) outcome_serial_id;
+ (void) decision_time;
+ (void) jproperties;
+ (void) jnew_rules;
+ ctx->total++;
+ GNUNET_free (ctx->justification);
+ ctx->justification = (NULL == justification)
+ ? NULL
+ : GNUNET_strdup (justification);
+ ctx->to_investigate = to_investigate;
+ ctx->is_active = is_active;
+ ctx->have_officer = (NULL != decider_pub);
+}
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_aml_decisions().
+ *
+ * @param cls a `struct HistoryContext *`
+ * @param row_id row of the decision
+ * @param justification why it was taken
+ * @param h_payto account it is about
+ * @param decision_time when it was taken
+ * @param expiration_time when the rules expire
+ * @param jproperties new account properties
+ * @param to_investigate whether staff should investigate
+ * @param is_active whether this is the active decision
+ * @param is_wallet whether the account is a wallet
+ * @param payto the account's payto URI
+ * @param account_rules the account's rules
+ */
+static void
+decision_cb (void *cls,
+ uint64_t row_id,
+ const char *justification,
+ const struct TALER_NormalizedPaytoHashP *h_payto,
+ struct GNUNET_TIME_Timestamp decision_time,
+ struct GNUNET_TIME_Absolute expiration_time,
+ const json_t *jproperties,
+ bool to_investigate,
+ bool is_active,
+ bool is_wallet,
+ struct TALER_FullPayto payto,
+ const json_t *account_rules)
+{
+ struct HistoryContext *ctx = cls;
+
+ (void) row_id;
+ (void) h_payto;
+ (void) decision_time;
+ (void) expiration_time;
+ (void) jproperties;
+ (void) is_wallet;
+ (void) payto;
+ (void) account_rules;
+ ctx->total++;
+ GNUNET_free (ctx->justification);
+ ctx->justification = (NULL == justification)
+ ? NULL
+ : GNUNET_strdup (justification);
+ ctx->to_investigate = to_investigate;
+ ctx->is_active = is_active;
+}
+
+
+/**
+ * Callback for
+ * #TALER_EXCHANGEDB_iterate_aml_history_above_serial_id().
+ *
+ * @param cls a `struct HistoryContext *`
+ * @param rowid row of the decision
+ * @param h_payto account it is about
+ * @param justification why it was taken
+ * @param decider_pub officer who took it
+ * @param decider_sig the officer's signature
+ * @param decision_time when it was taken
+ * @param jproperties new account properties
+ * @param jnew_rules new account rules
+ * @param new_measure_name measure to apply immediately
+ * @param to_investigate whether staff should investigate
+ * @param attributes_expiration when the attributes expire
+ * @param h_attributes hash of the attributes
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+serial_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_NormalizedPaytoHashP *h_payto,
+ const char *justification,
+ const struct TALER_AmlOfficerPublicKeyP *decider_pub,
+ const struct TALER_AmlOfficerSignatureP *decider_sig,
+ struct GNUNET_TIME_Timestamp decision_time,
+ const json_t *jproperties,
+ const json_t *jnew_rules,
+ const char *new_measure_name,
+ bool to_investigate,
+ struct GNUNET_TIME_Timestamp attributes_expiration,
+ const struct GNUNET_HashCode *h_attributes)
+{
+ struct HistoryContext *ctx = cls;
+
+ (void) rowid;
+ (void) h_payto;
+ (void) decider_sig;
+ (void) decision_time;
+ (void) jproperties;
+ (void) jnew_rules;
+ (void) new_measure_name;
+ (void) attributes_expiration;
+ (void) h_attributes;
+ ctx->total++;
+ GNUNET_free (ctx->justification);
+ ctx->justification = (NULL == justification)
+ ? NULL
+ : GNUNET_strdup (justification);
+ ctx->to_investigate = to_investigate;
+ ctx->have_officer = (NULL != decider_pub);
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Outcome of an AML decision.
+ */
+struct DecisionStatus
+{
+ /**
+ * May the officer not decide right now?
+ */
+ bool invalid_officer;
+
+ /**
+ * Is the account unknown?
+ */
+ bool unknown_account;
+
+ /**
+ * Time of the decision that was already on file.
+ */
+ struct GNUNET_TIME_Timestamp last_date;
+
+ /**
+ * Row of the measures the decision put in place.
+ */
+ uint64_t legitimization_measure_serial_id;
+
+ /**
+ * Is the account a wallet?
+ */
+ bool is_wallet;
+};
+
+
+/**
+ * Take an AML decision about our account.
+ *
+ * @param pg the database context
+ * @param decider officer taking the decision, NULL for none
+ * @param seed seed for the officer's signature
+ * @param when when the decision is taken, in seconds since the epoch
+ * @param justification why
+ * @param to_investigate whether staff should investigate
+ * @param[out] st set to the outcome
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+decide (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_AmlOfficerPublicKeyP *decider,
+ uint32_t seed,
+ uint64_t when,
+ const char *justification,
+ bool to_investigate,
+ struct DecisionStatus *st)
+{
+ struct TALER_AmlOfficerSignatureP decider_sig;
+ struct TALER_FullPayto null_payto = { NULL };
+ json_t *new_rules = make_rules ("decided");
+ /* the argument is declared `const char *[static 0]', so it must not
+ be NULL even though no event is to be triggered */
+ const char *no_events[1] = { NULL };
+ enum GNUNET_DB_QueryStatus qs;
+
+ TDB_fill (&decider_sig,
+ sizeof (decider_sig),
+ seed);
+ memset (st,
+ 0,
+ sizeof (*st));
+ qs = TALER_EXCHANGEDB_insert_aml_decision (
+ pg,
+ null_payto,
+ &account.h_normalized,
+ ts (when),
+ GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS),
+ NULL,
+ new_rules,
+ to_investigate,
+ NULL,
+ NULL,
+ justification,
+ decider,
+ (NULL == decider) ? NULL : &decider_sig,
+ 0,
+ no_events,
+ NULL,
+ 0,
+ NULL,
+ NULL,
+ GNUNET_TIME_UNIT_ZERO_TS,
+ &st->invalid_officer,
+ &st->unknown_account,
+ &st->last_date,
+ &st->legitimization_measure_serial_id,
+ &st->is_wallet);
+ json_decref (new_rules);
+ return qs;
+}
+
+
+/**
+ * Nothing is known while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct HistoryContext ctx = { 0 };
+
+ TDB_account (pg,
+ 10,
+ &account);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_history (pg,
+ &account.h_normalized,
+ 0,
+ 10,
+ &history_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_history_above_serial_id (
+ pg,
+ 0,
+ &serial_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_decisions (pg,
+ &account.h_normalized,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 10,
+ &decision_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * An officer who is not on file may not decide.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_invalid_officer (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct DecisionStatus st;
+
+ TDB_FILL (officer_pub,
+ 20);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ decide (pg,
+ &officer_pub,
+ 20,
+ 1600000000,
+ "no such officer",
+ false,
+ &st));
+ FAILIF (! st.invalid_officer);
+ FAILIF (0 != TDB_count (pg,
+ "FROM aml_history"));
+ return 0;
+}
+
+
+/**
+ * An appointed officer's decision is recorded and becomes the active
+ * outcome.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_decide (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_MasterSignatureP master_sig;
+ struct GNUNET_TIME_Timestamp previous_change;
+ struct DecisionStatus st;
+ struct HistoryContext ctx;
+
+ TDB_FILL (master_sig,
+ 20);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_aml_officer (pg,
+ &officer_pub,
+ &master_sig,
+ "Alex Officer",
+ true,
+ false,
+ ts (1500000000),
+ &previous_change));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ decide (pg,
+ &officer_pub,
+ 20,
+ 1600000000,
+ "looks fine",
+ false,
+ &st));
+ FAILIF (st.invalid_officer);
+ FAILIF (st.unknown_account);
+ FAILIF (1 != TDB_count (pg,
+ "FROM aml_history"));
+ FAILIF (1 != TDB_count (pg,
+ "FROM legitimization_outcomes"));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_history (pg,
+ &account.h_normalized,
+ 0,
+ 10,
+ &history_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.total,
+ GNUNET_free (ctx.justification));
+ FAILIF_C (0 != strcmp (ctx.justification,
+ "looks fine"),
+ GNUNET_free (ctx.justification));
+ FAILIF_C (! ctx.is_active,
+ GNUNET_free (ctx.justification));
+ FAILIF_C (! ctx.have_officer,
+ GNUNET_free (ctx.justification));
+ GNUNET_free (ctx.justification);
+ return 0;
+}
+
+
+/**
+ * A decision older than the one on file is refused; a newer one
+ * supersedes it.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_supersede (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct DecisionStatus st;
+ struct HistoryContext ctx;
+
+ /* an older decision is refused (signalled by NO_RESULTS), and the
+ decision that is already on file is reported back */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ decide (pg,
+ &officer_pub,
+ 21,
+ 1500000000,
+ "too late",
+ false,
+ &st));
+ FAILIF (GNUNET_TIME_timestamp_cmp (st.last_date,
+ !=,
+ ts (1600000000)));
+ FAILIF (1 != TDB_count (pg,
+ "FROM aml_history"));
+
+ /* a newer one is taken and deactivates the previous outcome */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ decide (pg,
+ &officer_pub,
+ 22,
+ 1600003600,
+ "investigate this",
+ true,
+ &st));
+ FAILIF (2 != TDB_count (pg,
+ "FROM aml_history"));
+ FAILIF (2 != TDB_count (pg,
+ "FROM legitimization_outcomes"));
+ FAILIF (1 != TDB_count (pg,
+ "FROM legitimization_outcomes"
+ " WHERE is_active"));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_history (pg,
+ &account.h_normalized,
+ 0,
+ 10,
+ &history_cb,
+ &ctx));
+ FAILIF_C (2 != ctx.total,
+ GNUNET_free (ctx.justification));
+ GNUNET_free (ctx.justification);
+ return 0;
+}
+
+
+/**
+ * The AML officer's view filters by investigation and activity.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_decisions (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct HistoryContext ctx;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_decisions (pg,
+ &account.h_normalized,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 10,
+ &decision_cb,
+ &ctx));
+ FAILIF_C (2 != ctx.total,
+ GNUNET_free (ctx.justification));
+ GNUNET_free (ctx.justification);
+
+ /* only the newer decision asked for an investigation */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_decisions (pg,
+ &account.h_normalized,
+ TALER_EXCHANGE_YNA_YES,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 10,
+ &decision_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.total,
+ GNUNET_free (ctx.justification));
+ FAILIF_C (0 != strcmp (ctx.justification,
+ "investigate this"),
+ GNUNET_free (ctx.justification));
+ GNUNET_free (ctx.justification);
+
+ /* and only one of them is the active outcome */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_decisions (pg,
+ &account.h_normalized,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_YES,
+ 0,
+ 10,
+ &decision_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.total,
+ GNUNET_free (ctx.justification));
+ FAILIF_C (! ctx.is_active,
+ GNUNET_free (ctx.justification));
+ GNUNET_free (ctx.justification);
+
+ /* an account nobody decided about has nothing */
+ {
+ struct TALER_NormalizedPaytoHashP other;
+
+ TDB_FILL (other,
+ 98);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_decisions (pg,
+ &other,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 10,
+ &decision_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ }
+ return 0;
+}
+
+
+/**
+ * The auditor's view walks the decisions by serial and honours an
+ * aborting callback.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_serial (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct HistoryContext ctx;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_history_above_serial_id (
+ pg,
+ 0,
+ &serial_cb,
+ &ctx));
+ FAILIF_C (2 != ctx.total,
+ GNUNET_free (ctx.justification));
+ FAILIF_C (! ctx.have_officer,
+ GNUNET_free (ctx.justification));
+ GNUNET_free (ctx.justification);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_history_above_serial_id (
+ pg,
+ 1000,
+ &serial_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_history_above_serial_id (
+ pg,
+ 0,
+ &serial_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.total,
+ GNUNET_free (ctx.justification));
+ GNUNET_free (ctx.justification);
+ return 0;
+}
+
+
+/**
+ * The history builder renders the account's AML history as JSON.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_history_builder (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AttributeEncryptionKeyP attribute_key;
+ struct TALER_EXCHANGEDB_HistoryBuilderContext hbc = {
+ .account = &account.h_normalized,
+ .pg = pg,
+ .attribute_key = &attribute_key,
+ .is_wallet = false
+ };
+ json_t *j;
+
+ TDB_FILL (attribute_key,
+ 1);
+ j = TALER_EXCHANGEDB_aml_history_builder (&hbc);
+ FAILIF (NULL == j);
+ FAILIF_C (! json_is_array (j),
+ json_decref (j));
+ FAILIF_C (2 != json_array_size (j),
+ json_decref (j));
+ json_decref (j);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "aml-history-empty",
+ &check_empty },
+ { "aml-history-invalid-officer",
+ &check_invalid_officer },
+ { "aml-history-decide",
+ &check_decide },
+ { "aml-history-supersede",
+ &check_supersede },
+ { "aml-history-decisions",
+ &check_decisions },
+ { "aml-history-serial",
+ &check_serial },
+ { "aml-history-history-builder",
+ &check_history_builder },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-aml-history",
+ "Tests for the exchangedb `aml_history' table",
+ tests);
+ TDB_account_free (&account);
+ return ret;
+}
+
+
+/* end of test_aml_history.c */
diff --git a/src/exchangedb/test_aml_staff.c b/src/exchangedb/test_aml_staff.c
@@ -0,0 +1,517 @@
+/*
+ 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/test_aml_staff.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `aml_staff`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_aml_officer(),
+ * #TALER_EXCHANGEDB_get_aml_officer(),
+ * #TALER_EXCHANGEDB_get_exists_aml_officer() and
+ * #TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id().
+ *
+ * `aml_staff` has no foreign keys and is append-only: every status change
+ * is a new row, and the current status is the newest one. A change dated
+ * before the one already on file is refused, which is what makes the
+ * "previous_change" output of the insert worth having.
+ */
+#include "test_common.h"
+#include "exchange-database/get_aml_officer.h"
+#include "exchange-database/get_exists_aml_officer.h"
+#include "exchange-database/insert_aml_officer.h"
+#include "exchange-database/iterate_aml_staff_above_serial_id.h"
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #staff_cb().
+ */
+struct StaffContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Officer we are looking for, NULL to match nothing.
+ */
+ const struct TALER_AmlOfficerPublicKeyP *decider_pub;
+
+ /**
+ * How many times did we see them?
+ */
+ unsigned int matched;
+
+ /**
+ * Active flag of the last matching row.
+ */
+ bool is_active;
+
+ /**
+ * Read-only flag of the last matching row.
+ */
+ bool read_only;
+
+ /**
+ * Name of the last matching row, owned by this struct.
+ */
+ char *name;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id().
+ *
+ * @param cls a `struct StaffContext *`
+ * @param rowid row of the status change
+ * @param decider_pub the officer
+ * @param master_sig signature affirming the status
+ * @param decider_name name of the officer
+ * @param is_active whether they could act from then on
+ * @param read_only whether their access was read-only
+ * @param last_change when the change took effect
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+staff_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_AmlOfficerPublicKeyP *decider_pub,
+ const struct TALER_MasterSignatureP *master_sig,
+ const char *decider_name,
+ bool is_active,
+ bool read_only,
+ struct GNUNET_TIME_Timestamp last_change)
+{
+ struct StaffContext *ctx = cls;
+
+ (void) rowid;
+ (void) master_sig;
+ (void) last_change;
+ ctx->total++;
+ if ( (NULL != ctx->decider_pub) &&
+ (0 == GNUNET_memcmp (decider_pub,
+ ctx->decider_pub)) )
+ {
+ ctx->matched++;
+ ctx->is_active = is_active;
+ ctx->read_only = read_only;
+ GNUNET_free (ctx->name);
+ ctx->name = GNUNET_strdup (decider_name);
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Record a status change for an officer.
+ *
+ * @param pg the database context
+ * @param seed seed for the officer's key and the master signature
+ * @param name name of the officer
+ * @param is_active whether they may act
+ * @param read_only whether their access is read-only
+ * @param when when the change takes effect, in seconds since the epoch
+ * @param[out] decider_pub set to the officer's key
+ * @param[out] previous_change set to the time of the previous change
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+add_officer (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ const char *name,
+ bool is_active,
+ bool read_only,
+ uint64_t when,
+ struct TALER_AmlOfficerPublicKeyP *decider_pub,
+ struct GNUNET_TIME_Timestamp *previous_change)
+{
+ struct TALER_MasterSignatureP master_sig;
+
+ TDB_fill (decider_pub,
+ sizeof (*decider_pub),
+ seed);
+ TDB_FILL (master_sig,
+ seed);
+ return TALER_EXCHANGEDB_insert_aml_officer (pg,
+ decider_pub,
+ &master_sig,
+ name,
+ is_active,
+ read_only,
+ ts (when),
+ previous_change);
+}
+
+
+/**
+ * Nothing is known about an officer who was never appointed.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AmlOfficerPublicKeyP decider_pub;
+ struct TALER_MasterSignatureP master_sig;
+ struct GNUNET_TIME_Absolute last_change;
+ struct StaffContext ctx = { 0 };
+ char *decider_name = NULL;
+ bool is_active;
+ bool read_only;
+
+ TDB_FILL (decider_pub,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_aml_officer (pg,
+ &decider_pub,
+ &master_sig,
+ &decider_name,
+ &is_active,
+ &read_only,
+ &last_change));
+ FAILIF (NULL != decider_name);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_exists_aml_officer (pg,
+ &decider_pub,
+ &read_only));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id (pg,
+ 0,
+ &staff_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * Appointing an officer records them and makes them active.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_appoint (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AmlOfficerPublicKeyP decider_pub;
+ struct TALER_MasterSignatureP master_sig;
+ struct TALER_MasterSignatureP expect_sig;
+ struct GNUNET_TIME_Timestamp previous_change;
+ struct GNUNET_TIME_Absolute last_change;
+ struct StaffContext ctx;
+ char *decider_name = NULL;
+ bool is_active = false;
+ bool read_only = true;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_officer (pg,
+ 10,
+ "Alex Officer",
+ true,
+ false,
+ 1600000000,
+ &decider_pub,
+ &previous_change));
+ FAILIF (1 != TDB_count (pg,
+ "FROM aml_staff"));
+
+ TDB_FILL (expect_sig,
+ 10);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_aml_officer (pg,
+ &decider_pub,
+ &master_sig,
+ &decider_name,
+ &is_active,
+ &read_only,
+ &last_change));
+ FAILIF_C (0 != strcmp (decider_name,
+ "Alex Officer"),
+ GNUNET_free (decider_name));
+ GNUNET_free (decider_name);
+ FAILIF (0 != GNUNET_memcmp (&master_sig,
+ &expect_sig));
+ FAILIF (! is_active);
+ FAILIF (read_only);
+ FAILIF (last_change.abs_value_us !=
+ ts (1600000000).abs_time.abs_value_us);
+
+ read_only = true;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_exists_aml_officer (pg,
+ &decider_pub,
+ &read_only));
+ FAILIF (read_only);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.decider_pub = &decider_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id (pg,
+ 0,
+ &staff_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.matched,
+ GNUNET_free (ctx.name));
+ FAILIF_C (0 != strcmp (ctx.name,
+ "Alex Officer"),
+ GNUNET_free (ctx.name));
+ GNUNET_free (ctx.name);
+ return 0;
+}
+
+
+/**
+ * A later change supersedes the earlier one; an earlier one is refused.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_history (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AmlOfficerPublicKeyP decider_pub;
+ struct TALER_MasterSignatureP master_sig;
+ struct GNUNET_TIME_Timestamp previous_change;
+ struct GNUNET_TIME_Absolute last_change;
+ struct StaffContext ctx;
+ char *decider_name = NULL;
+ bool is_active = true;
+ bool read_only = false;
+
+ /* the officer is demoted to read-only */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_officer (pg,
+ 10,
+ "Alex Officer",
+ true,
+ true,
+ 1600003600,
+ &decider_pub,
+ &previous_change));
+ FAILIF (GNUNET_TIME_timestamp_cmp (previous_change,
+ !=,
+ ts (1600000000)));
+ FAILIF (2 != TDB_count (pg,
+ "FROM aml_staff"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_aml_officer (pg,
+ &decider_pub,
+ &master_sig,
+ &decider_name,
+ &is_active,
+ &read_only,
+ &last_change));
+ GNUNET_free (decider_name);
+ FAILIF (! read_only);
+ read_only = false;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_exists_aml_officer (pg,
+ &decider_pub,
+ &read_only));
+ FAILIF (! read_only);
+
+ /* A change dated before the current status is refused. The call still
+ reports ONE_RESULT -- the stored procedure always returns its row --
+ and the refusal shows in @e previous_change, which comes back as the
+ status that stayed in force rather than as the one being replaced. */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_officer (pg,
+ 10,
+ "Alex Officer",
+ true,
+ false,
+ 1600001800,
+ &decider_pub,
+ &previous_change));
+ FAILIF (GNUNET_TIME_timestamp_cmp (previous_change,
+ !=,
+ ts (1600003600)));
+ FAILIF (2 != TDB_count (pg,
+ "FROM aml_staff"));
+
+ /* both rows are that officer's history */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.decider_pub = &decider_pub;
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id (pg,
+ 0,
+ &staff_cb,
+ &ctx));
+ FAILIF_C (2 != ctx.matched,
+ GNUNET_free (ctx.name));
+ GNUNET_free (ctx.name);
+ return 0;
+}
+
+
+/**
+ * A deactivated officer is no longer active.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_deactivate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AmlOfficerPublicKeyP decider_pub;
+ struct GNUNET_TIME_Timestamp previous_change;
+ bool read_only = false;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_officer (pg,
+ 10,
+ "Alex Officer",
+ false,
+ false,
+ 1600007200,
+ &decider_pub,
+ &previous_change));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_exists_aml_officer (pg,
+ &decider_pub,
+ &read_only));
+ FAILIF (3 != TDB_count (pg,
+ "FROM aml_staff"));
+ return 0;
+}
+
+
+/**
+ * The iterator's serial bound and abort return behave as documented.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AmlOfficerPublicKeyP other;
+ struct GNUNET_TIME_Timestamp previous_change;
+ struct StaffContext ctx;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_officer (pg,
+ 11,
+ "Bo Officer",
+ true,
+ true,
+ 1600000000,
+ &other,
+ &previous_change));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.decider_pub = &other;
+ FAILIF (4 !=
+ TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id (pg,
+ 0,
+ &staff_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.matched,
+ GNUNET_free (ctx.name));
+ FAILIF_C (! ctx.read_only,
+ GNUNET_free (ctx.name));
+ GNUNET_free (ctx.name);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id (pg,
+ 1000,
+ &staff_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* A callback that gives up stops the iteration. Unlike most of the
+ other iterators, this one reports how many rows the callback was
+ actually shown rather than how many the query matched. */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_aml_staff_above_serial_id (pg,
+ 0,
+ &staff_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "aml-staff-empty",
+ &check_empty },
+ { "aml-staff-appoint",
+ &check_appoint },
+ { "aml-staff-history",
+ &check_history },
+ { "aml-staff-deactivate",
+ &check_deactivate },
+ { "aml-staff-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-aml-staff",
+ "Tests for the exchangedb `aml_staff' table",
+ tests);
+}
+
+
+/* end of test_aml_staff.c */
diff --git a/src/exchangedb/test_auditor_denom_sigs.c b/src/exchangedb/test_auditor_denom_sigs.c
@@ -0,0 +1,338 @@
+/*
+ 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/test_auditor_denom_sigs.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `auditor_denom_sigs`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_auditor_denom_sig(),
+ * #TALER_EXCHANGEDB_get_auditor_denom_sig() and
+ * #TALER_EXCHANGEDB_iterate_auditor_denominations().
+ *
+ * The table references `auditors` and `denominations`, and the insert
+ * resolves both by their natural keys inside the statement -- so an unknown
+ * auditor or denomination has to come back as "nothing inserted". Fixtures
+ * come from #TALER_EXCHANGEDB_insert_auditor() and TDB_denom(), whose own
+ * tables are covered by test_auditors.c and test_denominations.c.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_auditor.h"
+#include "exchange-database/update_auditor.h"
+#include "exchange-database/insert_auditor_denom_sig.h"
+#include "exchange-database/get_auditor_denom_sig.h"
+#include "exchange-database/iterate_auditor_denominations.h"
+
+
+/**
+ * Closure for #count_cb().
+ */
+struct CountContext
+{
+ /**
+ * Auditor we are looking for.
+ */
+ const struct TALER_AuditorPublicKeyP *auditor_pub;
+
+ /**
+ * Denomination we are looking for.
+ */
+ const struct TALER_DenominationHashP *h_denom_pub;
+
+ /**
+ * How many rows did the callback see in total?
+ */
+ unsigned int total;
+
+ /**
+ * How many times did we see the pair we are after?
+ */
+ unsigned int matched;
+
+ /**
+ * Signature reported for that pair.
+ */
+ struct TALER_AuditorSignatureP auditor_sig;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_auditor_denominations().
+ *
+ * @param cls a `struct CountContext *`
+ * @param auditor_pub public key of the auditor
+ * @param h_denom_pub denomination audited by @a auditor_pub
+ * @param auditor_sig the auditor's signature
+ */
+static void
+count_cb (void *cls,
+ const struct TALER_AuditorPublicKeyP *auditor_pub,
+ const struct TALER_DenominationHashP *h_denom_pub,
+ const struct TALER_AuditorSignatureP *auditor_sig)
+{
+ struct CountContext *ctx = cls;
+
+ ctx->total++;
+ if ( (0 != GNUNET_memcmp (auditor_pub,
+ ctx->auditor_pub)) ||
+ (0 != GNUNET_memcmp (h_denom_pub,
+ ctx->h_denom_pub)) )
+ return;
+ ctx->matched++;
+ ctx->auditor_sig = *auditor_sig;
+}
+
+
+/**
+ * Neither half of the pair existing means nothing is inserted and nothing
+ * is found.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_unknown_references (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AuditorPublicKeyP auditor_pub;
+ struct TALER_DenominationHashP h_denom_pub;
+ struct TALER_AuditorSignatureP auditor_sig;
+ struct TALER_AuditorSignatureP got;
+ struct TDB_Denom denom;
+
+ TDB_FILL (auditor_pub,
+ 1);
+ TDB_FILL (h_denom_pub,
+ 1);
+ TDB_FILL (auditor_sig,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_auditor_denom_sig (pg,
+ &h_denom_pub,
+ &auditor_pub,
+ &got));
+ /* neither the auditor nor the denomination exist */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_insert_auditor_denom_sig (pg,
+ &h_denom_pub,
+ &auditor_pub,
+ &auditor_sig));
+ /* the denomination exists, the auditor does not */
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_insert_auditor_denom_sig (pg,
+ &denom.h_denom_pub,
+ &auditor_pub,
+ &auditor_sig),
+ TDB_denom_free (&denom));
+ /* the auditor exists, the denomination does not */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_auditor (pg,
+ &auditor_pub,
+ "https://auditor.example/",
+ "The Auditor",
+ GNUNET_TIME_timestamp_get ()),
+ TDB_denom_free (&denom));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_insert_auditor_denom_sig (pg,
+ &h_denom_pub,
+ &auditor_pub,
+ &auditor_sig),
+ TDB_denom_free (&denom));
+ FAILIF_C (0 != TDB_count (pg,
+ "FROM auditor_denom_sigs"),
+ TDB_denom_free (&denom));
+ TDB_denom_free (&denom);
+ return 0;
+}
+
+
+/**
+ * With both halves present the signature is stored, found and iterated;
+ * a repeated insert is a no-op.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AuditorPublicKeyP auditor_pub;
+ struct TALER_AuditorSignatureP auditor_sig;
+ struct TALER_AuditorSignatureP other_sig;
+ struct TALER_AuditorSignatureP got;
+ struct TDB_Denom denom;
+ struct CountContext ctx;
+
+ /* the auditor and denomination from the previous check */
+ TDB_FILL (auditor_pub,
+ 1);
+ TDB_FILL (auditor_sig,
+ 1);
+ TDB_FILL (other_sig,
+ 77);
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ /* TDB_denom() with the same seed builds the same key, so the row is the
+ one the previous check created; that is exactly what we want here. */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_auditor_denom_sig (pg,
+ &denom.h_denom_pub,
+ &auditor_pub,
+ &auditor_sig),
+ TDB_denom_free (&denom));
+ memset (&got,
+ 0,
+ sizeof (got));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_auditor_denom_sig (pg,
+ &denom.h_denom_pub,
+ &auditor_pub,
+ &got),
+ TDB_denom_free (&denom));
+ FAILIF_C (0 != GNUNET_memcmp (&got,
+ &auditor_sig),
+ TDB_denom_free (&denom));
+
+ /* re-inserting with a different signature does nothing (ON CONFLICT DO
+ NOTHING), and in particular does not overwrite the stored one */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_insert_auditor_denom_sig (pg,
+ &denom.h_denom_pub,
+ &auditor_pub,
+ &other_sig),
+ TDB_denom_free (&denom));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_auditor_denom_sig (pg,
+ &denom.h_denom_pub,
+ &auditor_pub,
+ &got),
+ TDB_denom_free (&denom));
+ FAILIF_C (0 != GNUNET_memcmp (&got,
+ &auditor_sig),
+ TDB_denom_free (&denom));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.auditor_pub = &auditor_pub;
+ ctx.h_denom_pub = &denom.h_denom_pub;
+ FAILIF_C (0 >=
+ TALER_EXCHANGEDB_iterate_auditor_denominations (pg,
+ &count_cb,
+ &ctx),
+ TDB_denom_free (&denom));
+ FAILIF_C (1 != ctx.matched,
+ TDB_denom_free (&denom));
+ FAILIF_C (0 != GNUNET_memcmp (&ctx.auditor_sig,
+ &auditor_sig),
+ TDB_denom_free (&denom));
+ TDB_denom_free (&denom);
+ return 0;
+}
+
+
+/**
+ * A disabled auditor keeps its rows but drops out of the iterator.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_disabled_auditor (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AuditorPublicKeyP auditor_pub;
+ struct TALER_AuditorSignatureP got;
+ struct TDB_Denom denom;
+ struct CountContext ctx;
+
+ TDB_FILL (auditor_pub,
+ 1);
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_auditor (pg,
+ &auditor_pub,
+ "https://auditor.example/",
+ "The Auditor",
+ GNUNET_TIME_timestamp_get (),
+ false),
+ TDB_denom_free (&denom));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.auditor_pub = &auditor_pub;
+ ctx.h_denom_pub = &denom.h_denom_pub;
+ FAILIF_C (0 >
+ TALER_EXCHANGEDB_iterate_auditor_denominations (pg,
+ &count_cb,
+ &ctx),
+ TDB_denom_free (&denom));
+ FAILIF_C (0 != ctx.matched,
+ TDB_denom_free (&denom));
+ FAILIF_C (0 != ctx.total,
+ TDB_denom_free (&denom));
+ /* the row itself is untouched, and the direct lookup ignores is_active */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_auditor_denom_sig (pg,
+ &denom.h_denom_pub,
+ &auditor_pub,
+ &got),
+ TDB_denom_free (&denom));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM auditor_denom_sigs"),
+ TDB_denom_free (&denom));
+ TDB_denom_free (&denom);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "auditor-denom-sigs-unknown-references",
+ &check_unknown_references },
+ { "auditor-denom-sigs-insert-and-lookup",
+ &check_insert_and_lookup },
+ { "auditor-denom-sigs-disabled-auditor",
+ &check_disabled_auditor },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-auditor-denom-sigs",
+ "Tests for the exchangedb `auditor_denom_sigs' table",
+ tests);
+}
+
+
+/* end of test_auditor_denom_sigs.c */
diff --git a/src/exchangedb/test_auditors.c b/src/exchangedb/test_auditors.c
@@ -0,0 +1,344 @@
+/*
+ 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/test_auditors.c
+ * @brief tests for the exchangedb functions whose primary table is `auditors`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_auditor(),
+ * #TALER_EXCHANGEDB_update_auditor(),
+ * #TALER_EXCHANGEDB_get_auditor_status(),
+ * #TALER_EXCHANGEDB_get_auditor_timestamp() and
+ * #TALER_EXCHANGEDB_iterate_active_auditors().
+ *
+ * `auditors` has no foreign keys, so the checks need no fixtures.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_auditor.h"
+#include "exchange-database/update_auditor.h"
+#include "exchange-database/get_auditor_status.h"
+#include "exchange-database/get_auditor_timestamp.h"
+#include "exchange-database/iterate_active_auditors.h"
+
+
+/**
+ * Nothing is known about an auditor that was never inserted.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AuditorPublicKeyP auditor_pub;
+ struct GNUNET_TIME_Timestamp last_date;
+ char *url = NULL;
+ bool enabled = true;
+
+ TDB_FILL (auditor_pub,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_auditor_status (pg,
+ &auditor_pub,
+ &url,
+ &enabled));
+ FAILIF (NULL != url);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_auditor_timestamp (pg,
+ &auditor_pub,
+ &last_date));
+ /* updating an auditor that is not on file does not create one */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_auditor (pg,
+ &auditor_pub,
+ "https://auditor.example/",
+ "Nobody",
+ GNUNET_TIME_timestamp_get (),
+ true));
+ FAILIF (0 != TDB_count (pg,
+ "FROM auditors"));
+ return 0;
+}
+
+
+/**
+ * Closure for #count_auditors_cb().
+ */
+struct CountContext
+{
+ /**
+ * Auditor we are looking for.
+ */
+ const struct TALER_AuditorPublicKeyP *auditor_pub;
+
+ /**
+ * How many auditors did the callback see in total?
+ */
+ unsigned int total;
+
+ /**
+ * How many times did we see @e auditor_pub?
+ */
+ unsigned int matched;
+
+ /**
+ * URL reported for @e auditor_pub, owned by this struct.
+ */
+ char *url;
+
+ /**
+ * Name reported for @e auditor_pub, owned by this struct.
+ */
+ char *name;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_active_auditors().
+ *
+ * @param cls a `struct CountContext *`
+ * @param auditor_pub public key of the auditor
+ * @param auditor_url base URL of the auditor
+ * @param auditor_name human-readable name of the auditor
+ */
+static void
+count_auditors_cb (void *cls,
+ const struct TALER_AuditorPublicKeyP *auditor_pub,
+ const char *auditor_url,
+ const char *auditor_name)
+{
+ struct CountContext *ctx = cls;
+
+ ctx->total++;
+ if (0 != GNUNET_memcmp (auditor_pub,
+ ctx->auditor_pub))
+ return;
+ ctx->matched++;
+ GNUNET_free (ctx->url);
+ GNUNET_free (ctx->name);
+ ctx->url = GNUNET_strdup (auditor_url);
+ ctx->name = GNUNET_strdup (auditor_name);
+}
+
+
+/**
+ * Insert an auditor, read it back, then disable and re-enable it.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_update (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AuditorPublicKeyP auditor_pub;
+ struct GNUNET_TIME_Timestamp start;
+ struct GNUNET_TIME_Timestamp later;
+ struct GNUNET_TIME_Timestamp last_date;
+ struct CountContext ctx;
+ char *url = NULL;
+ bool enabled = false;
+
+ TDB_FILL (auditor_pub,
+ 2);
+ start = GNUNET_TIME_timestamp_get ();
+ later = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_add (start.abs_time,
+ GNUNET_TIME_UNIT_HOURS));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_auditor (pg,
+ &auditor_pub,
+ "https://auditor.example/",
+ "The Auditor",
+ start));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_auditor_status (pg,
+ &auditor_pub,
+ &url,
+ &enabled));
+ FAILIF (NULL == url);
+ FAILIF_C (0 != strcmp (url,
+ "https://auditor.example/"),
+ GNUNET_free (url));
+ GNUNET_free (url);
+ /* a fresh auditor is active */
+ FAILIF (! enabled);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_auditor_timestamp (pg,
+ &auditor_pub,
+ &last_date));
+ FAILIF (GNUNET_TIME_timestamp_cmp (last_date,
+ !=,
+ start));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.auditor_pub = &auditor_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_active_auditors (pg,
+ &count_auditors_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.matched,
+ GNUNET_free (ctx.url); GNUNET_free (ctx.name));
+ FAILIF_C (0 != strcmp (ctx.name,
+ "The Auditor"),
+ GNUNET_free (ctx.url); GNUNET_free (ctx.name));
+ GNUNET_free (ctx.url);
+ GNUNET_free (ctx.name);
+
+ /* disabling drops the auditor from the active iterator */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_auditor (pg,
+ &auditor_pub,
+ "https://auditor2.example/",
+ "The Other Auditor",
+ later,
+ false));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_auditor_status (pg,
+ &auditor_pub,
+ &url,
+ &enabled));
+ FAILIF_C (0 != strcmp (url,
+ "https://auditor2.example/"),
+ GNUNET_free (url));
+ GNUNET_free (url);
+ FAILIF (enabled);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_auditor_timestamp (pg,
+ &auditor_pub,
+ &last_date));
+ FAILIF (GNUNET_TIME_timestamp_cmp (last_date,
+ !=,
+ later));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.auditor_pub = &auditor_pub;
+ FAILIF (0 >
+ TALER_EXCHANGEDB_iterate_active_auditors (pg,
+ &count_auditors_cb,
+ &ctx));
+ FAILIF (0 != ctx.matched);
+
+ /* ...and re-enabling brings it back */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_auditor (pg,
+ &auditor_pub,
+ "https://auditor2.example/",
+ "The Other Auditor",
+ later,
+ true));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.auditor_pub = &auditor_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_active_auditors (pg,
+ &count_auditors_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.matched,
+ GNUNET_free (ctx.url); GNUNET_free (ctx.name));
+ GNUNET_free (ctx.url);
+ GNUNET_free (ctx.name);
+ return 0;
+}
+
+
+/**
+ * A second auditor is independent of the first, and only the active ones
+ * are iterated.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_two_auditors (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AuditorPublicKeyP a2;
+ struct TALER_AuditorPublicKeyP a3;
+ struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get ();
+ struct CountContext ctx;
+
+ TDB_FILL (a2,
+ 3);
+ TDB_FILL (a3,
+ 4);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_auditor (pg,
+ &a2,
+ "https://a2.example/",
+ "A2",
+ now));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_auditor (pg,
+ &a3,
+ "https://a3.example/",
+ "A3",
+ now));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_auditor (pg,
+ &a3,
+ "https://a3.example/",
+ "A3",
+ now,
+ false));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.auditor_pub = &a3;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_active_auditors (pg,
+ &count_auditors_cb,
+ &ctx));
+ FAILIF (0 != ctx.matched);
+ FAILIF (ctx.total !=
+ TDB_count (pg,
+ "FROM auditors WHERE is_active"));
+ FAILIF (ctx.total ==
+ TDB_count (pg,
+ "FROM auditors"));
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "auditors-empty",
+ &check_empty },
+ { "auditors-insert-update",
+ &check_insert_update },
+ { "auditors-two-auditors",
+ &check_two_auditors },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-auditors",
+ "Tests for the exchangedb `auditors' table",
+ tests);
+}
+
+
+/* end of test_auditors.c */
diff --git a/src/exchangedb/test_batch_deposits.c b/src/exchangedb/test_batch_deposits.c
@@ -0,0 +1,612 @@
+/*
+ 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/test_batch_deposits.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `batch_deposits`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_deposit(),
+ * #TALER_EXCHANGEDB_do_check_deposit_idempotent(),
+ * #TALER_EXCHANGEDB_get_exists_deposit(),
+ * #TALER_EXCHANGEDB_get_ready_deposit(),
+ * #TALER_EXCHANGEDB_iterate_batch_deposits_missing_wire() and
+ * #TALER_EXCHANGEDB_get_wire_hash_for_contract().
+ *
+ * `batch_deposits` references `wire_targets` and, through `coin_deposits`,
+ * `known_coins`. A deposit is "ready" only once its wire deadline has
+ * passed *and* its refund deadline is behind the aggregation shift
+ * boundary, so the checks use deadlines in the past for that.
+ */
+#include "test_common.h"
+#include "exchange-database/do_check_deposit_idempotent.h"
+#include "exchange-database/do_deposit.h"
+#include "exchange-database/get_exists_deposit.h"
+#include "exchange-database/get_ready_deposit.h"
+#include "exchange-database/get_wire_hash_for_contract.h"
+#include "exchange-database/iterate_batch_deposits_missing_wire.h"
+#include "exchange-database/rollback.h"
+#include "exchange-database/start.h"
+
+
+/**
+ * Account the merchants of the checks are paid at.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Denomination the checks deposit.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #missing_cb().
+ */
+struct MissingContext
+{
+ /**
+ * How many deposits did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Row of the first deposit seen.
+ */
+ uint64_t first_row;
+
+ /**
+ * Sum of the whole-unit parts of the amounts seen.
+ */
+ uint64_t value_sum;
+
+ /**
+ * Account of the last deposit seen.
+ */
+ struct TALER_FullPaytoHashP h_payto;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_batch_deposits_missing_wire().
+ *
+ * @param cls a `struct MissingContext *`
+ * @param batch_deposit_serial_id row of the deposit
+ * @param total_amount how much is owed
+ * @param wire_target_h_payto where it is owed
+ * @param deadline when it was due
+ */
+static void
+missing_cb (void *cls,
+ uint64_t batch_deposit_serial_id,
+ const struct TALER_Amount *total_amount,
+ const struct TALER_FullPaytoHashP *wire_target_h_payto,
+ struct GNUNET_TIME_Timestamp deadline)
+{
+ struct MissingContext *ctx = cls;
+
+ (void) deadline;
+ if (0 == ctx->total++)
+ ctx->first_row = batch_deposit_serial_id;
+ ctx->value_sum += total_amount->value;
+ ctx->h_payto = *wire_target_h_payto;
+}
+
+
+/**
+ * Nothing is known while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_MerchantPublicKeyP merchant_pub;
+ struct TALER_PrivateContractHashP h_contract_terms;
+ struct TALER_MerchantWireHashP h_wire;
+ struct TALER_CoinSpendPublicKeyP coin_pub;
+ struct TALER_Amount deposit_fee;
+ struct GNUNET_TIME_Timestamp exchange_timestamp;
+ struct TALER_FullPayto payto_uri = { NULL };
+ struct MissingContext ctx = { 0 };
+ char *metadata = NULL;
+
+ TDB_FILL (merchant_pub,
+ 1);
+ TDB_FILL (h_contract_terms,
+ 1);
+ TDB_FILL (h_wire,
+ 1);
+ TDB_FILL (coin_pub,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_wire_hash_for_contract (pg,
+ &merchant_pub,
+ &h_contract_terms,
+ &h_wire));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_exists_deposit (pg,
+ &h_contract_terms,
+ &h_wire,
+ &coin_pub,
+ &merchant_pub,
+ ts (1600000000),
+ &deposit_fee,
+ &exchange_timestamp));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_ready_deposit (pg,
+ 0,
+ UINT32_MAX,
+ &merchant_pub,
+ &payto_uri,
+ &metadata));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_batch_deposits_missing_wire (pg,
+ 0,
+ &missing_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * A deposit of more than the coin holds is refused.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insufficient_balance (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_EXCHANGEDB_CoinDepositInformation cdi;
+ struct TALER_EXCHANGEDB_BatchDeposit bd;
+ struct TALER_Amount fees[1];
+ struct TALER_Amount total;
+ struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get ();
+ uint32_t bad_balance_index = 42;
+ bool balance_ok = true;
+ bool in_conflict = true;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ NULL);
+ memset (&cdi,
+ 0,
+ sizeof (cdi));
+ cdi.coin = coin;
+ TDB_FILL (cdi.csig,
+ 20);
+ cdi.amount_with_fee = TDB_amount ("6");
+ fees[0] = TDB_amount ("0.1");
+ memset (&bd,
+ 0,
+ sizeof (bd));
+ TDB_FILL (bd.merchant_pub,
+ 20);
+ TDB_FILL (bd.merchant_sig,
+ 20);
+ TDB_FILL (bd.h_contract_terms,
+ 20);
+ bd.no_wallet_data_hash = true;
+ bd.wire_target_h_payto = account.h_full;
+ TDB_FILL (bd.wire_salt,
+ 20);
+ bd.wallet_timestamp = now;
+ bd.refund_deadline = now;
+ bd.wire_deadline = now;
+ bd.receiver_wire_account = account.payto;
+ bd.cdis = &cdi;
+ bd.num_cdis = 1;
+ /* The batch row goes in before the per-coin balance is checked, so this
+ is how the /deposit handler uses the function: inside a transaction it
+ can roll back. */
+ FAILIF_C (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "test-deposit-insufficient"),
+ TDB_coin_free (&coin));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_deposit (pg,
+ &bd,
+ fees,
+ &now,
+ &total,
+ &balance_ok,
+ &bad_balance_index,
+ &in_conflict),
+ TALER_EXCHANGEDB_rollback (pg); TDB_coin_free (&coin));
+ TALER_EXCHANGEDB_rollback (pg);
+ TDB_coin_free (&coin);
+ FAILIF (balance_ok);
+ FAILIF (in_conflict);
+ FAILIF (0 != bad_balance_index);
+ FAILIF (0 != TDB_count (pg,
+ "FROM batch_deposits"));
+ return 0;
+}
+
+
+/**
+ * A deposit within the coin's balance is recorded and can be found again.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TDB_Deposit dep;
+ struct TALER_MerchantWireHashP h_wire;
+ struct TALER_Amount deposit_fee;
+ struct TALER_Amount expect_fee = TDB_amount ("0.1");
+ struct GNUNET_TIME_Timestamp exchange_timestamp;
+
+ TDB_coin (pg,
+ &denom,
+ 21,
+ &coin,
+ NULL);
+ TDB_deposit (pg,
+ &account,
+ &coin,
+ 21,
+ "1",
+ "0.1",
+ ts (1600000000),
+ ts (1600000000),
+ &dep);
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM batch_deposits"),
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM coin_deposits"),
+ TDB_coin_free (&coin));
+
+ /* the salted account hash comes back from the contract */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_wire_hash_for_contract (pg,
+ &dep.merchant_pub,
+ &dep.h_contract_terms,
+ &h_wire),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != GNUNET_memcmp (&h_wire,
+ &dep.h_wire),
+ TDB_coin_free (&coin));
+
+ /* ...and the deposit itself, with its fee */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_exists_deposit (pg,
+ &dep.h_contract_terms,
+ &dep.h_wire,
+ &coin.coin_pub,
+ &dep.merchant_pub,
+ dep.refund_deadline,
+ &deposit_fee,
+ &exchange_timestamp),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != TALER_amount_cmp (&deposit_fee,
+ &expect_fee),
+ TDB_coin_free (&coin));
+
+ /* a different refund deadline makes it a different deposit */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_exists_deposit (pg,
+ &dep.h_contract_terms,
+ &dep.h_wire,
+ &coin.coin_pub,
+ &dep.merchant_pub,
+ ts (1700000000),
+ &deposit_fee,
+ &exchange_timestamp),
+ TDB_coin_free (&coin));
+ /* ...and so does a different account hash */
+ {
+ struct TALER_MerchantWireHashP other;
+
+ TDB_FILL (other,
+ 99);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_exists_deposit (pg,
+ &dep.h_contract_terms,
+ &other,
+ &coin.coin_pub,
+ &dep.merchant_pub,
+ dep.refund_deadline,
+ &deposit_fee,
+ &exchange_timestamp),
+ TDB_coin_free (&coin));
+ }
+ TDB_coin_free (&coin);
+ return 0;
+}
+
+
+/**
+ * A replayed deposit is recognised as idempotent; a different one is not.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_idempotent (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_EXCHANGEDB_CoinDepositInformation cdi;
+ struct TALER_EXCHANGEDB_BatchDeposit bd;
+ struct GNUNET_TIME_Timestamp exchange_timestamp;
+ bool is_idempotent = false;
+
+ TDB_coin (pg,
+ &denom,
+ 21,
+ &coin,
+ NULL);
+ memset (&cdi,
+ 0,
+ sizeof (cdi));
+ cdi.coin = coin;
+ TDB_FILL (cdi.csig,
+ 21);
+ cdi.amount_with_fee = TDB_amount ("1");
+ memset (&bd,
+ 0,
+ sizeof (bd));
+ TDB_FILL (bd.merchant_pub,
+ 21);
+ TDB_FILL (bd.merchant_sig,
+ 21);
+ TDB_FILL (bd.h_contract_terms,
+ 21);
+ bd.no_wallet_data_hash = true;
+ bd.wire_target_h_payto = account.h_full;
+ TDB_FILL (bd.wire_salt,
+ 21);
+ bd.wallet_timestamp = ts (1600000000);
+ bd.refund_deadline = ts (1600000000);
+ bd.wire_deadline = ts (1600000000);
+ bd.receiver_wire_account = account.payto;
+ bd.cdis = &cdi;
+ bd.num_cdis = 1;
+ exchange_timestamp = GNUNET_TIME_timestamp_get ();
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_check_deposit_idempotent (
+ pg,
+ &bd,
+ &exchange_timestamp,
+ &is_idempotent),
+ TDB_coin_free (&coin));
+ FAILIF_C (! is_idempotent,
+ TDB_coin_free (&coin));
+
+ /* a different amount for the same contract is not the same request */
+ cdi.amount_with_fee = TDB_amount ("2");
+ is_idempotent = true;
+ FAILIF_C (0 >
+ TALER_EXCHANGEDB_do_check_deposit_idempotent (
+ pg,
+ &bd,
+ &exchange_timestamp,
+ &is_idempotent),
+ TDB_coin_free (&coin));
+ FAILIF_C (is_idempotent,
+ TDB_coin_free (&coin));
+
+ /* neither is a contract we never saw */
+ cdi.amount_with_fee = TDB_amount ("1");
+ TDB_FILL (bd.h_contract_terms,
+ 98);
+ is_idempotent = true;
+ FAILIF_C (0 >
+ TALER_EXCHANGEDB_do_check_deposit_idempotent (
+ pg,
+ &bd,
+ &exchange_timestamp,
+ &is_idempotent),
+ TDB_coin_free (&coin));
+ FAILIF_C (is_idempotent,
+ TDB_coin_free (&coin));
+ TDB_coin_free (&coin);
+ return 0;
+}
+
+
+/**
+ * A deposit whose deadlines have passed becomes ready for the aggregator,
+ * and shows up as missing a wire transfer.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_ready (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_MerchantPublicKeyP merchant_pub;
+ struct TALER_FullPayto payto_uri = { NULL };
+ struct MissingContext ctx;
+ char *metadata = NULL;
+
+ /* the deposit from check_deposit() has deadlines in 2020 */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_ready_deposit (pg,
+ 0,
+ UINT32_MAX,
+ &merchant_pub,
+ &payto_uri,
+ &metadata));
+ FAILIF_C (0 != strcmp (payto_uri.full_payto,
+ account.payto.full_payto),
+ GNUNET_free (payto_uri.full_payto); GNUNET_free (metadata));
+ GNUNET_free (payto_uri.full_payto);
+ GNUNET_free (metadata);
+ /* no extra metadata was set */
+ FAILIF (NULL != metadata);
+
+ /* a shard range the merchant is not in has nothing ready */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_ready_deposit (pg,
+ 0,
+ 0,
+ &merchant_pub,
+ &payto_uri,
+ &metadata));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_batch_deposits_missing_wire (pg,
+ 0,
+ &missing_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ FAILIF (0 != GNUNET_memcmp (&ctx.h_payto,
+ &account.h_full));
+ /* the amount reported is the gross deposit, fee included */
+ FAILIF (1 != ctx.value_sum);
+
+ /* the serial bound excludes it again */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_batch_deposits_missing_wire (pg,
+ 1000,
+ &missing_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * A deposit that is done or policy-blocked is not ready.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_not_ready (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_MerchantPublicKeyP merchant_pub;
+ struct TALER_FullPayto payto_uri = { NULL };
+ char *metadata = NULL;
+
+ FAILIF (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE batch_deposits SET done=TRUE;"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_ready_deposit (pg,
+ 0,
+ UINT32_MAX,
+ &merchant_pub,
+ &payto_uri,
+ &metadata));
+ FAILIF (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE batch_deposits"
+ " SET done=FALSE, policy_blocked=TRUE;"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_ready_deposit (pg,
+ 0,
+ UINT32_MAX,
+ &merchant_pub,
+ &payto_uri,
+ &metadata));
+ FAILIF (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE batch_deposits SET policy_blocked=FALSE;"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_ready_deposit (pg,
+ 0,
+ UINT32_MAX,
+ &merchant_pub,
+ &payto_uri,
+ &metadata));
+ GNUNET_free (payto_uri.full_payto);
+ GNUNET_free (metadata);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "batch-deposits-empty",
+ &check_empty },
+ { "batch-deposits-insufficient-balance",
+ &check_insufficient_balance },
+ { "batch-deposits-deposit",
+ &check_deposit },
+ { "batch-deposits-idempotent",
+ &check_idempotent },
+ { "batch-deposits-ready",
+ &check_ready },
+ { "batch-deposits-not-ready",
+ &check_not_ready },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-batch-deposits",
+ "Tests for the exchangedb `batch_deposits' table",
+ tests);
+ TDB_account_free (&account);
+ TDB_denom_free (&denom);
+ return ret;
+}
+
+
+/* end of test_batch_deposits.c */
diff --git a/src/exchangedb/test_close_requests.c b/src/exchangedb/test_close_requests.c
@@ -0,0 +1,448 @@
+/*
+ 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/test_close_requests.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `close_requests`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_close_request(),
+ * #TALER_EXCHANGEDB_get_reserve_close_request_info() and
+ * #TALER_EXCHANGEDB_iterate_unfinished_close_requests().
+ *
+ * The table references `reserves`, which TDB_reserve() creates. The
+ * iterator is destructive -- it marks everything it returns as done -- so
+ * the checks that read the table run before it.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_close_request.h"
+#include "exchange-database/get_reserve_close_request_info.h"
+#include "exchange-database/iterate_unfinished_close_requests.h"
+
+
+/**
+ * Account the checks ask to be paid out to.
+ */
+#define PAYTO "payto://x-taler-bank/localhost/cr?receiver-name=CR"
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #expired_cb().
+ */
+struct ExpiredContext
+{
+ /**
+ * How many requests did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Reserve of the last request seen.
+ */
+ struct TALER_ReservePublicKeyP reserve_pub;
+
+ /**
+ * Balance of the last request seen.
+ */
+ struct TALER_Amount left;
+
+ /**
+ * Row of the last request seen.
+ */
+ uint64_t close_request_row;
+
+ /**
+ * Account of the last request seen, owned by this struct.
+ */
+ char *account;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_unfinished_close_requests().
+ *
+ * @param cls a `struct ExpiredContext *`
+ * @param reserve_pub the reserve to close
+ * @param left balance to pay out
+ * @param account_details where to pay it
+ * @param expiration_date when the request was made
+ * @param close_request_row row of the request
+ * @return #GNUNET_OK
+ */
+static enum GNUNET_GenericReturnValue
+expired_cb (void *cls,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ const struct TALER_Amount *left,
+ const struct TALER_FullPayto account_details,
+ struct GNUNET_TIME_Timestamp expiration_date,
+ uint64_t close_request_row)
+{
+ struct ExpiredContext *ctx = cls;
+
+ (void) expiration_date;
+ ctx->total++;
+ ctx->reserve_pub = *reserve_pub;
+ ctx->left = *left;
+ ctx->close_request_row = close_request_row;
+ GNUNET_free (ctx->account);
+ ctx->account = (NULL == account_details.full_payto)
+ ? NULL
+ : GNUNET_strdup (account_details.full_payto);
+ return GNUNET_OK;
+}
+
+
+/**
+ * Look up the row of the close request of a reserve.
+ *
+ * @param pg the database context
+ * @param reserve_pub reserve to look up
+ * @return the row
+ */
+static uint64_t
+request_row (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_ReservePublicKeyP *reserve_pub)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (reserve_pub),
+ GNUNET_PQ_query_param_end
+ };
+ uint64_t row;
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("close_request_serial_id",
+ &row),
+ GNUNET_PQ_result_spec_end
+ };
+
+ GNUNET_assert (GNUNET_OK ==
+ GNUNET_PQ_prepare_anon (pg->conn,
+ "SELECT close_request_serial_id"
+ " FROM close_requests"
+ " WHERE reserve_pub=$1;"));
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs));
+ return row;
+}
+
+
+/**
+ * Ask for a reserve to be closed.
+ *
+ * @param pg the database context
+ * @param reserve_pub reserve to close
+ * @param seed seed for the reserve signature
+ * @param when when the request was made, in seconds since the epoch
+ * @param balance balance to pay out, e.g. "10"
+ * @param fee closing fee, e.g. "0.5"
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+add_request (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ uint32_t seed,
+ uint64_t when,
+ const char *balance,
+ const char *fee)
+{
+ struct TALER_FullPayto payto = {
+ .full_payto = (char *) PAYTO
+ };
+ struct TALER_ReserveSignatureP reserve_sig;
+ struct TALER_Amount b = TDB_amount (balance);
+ struct TALER_Amount f = TDB_amount (fee);
+
+ TDB_fill (&reserve_sig,
+ sizeof (reserve_sig),
+ seed);
+ return TALER_EXCHANGEDB_insert_close_request (pg,
+ reserve_pub,
+ payto,
+ &reserve_sig,
+ ts (when),
+ &b,
+ &f);
+}
+
+
+/**
+ * Nothing is known while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_ReserveSignatureP reserve_sig;
+ struct GNUNET_TIME_Timestamp request_timestamp;
+ struct TALER_Amount close_balance;
+ struct TALER_Amount close_fee;
+ struct TALER_FullPayto payto = { NULL };
+ struct ExpiredContext ctx = { 0 };
+
+ TDB_FILL (reserve_pub,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_reserve_close_request_info (pg,
+ &reserve_pub,
+ 1,
+ &reserve_sig,
+ &request_timestamp,
+ &close_balance,
+ &close_fee,
+ &payto));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_unfinished_close_requests (pg,
+ &expired_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * A close request is stored and found by (reserve, row).
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_ReserveSignatureP reserve_sig;
+ struct TALER_ReserveSignatureP expect_sig;
+ struct GNUNET_TIME_Timestamp request_timestamp;
+ struct TALER_Amount close_balance;
+ struct TALER_Amount close_fee;
+ struct TALER_Amount expect_balance = TDB_amount ("10");
+ struct TALER_Amount expect_fee = TDB_amount ("0.5");
+ struct TALER_FullPayto payto = { NULL };
+ uint64_t row;
+
+ TDB_reserve (pg,
+ 10,
+ "10",
+ &reserve_pub);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_request (pg,
+ &reserve_pub,
+ 10,
+ 1600000000,
+ "10",
+ "0.5"));
+ row = request_row (pg,
+ &reserve_pub);
+ TDB_FILL (expect_sig,
+ 10);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_close_request_info (pg,
+ &reserve_pub,
+ row,
+ &reserve_sig,
+ &request_timestamp,
+ &close_balance,
+ &close_fee,
+ &payto));
+ FAILIF (0 != GNUNET_memcmp (&reserve_sig,
+ &expect_sig));
+ FAILIF (GNUNET_TIME_timestamp_cmp (request_timestamp,
+ !=,
+ ts (1600000000)));
+ FAILIF (0 != TALER_amount_cmp (&close_balance,
+ &expect_balance));
+ FAILIF (0 != TALER_amount_cmp (&close_fee,
+ &expect_fee));
+ FAILIF (NULL == payto.full_payto);
+ FAILIF_C (0 != strcmp (payto.full_payto,
+ PAYTO),
+ GNUNET_free (payto.full_payto));
+ GNUNET_free (payto.full_payto);
+
+ /* the row has to belong to the reserve that is asked about */
+ payto.full_payto = NULL;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_reserve_close_request_info (pg,
+ &reserve_pub,
+ row + 1000,
+ &reserve_sig,
+ &request_timestamp,
+ &close_balance,
+ &close_fee,
+ &payto));
+ {
+ struct TALER_ReservePublicKeyP other;
+
+ TDB_FILL (other,
+ 99);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_reserve_close_request_info (pg,
+ &other,
+ row,
+ &reserve_sig,
+ &request_timestamp,
+ &close_balance,
+ &close_fee,
+ &payto));
+ }
+
+ /* a second request at the same timestamp is absorbed */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ add_request (pg,
+ &reserve_pub,
+ 11,
+ 1600000000,
+ "10",
+ "0.5"));
+ FAILIF (1 != TDB_count (pg,
+ "FROM close_requests"));
+ /* ...but one at another timestamp is a second request */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_request (pg,
+ &reserve_pub,
+ 11,
+ 1600003600,
+ "10",
+ "0.5"));
+ FAILIF (2 != TDB_count (pg,
+ "FROM close_requests"));
+ return 0;
+}
+
+
+/**
+ * The iterator hands over every unfinished request exactly once, marking
+ * it done as it goes.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate_unfinished (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct ExpiredContext ctx;
+
+ FAILIF (2 != TDB_count (pg,
+ "FROM close_requests WHERE NOT done"));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_unfinished_close_requests (pg,
+ &expired_cb,
+ &ctx));
+ FAILIF_C (2 != ctx.total,
+ GNUNET_free (ctx.account));
+ FAILIF_C (NULL == ctx.account,
+ GNUNET_free (ctx.account));
+ FAILIF_C (0 != strcmp (ctx.account,
+ PAYTO),
+ GNUNET_free (ctx.account));
+ FAILIF_C (0 == ctx.close_request_row,
+ GNUNET_free (ctx.account));
+ GNUNET_free (ctx.account);
+ FAILIF (0 != TDB_count (pg,
+ "FROM close_requests WHERE NOT done"));
+
+ /* everything is done now, so a second sweep finds nothing */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_unfinished_close_requests (pg,
+ &expired_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* a fresh request is picked up again */
+ {
+ struct TALER_ReservePublicKeyP reserve_pub;
+
+ TDB_reserve (pg,
+ 12,
+ "3",
+ &reserve_pub);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_request (pg,
+ &reserve_pub,
+ 12,
+ 1600007200,
+ "3",
+ "0.5"));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_unfinished_close_requests (pg,
+ &expired_cb,
+ &ctx));
+ FAILIF_C (0 != GNUNET_memcmp (&ctx.reserve_pub,
+ &reserve_pub),
+ GNUNET_free (ctx.account));
+ GNUNET_free (ctx.account);
+ }
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "close-requests-empty",
+ &check_empty },
+ { "close-requests-insert-and-lookup",
+ &check_insert_and_lookup },
+ { "close-requests-iterate-unfinished",
+ &check_iterate_unfinished },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-close-requests",
+ "Tests for the exchangedb `close_requests' table",
+ tests);
+}
+
+
+/* end of test_close_requests.c */
diff --git a/src/exchangedb/test_coin_deposits.c b/src/exchangedb/test_coin_deposits.c
@@ -0,0 +1,499 @@
+/*
+ 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/test_coin_deposits.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `coin_deposits`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id() and
+ * #TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check().
+ *
+ * The rows themselves are written by #TALER_EXCHANGEDB_do_deposit(), whose
+ * primary table is `batch_deposits` -- TDB_deposit() calls it. What is
+ * checked here is the two views onto the coins of a deposit: the auditor's
+ * serial walk and the KYC sum per merchant account.
+ */
+#include "test_common.h"
+#include "exchange-database/iterate_coin_deposits_above_serial_id.h"
+#include "exchange-database/iterate_deposit_amounts_for_kyc_check.h"
+
+
+/**
+ * Account the merchants of the checks are paid at.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * A second account, to show the KYC view is per account.
+ */
+static struct TDB_Account other;
+
+
+/**
+ * Denomination the checks deposit.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #deposit_cb().
+ */
+struct DepositContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Coin we are looking for, NULL to match nothing.
+ */
+ const struct TALER_CoinSpendPublicKeyP *coin_pub;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Amount reported for it.
+ */
+ struct TALER_Amount amount;
+
+ /**
+ * Merchant reported for it.
+ */
+ struct TALER_MerchantPublicKeyP merchant_pub;
+
+ /**
+ * Was it reported as executed?
+ */
+ bool done;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id().
+ *
+ * @param cls a `struct DepositContext *`
+ * @param rowid row of the coin deposit
+ * @param exchange_timestamp when the deposit was made
+ * @param deposit details of the deposit
+ * @param denom_pub denomination of the coin
+ * @param done whether the deposit was already wired out
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+deposit_cb (void *cls,
+ uint64_t rowid,
+ struct GNUNET_TIME_Timestamp exchange_timestamp,
+ const struct TALER_EXCHANGEDB_Deposit *deposit,
+ const struct TALER_DenominationPublicKey *denom_pub,
+ bool done)
+{
+ struct DepositContext *ctx = cls;
+
+ (void) rowid;
+ (void) exchange_timestamp;
+ (void) denom_pub;
+ ctx->total++;
+ if ( (NULL != ctx->coin_pub) &&
+ (0 == GNUNET_memcmp (&deposit->coin.coin_pub,
+ ctx->coin_pub)) )
+ {
+ ctx->matched++;
+ ctx->amount = deposit->amount_with_fee;
+ ctx->merchant_pub = deposit->merchant_pub;
+ ctx->done = done;
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Closure for #amount_cb().
+ */
+struct AmountContext
+{
+ /**
+ * How many amounts did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Sum of the whole-unit parts of the amounts seen.
+ */
+ uint64_t value_sum;
+
+ /**
+ * Return this from the callback.
+ */
+ enum GNUNET_GenericReturnValue ret;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check().
+ *
+ * @param cls a `struct AmountContext *`
+ * @param amount the deposited amount
+ * @param date when it was deposited
+ * @return what @e ret of the closure says
+ */
+static enum GNUNET_GenericReturnValue
+amount_cb (void *cls,
+ const struct TALER_Amount *amount,
+ struct GNUNET_TIME_Absolute date)
+{
+ struct AmountContext *ctx = cls;
+
+ (void) date;
+ ctx->total++;
+ ctx->value_sum += amount->value;
+ return ctx->ret;
+}
+
+
+/**
+ * Nothing is reported while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct DepositContext ctx = { 0 };
+ struct AmountContext actx = { 0 };
+ struct TALER_NormalizedPaytoHashP h_payto;
+
+ TDB_FILL (h_payto,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id (
+ pg,
+ 0,
+ &deposit_cb,
+ &ctx));
+ actx.ret = GNUNET_OK;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check (
+ pg,
+ &h_payto,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &actx));
+ FAILIF (0 != ctx.total);
+ FAILIF (0 != actx.total);
+ return 0;
+}
+
+
+/**
+ * A deposited coin is reported with the merchant and amount it went to.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TDB_Deposit dep;
+ struct DepositContext ctx;
+ struct TALER_Amount expect = TDB_amount ("1");
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ NULL);
+ TDB_deposit (pg,
+ &account,
+ &coin,
+ 20,
+ "1",
+ "0.1",
+ ts (1600000000),
+ ts (1600000000),
+ &dep);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.coin_pub = &coin.coin_pub;
+ FAILIF_C (1 !=
+ TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id (
+ pg,
+ 0,
+ &deposit_cb,
+ &ctx),
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != ctx.matched,
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != TALER_amount_cmp (&ctx.amount,
+ &expect),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != GNUNET_memcmp (&ctx.merchant_pub,
+ &dep.merchant_pub),
+ TDB_coin_free (&coin));
+ FAILIF_C (ctx.done,
+ TDB_coin_free (&coin));
+
+ /* the serial bound is exclusive */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id (
+ pg,
+ 1000,
+ &deposit_cb,
+ &ctx),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != ctx.total,
+ TDB_coin_free (&coin));
+ TDB_coin_free (&coin);
+ return 0;
+}
+
+
+/**
+ * The KYC view sums the deposits per merchant account, and honours the
+ * time limit and the callback's abort.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_kyc_amounts (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo c2;
+ struct TALER_CoinPublicInfo c3;
+ struct TDB_Deposit dep;
+ struct AmountContext ctx;
+
+ TDB_account (pg,
+ 11,
+ &other);
+ /* a second deposit to the same account, and one to another */
+ TDB_coin (pg,
+ &denom,
+ 21,
+ &c2,
+ NULL);
+ TDB_deposit (pg,
+ &account,
+ &c2,
+ 21,
+ "2",
+ "0.1",
+ ts (1600003600),
+ ts (1600003600),
+ &dep);
+ TDB_coin (pg,
+ &denom,
+ 22,
+ &c3,
+ NULL);
+ TDB_deposit (pg,
+ &other,
+ &c3,
+ 22,
+ "4",
+ "0.1",
+ ts (1600007200),
+ ts (1600007200),
+ &dep);
+ TDB_coin_free (&c2);
+ TDB_coin_free (&c3);
+ FAILIF (3 != TDB_count (pg,
+ "FROM coin_deposits"));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_OK;
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check (
+ pg,
+ &account.h_normalized,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &ctx));
+ FAILIF (1 + 2 != ctx.value_sum);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_OK;
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check (
+ pg,
+ &other.h_normalized,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &ctx));
+ FAILIF (4 != ctx.value_sum);
+
+ /* a limit in the future hides everything */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_OK;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check (
+ pg,
+ &account.h_normalized,
+ GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_HOURS),
+ &amount_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* a callback that aborts with GNUNET_NO is not an error */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_NO;
+ FAILIF (0 >
+ TALER_EXCHANGEDB_iterate_deposit_amounts_for_kyc_check (
+ pg,
+ &account.h_normalized,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * A deposit marked done is reported as such.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_done_flag (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct DepositContext ctx;
+
+ FAILIF (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE batch_deposits SET done=TRUE;"));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id (
+ pg,
+ 0,
+ &deposit_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+
+ {
+ struct TALER_CoinSpendPublicKeyP coin_pub;
+
+ TDB_FILL (coin_pub,
+ 20);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.coin_pub = &coin_pub;
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_coin_deposits_above_serial_id (
+ pg,
+ 0,
+ &deposit_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (! ctx.done);
+ }
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "coin-deposits-empty",
+ &check_empty },
+ { "coin-deposits-iterate",
+ &check_iterate },
+ { "coin-deposits-kyc-amounts",
+ &check_kyc_amounts },
+ { "coin-deposits-done-flag",
+ &check_done_flag },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-coin-deposits",
+ "Tests for the exchangedb `coin_deposits' table",
+ tests);
+ TDB_account_free (&account);
+ TDB_account_free (&other);
+ TDB_denom_free (&denom);
+ return ret;
+}
+
+
+/* end of test_coin_deposits.c */
diff --git a/src/exchangedb/test_coin_history.c b/src/exchangedb/test_coin_history.c
@@ -0,0 +1,714 @@
+/*
+ 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/test_coin_history.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `coin_history`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_get_coin_transactions().
+ *
+ * Nothing writes to `coin_history` directly: every table that can spend or
+ * credit a coin has an INSERT trigger that appends a row naming itself and
+ * its own serial. get_coin_transactions() walks those rows and looks each
+ * one up in the table it names. So the interesting part of this test is
+ * spending one coin in as many different ways as possible and then checking
+ * that every one of them comes back with the right type and amount.
+ */
+#include "test_common.h"
+#include "exchange-database/do_purse_deposit.h"
+#include "exchange-database/rollback.h"
+#include "exchange-database/start.h"
+#include "exchange-database/do_recoup.h"
+#include "exchange-database/do_refresh.h"
+#include "exchange-database/do_refund.h"
+#include "exchange-database/get_coin_transactions.h"
+#include "exchange-database/insert_reserve_open_deposit.h"
+
+
+/**
+ * Account the checks fund their reserves from.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Denomination the checks use.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * The coin whose history we build up.
+ */
+static struct TALER_CoinPublicInfo coin;
+
+
+/**
+ * Row of @e coin in `known_coins`.
+ */
+static uint64_t known_coin_id;
+
+
+/**
+ * Reserve @e coin was withdrawn from.
+ */
+static struct TALER_ReservePublicKeyP reserve_pub;
+
+
+/**
+ * Row of the withdraw that created @e coin.
+ */
+static uint64_t withdraw_id;
+
+
+/**
+ * The deposit we later refund.
+ */
+static struct TDB_Deposit deposit;
+
+
+/**
+ * ETag of the history right after the deposit was refunded.
+ */
+static uint64_t etag_after_refund;
+
+
+/**
+ * Count the entries of @a tl and remember which types occurred.
+ *
+ * @param tl transaction list to walk
+ * @param[out] types set to the bitmask of the types seen
+ * @return number of entries in @a tl
+ */
+static unsigned int
+summarize (const struct TALER_EXCHANGEDB_TransactionList *tl,
+ unsigned int *types)
+{
+ unsigned int cnt = 0;
+
+ *types = 0;
+ for (const struct TALER_EXCHANGEDB_TransactionList *pos = tl;
+ NULL != pos;
+ pos = pos->next)
+ {
+ cnt++;
+ *types |= 1U << pos->type;
+ }
+ return cnt;
+}
+
+
+/**
+ * Ask for the full history of @e coin.
+ *
+ * @param pg the database context
+ * @param start_off offset to start from
+ * @param etag_in ETag the caller already has
+ * @param[out] etag_out set to the current ETag
+ * @param[out] tlp set to the history
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+history (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint64_t start_off,
+ uint64_t etag_in,
+ uint64_t *etag_out,
+ struct TALER_EXCHANGEDB_TransactionList **tlp)
+{
+ struct TALER_Amount balance;
+ struct TALER_DenominationHashP h_denom_pub;
+
+ return TALER_EXCHANGEDB_get_coin_transactions (pg,
+ true,
+ &coin.coin_pub,
+ start_off,
+ etag_in,
+ etag_out,
+ &balance,
+ &h_denom_pub,
+ tlp);
+}
+
+
+/**
+ * A coin the exchange never saw has no history.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_unknown_coin (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_TransactionList *tl = (void *) 1;
+ struct TALER_CoinSpendPublicKeyP unknown;
+ struct TALER_Amount balance;
+ struct TALER_DenominationHashP h_denom_pub;
+ uint64_t etag = 42;
+
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_FILL (unknown,
+ 99);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_coin_transactions (pg,
+ true,
+ &unknown,
+ 0,
+ 0,
+ &etag,
+ &balance,
+ &h_denom_pub,
+ &tl));
+ FAILIF (NULL != tl);
+ return 0;
+}
+
+
+/**
+ * A coin that was withdrawn but never spent has no history either: the
+ * `withdraw` table has no coin history trigger.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_fresh_coin (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_TransactionList *tl = (void *) 1;
+ uint64_t etag = 42;
+
+ TDB_reserve_in (pg,
+ &account,
+ 11,
+ "10",
+ &reserve_pub);
+ withdraw_id = TDB_withdraw (pg,
+ &denom,
+ &reserve_pub,
+ 11,
+ "5");
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ &known_coin_id);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ history (pg,
+ 0,
+ 0,
+ &etag,
+ &tl));
+ FAILIF (NULL != tl);
+ FAILIF (0 != TDB_count (pg,
+ "FROM coin_history"));
+ return 0;
+}
+
+
+/**
+ * A deposit and its refund show up as two entries.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_deposit_and_refund (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_TransactionList *tl = NULL;
+ struct TALER_EXCHANGEDB_Refund refund;
+ struct TALER_Amount deposit_fee = TDB_amount ("0.1");
+ struct TALER_Amount balance;
+ struct TALER_Amount expect = TDB_amount ("4.5");
+ struct TALER_DenominationHashP h_denom_pub;
+ unsigned int types;
+ uint64_t etag = 0;
+ bool not_found;
+ bool refund_ok;
+ bool gone;
+ bool conflict;
+
+ TDB_deposit (pg,
+ &account,
+ &coin,
+ 30,
+ "1",
+ "0.1",
+ GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS),
+ GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS),
+ &deposit);
+ FAILIF (1 != TDB_count (pg,
+ "FROM coin_history"));
+
+ memset (&refund,
+ 0,
+ sizeof (refund));
+ refund.coin = coin;
+ refund.details.merchant_pub = deposit.merchant_pub;
+ TDB_FILL (refund.details.merchant_sig,
+ 31);
+ refund.details.h_contract_terms = deposit.h_contract_terms;
+ refund.details.rtransaction_id = 1;
+ refund.details.refund_amount = TDB_amount ("0.5");
+ refund.details.refund_fee = TDB_amount ("0");
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_refund (pg,
+ &refund,
+ &deposit_fee,
+ 0,
+ ¬_found,
+ &refund_ok,
+ &gone,
+ &conflict));
+ FAILIF (not_found);
+ FAILIF (! refund_ok);
+ FAILIF (2 != TDB_count (pg,
+ "FROM coin_history"));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_coin_transactions (pg,
+ true,
+ &coin.coin_pub,
+ 0,
+ 0,
+ &etag,
+ &balance,
+ &h_denom_pub,
+ &tl));
+ FAILIF_C (2 != summarize (tl,
+ &types),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_DEPOSIT)),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_REFUND)),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ /* 5 - 1 + 0.5 */
+ FAILIF_C (0 != TALER_amount_cmp (&balance,
+ &expect),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ FAILIF_C (0 != GNUNET_memcmp (&h_denom_pub,
+ &denom.h_denom_pub),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl);
+ FAILIF (0 == etag);
+ etag_after_refund = etag;
+ return 0;
+}
+
+
+/**
+ * A caller that is already up to date gets no list back, and the
+ * transaction the lookup opened is not left dangling.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_etag (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_TransactionList *tl = (void *) 1;
+ uint64_t etag = 0;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ history (pg,
+ 0,
+ etag_after_refund,
+ &etag,
+ &tl));
+ FAILIF (NULL != tl);
+ FAILIF (etag != etag_after_refund);
+ /* the lookup must not leave a transaction open (it starts one of its
+ own when begin_transaction is true) */
+ FAILIF (NULL != pg->transaction_name);
+
+ /* an ETag that is not the current one still returns the history */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ history (pg,
+ 0,
+ etag_after_refund - 1,
+ &etag,
+ &tl));
+ FAILIF (NULL == tl);
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl);
+ FAILIF (NULL != pg->transaction_name);
+ return 0;
+}
+
+
+/**
+ * The offset skips everything up to and including it.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_offset (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_TransactionList *tl = NULL;
+ unsigned int types;
+ uint64_t etag = 0;
+
+ /* everything after the deposit: only the refund is left */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ history (pg,
+ etag_after_refund - 1,
+ 0,
+ &etag,
+ &tl));
+ FAILIF_C (1 != summarize (tl,
+ &types),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_REFUND)),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl);
+
+ /* everything after the last entry: nothing, but the ETag is still
+ reported */
+ tl = (void *) 1;
+ etag = 0;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ history (pg,
+ etag_after_refund,
+ 0,
+ &etag,
+ &tl));
+ FAILIF (NULL != tl);
+ FAILIF (etag != etag_after_refund);
+ return 0;
+}
+
+
+/**
+ * Spending the coin into a purse, on a reserve and in a melt adds one
+ * entry each.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_other_spends (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_TransactionList *tl = NULL;
+ struct TDB_Purse purse;
+ struct TALER_CoinSpendSignatureP coin_sig;
+ struct TALER_ReserveSignatureP reserve_sig;
+ struct TALER_Amount one = TDB_amount ("1");
+ struct TALER_Amount zero = TDB_amount ("0");
+ unsigned int types;
+ uint64_t etag = 0;
+ bool balance_ok;
+ bool too_late;
+ bool conflict;
+ bool insufficient_funds;
+
+ /* into a purse */
+ TDB_purse (pg,
+ 40,
+ "1",
+ GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS),
+ &purse);
+ TDB_FILL (coin_sig,
+ 41);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_purse_deposit (pg,
+ &purse.purse_pub,
+ &coin.coin_pub,
+ &one,
+ &coin_sig,
+ &one,
+ &balance_ok,
+ &too_late,
+ &conflict));
+ FAILIF (! balance_ok);
+ FAILIF (conflict);
+
+ /* to keep a reserve open */
+ TDB_FILL (coin_sig,
+ 42);
+ TDB_FILL (reserve_sig,
+ 42);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_reserve_open_deposit (pg,
+ &coin,
+ &coin_sig,
+ known_coin_id,
+ &one,
+ &reserve_sig,
+ &reserve_pub,
+ &insufficient_funds));
+ FAILIF (insufficient_funds);
+
+ /* and into a melt */
+ {
+ struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf;
+ struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get ();
+ struct TALER_Amount coin_balance;
+ bool found;
+ bool zombie_required = false;
+ bool nonce_reuse;
+ bool melt_balance_ok;
+ uint32_t noreveal_index;
+ enum GNUNET_DB_QueryStatus qs;
+
+ memset (&rf,
+ 0,
+ sizeof (rf));
+ rf.coin.coin_pub = coin.coin_pub;
+ rf.coin.denom_pub_hash = coin.denom_pub_hash;
+ rf.coin.no_age_commitment = coin.no_age_commitment;
+ TDB_FILL (rf.coin_sig,
+ 43);
+ TDB_FILL (rf.rc,
+ 43);
+ TDB_FILL (rf.refresh_seed,
+ 43);
+ TDB_FILL (rf.planchets_h,
+ 43);
+ TDB_FILL (rf.selected_h,
+ 44);
+ rf.amount_with_fee = one;
+ rf.num_coins = 1;
+ rf.denom_serials = GNUNET_new (uint64_t);
+ rf.denom_serials[0] = denom.serial;
+ rf.denom_sigs = GNUNET_new (struct TALER_BlindedDenominationSignature);
+ TDB_blinded_denom_sig (43,
+ &rf.denom_sigs[0]);
+ rf.noreveal_index = 1;
+ rf.is_v27_refresh = true;
+ rf.no_blinding_seed = true;
+ qs = TALER_EXCHANGEDB_do_refresh (pg,
+ &rf,
+ &now,
+ &found,
+ &noreveal_index,
+ &zombie_required,
+ &nonce_reuse,
+ &melt_balance_ok,
+ &coin_balance);
+ TALER_blinded_denom_sig_free (&rf.denom_sigs[0]);
+ GNUNET_free (rf.denom_sigs);
+ GNUNET_free (rf.denom_serials);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs);
+ FAILIF (! melt_balance_ok);
+ }
+
+ FAILIF (5 != TDB_count (pg,
+ "FROM coin_history"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ history (pg,
+ 0,
+ 0,
+ &etag,
+ &tl));
+ FAILIF_C (5 != summarize (tl,
+ &types),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_PURSE_DEPOSIT)),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_RESERVE_OPEN)),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_MELT)),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+
+ /* the ETag is the highest coin history row of the list */
+ {
+ uint64_t max = 0;
+
+ for (const struct TALER_EXCHANGEDB_TransactionList *pos = tl;
+ NULL != pos;
+ pos = pos->next)
+ max = GNUNET_MAX (max,
+ pos->coin_history_id);
+ FAILIF_C (etag != max,
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ }
+
+ /* the spends add up to what the coin no longer has */
+ {
+ struct TALER_Amount total;
+ struct TALER_Amount expect = TDB_amount ("3.5");
+
+ FAILIF_C (GNUNET_OK !=
+ TALER_EXCHANGEDB_calculate_transaction_list_totals (tl,
+ &zero,
+ &total),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ FAILIF_C (0 != TALER_amount_cmp (&total,
+ &expect),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ }
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl);
+ return 0;
+}
+
+
+/**
+ * Recouping the coin adds the last entry and empties it.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_recoup (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_TransactionList *tl = NULL;
+ struct TALER_CoinSpendSignatureP coin_sig;
+ union GNUNET_CRYPTO_BlindingSecretP coin_bks;
+ struct GNUNET_TIME_Timestamp recoup_timestamp;
+ struct TALER_Amount balance;
+ struct TALER_Amount zero = TDB_amount ("0");
+ struct TALER_DenominationHashP h_denom_pub;
+ unsigned int types;
+ uint64_t etag = 0;
+ bool recoup_ok;
+ bool internal_failure;
+
+ TDB_FILL (coin_sig,
+ 50);
+ TDB_FILL (coin_bks,
+ 50);
+ /* in/out: the caller picks the time, the callee only overwrites it if
+ the coin was recouped before */
+ recoup_timestamp = GNUNET_TIME_timestamp_get ();
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_recoup (pg,
+ &reserve_pub,
+ withdraw_id,
+ &coin_bks,
+ &coin.coin_pub,
+ known_coin_id,
+ &coin_sig,
+ &recoup_timestamp,
+ &recoup_ok,
+ &internal_failure));
+ FAILIF (internal_failure);
+ FAILIF (! recoup_ok);
+ FAILIF (6 != TDB_count (pg,
+ "FROM coin_history"));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_coin_transactions (pg,
+ true,
+ &coin.coin_pub,
+ 0,
+ 0,
+ &etag,
+ &balance,
+ &h_denom_pub,
+ &tl));
+ FAILIF_C (6 != summarize (tl,
+ &types),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ FAILIF_C (0 == (types & (1U << TALER_EXCHANGEDB_TT_RECOUP_WITHDRAW)),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl);
+ FAILIF (0 != TALER_amount_cmp (&balance,
+ &zero));
+ return 0;
+}
+
+
+/**
+ * With begin_transaction false the caller's transaction is used and left
+ * open.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_in_transaction (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_TransactionList *tl = NULL;
+ struct TALER_Amount balance;
+ struct TALER_DenominationHashP h_denom_pub;
+ unsigned int types;
+ uint64_t etag = 0;
+
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "coin-history"));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_coin_transactions (pg,
+ false,
+ &coin.coin_pub,
+ 0,
+ 0,
+ &etag,
+ &balance,
+ &h_denom_pub,
+ &tl),
+ TALER_EXCHANGEDB_rollback (pg));
+ FAILIF_C (6 != summarize (tl,
+ &types),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl);
+ TALER_EXCHANGEDB_rollback (pg));
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl);
+ FAILIF_C (NULL == pg->transaction_name,
+ TALER_EXCHANGEDB_rollback (pg));
+ TALER_EXCHANGEDB_rollback (pg);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "coin-history-unknown-coin",
+ &check_unknown_coin },
+ { "coin-history-fresh-coin",
+ &check_fresh_coin },
+ { "coin-history-deposit-and-refund",
+ &check_deposit_and_refund },
+ { "coin-history-etag",
+ &check_etag },
+ { "coin-history-offset",
+ &check_offset },
+ { "coin-history-other-spends",
+ &check_other_spends },
+ { "coin-history-recoup",
+ &check_recoup },
+ { "coin-history-in-transaction",
+ &check_in_transaction },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-coin-history",
+ "Tests for the exchangedb `coin_history' table",
+ tests);
+ TDB_coin_free (&coin);
+ TDB_denom_free (&denom);
+ TDB_account_free (&account);
+ return ret;
+}
+
+
+/* end of test_coin_history.c */
diff --git a/src/exchangedb/test_common.c b/src/exchangedb/test_common.c
@@ -0,0 +1,866 @@
+/*
+ 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/test_common.c
+ * @brief shared scaffolding for the per-table exchangedb tests
+ * @author Christian Grothoff
+ */
+#include "test_common.h"
+#include "exchange-database/create_tables.h"
+#include "exchange-database/do_insert_known_coin.h"
+#include "exchange-database/insert_denomination_info.h"
+#include "exchange-database/get_denomination_info.h"
+#include "exchange-database/do_deposit.h"
+#include "exchange-database/insert_purse_request.h"
+#include "exchange-database/do_withdraw.h"
+
+
+/**
+ * Name of the single check to run, NULL to run all of them.
+ */
+static char *only;
+
+/**
+ * Return value of the process.
+ */
+static int result;
+
+/**
+ * Checks the current binary wants to run.
+ */
+static const struct TDB_Test *tdb_tests;
+
+
+enum GNUNET_GenericReturnValue
+TDB_exec (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const char *fmt,
+ ...)
+{
+ enum GNUNET_GenericReturnValue ret;
+ va_list ap;
+ char *sql;
+ int len;
+
+ va_start (ap,
+ fmt);
+ len = vsnprintf (NULL,
+ 0,
+ fmt,
+ ap);
+ va_end (ap);
+ GNUNET_assert (len >= 0);
+ sql = GNUNET_malloc ((size_t) len + 1);
+ va_start (ap,
+ fmt);
+ GNUNET_assert (len ==
+ vsnprintf (sql,
+ (size_t) len + 1,
+ fmt,
+ ap));
+ va_end (ap);
+ {
+ struct GNUNET_PQ_ExecuteStatement es[] = {
+ GNUNET_PQ_make_execute (sql),
+ GNUNET_PQ_EXECUTE_STATEMENT_END
+ };
+
+ ret = GNUNET_PQ_exec_statements (pg->conn,
+ es);
+ }
+ GNUNET_free (sql);
+ return ret;
+}
+
+
+uint64_t
+TDB_count (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const char *fmt,
+ ...)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_end
+ };
+ uint64_t n;
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("n",
+ &n),
+ GNUNET_PQ_result_spec_end
+ };
+ va_list ap;
+ char *frag;
+ char *sql;
+ int len;
+
+ va_start (ap,
+ fmt);
+ len = vsnprintf (NULL,
+ 0,
+ fmt,
+ ap);
+ va_end (ap);
+ GNUNET_assert (len >= 0);
+ frag = GNUNET_malloc ((size_t) len + 1);
+ va_start (ap,
+ fmt);
+ GNUNET_assert (len ==
+ vsnprintf (frag,
+ (size_t) len + 1,
+ fmt,
+ ap));
+ va_end (ap);
+ GNUNET_asprintf (&sql,
+ "SELECT COUNT(*) AS n %s;",
+ frag);
+ GNUNET_free (frag);
+ GNUNET_assert (GNUNET_OK ==
+ GNUNET_PQ_prepare_anon (pg->conn,
+ sql));
+ GNUNET_free (sql);
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs));
+ return n;
+}
+
+
+struct TALER_Amount
+TDB_amount (const char *str)
+{
+ struct TALER_Amount amount;
+ char *s;
+
+ GNUNET_asprintf (&s,
+ CURRENCY ":%s",
+ str);
+ GNUNET_assert (GNUNET_OK ==
+ TALER_string_to_amount (s,
+ &amount));
+ GNUNET_free (s);
+ return amount;
+}
+
+
+void
+TDB_fill (void *ptr,
+ size_t size,
+ uint32_t seed)
+{
+ unsigned char *buf = ptr;
+ uint32_t state = seed * 2654435761U + 1;
+
+ for (size_t i = 0; i<size; i++)
+ {
+ state = state * 1103515245U + 12345U;
+ buf[i] = (unsigned char) (state >> 16);
+ }
+}
+
+
+char *
+TDB_hex (const void *data,
+ size_t size)
+{
+ const unsigned char *raw = data;
+ char *hex;
+
+ hex = GNUNET_malloc (size * 2 + 1);
+ for (size_t i = 0; i<size; i++)
+ GNUNET_snprintf (&hex[i * 2],
+ 3,
+ "%02x",
+ raw[i]);
+ return hex;
+}
+
+
+void
+TDB_denom_sig (uint32_t seed,
+ struct TALER_DenominationSignature *sig)
+{
+ struct GNUNET_CRYPTO_UnblindedSignature *ubs;
+
+ ubs = GNUNET_new (struct GNUNET_CRYPTO_UnblindedSignature);
+ ubs->cipher = GNUNET_CRYPTO_BSA_CS;
+ ubs->rc = 1;
+ TDB_fill (&ubs->details.cs_signature,
+ sizeof (ubs->details.cs_signature),
+ seed);
+ sig->unblinded_sig = ubs;
+}
+
+
+void
+TDB_blinded_denom_sig (uint32_t seed,
+ struct TALER_BlindedDenominationSignature *sig)
+{
+ struct GNUNET_CRYPTO_BlindedSignature *bs;
+
+ bs = GNUNET_new (struct GNUNET_CRYPTO_BlindedSignature);
+ bs->cipher = GNUNET_CRYPTO_BSA_CS;
+ bs->rc = 1;
+ TDB_fill (&bs->details.blinded_cs_answer,
+ sizeof (bs->details.blinded_cs_answer),
+ seed);
+ sig->blinded_sig = bs;
+}
+
+
+void
+TDB_denom (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ const char *value,
+ const char *fee,
+ struct TDB_Denom *denom)
+{
+ struct GNUNET_TIME_Timestamp now;
+ struct TALER_Amount f;
+
+ memset (denom,
+ 0,
+ sizeof (*denom));
+ /* Build the Clause-Schnorr key pair from @a seed instead of at random, so
+ that two calls with the same seed name the same denomination -- checks
+ rely on that to refer to a row an earlier check created. The top nibble
+ of the scalar is cleared to keep it below the group order. */
+ {
+ struct GNUNET_CRYPTO_BlindSignPrivateKey *bpriv;
+ struct GNUNET_CRYPTO_BlindSignPublicKey *bpub;
+ unsigned char *d;
+
+ bpriv = GNUNET_new (struct GNUNET_CRYPTO_BlindSignPrivateKey);
+ bpriv->cipher = GNUNET_CRYPTO_BSA_CS;
+ bpriv->rc = 1;
+ d = bpriv->details.cs_private_key.scalar.d;
+ TDB_fill (d,
+ sizeof (bpriv->details.cs_private_key.scalar.d),
+ seed);
+ d[sizeof (bpriv->details.cs_private_key.scalar.d) - 1] &= 0x0f;
+ bpub = GNUNET_new (struct GNUNET_CRYPTO_BlindSignPublicKey);
+ bpub->cipher = GNUNET_CRYPTO_BSA_CS;
+ bpub->rc = 1;
+ GNUNET_CRYPTO_cs_private_key_get_public (
+ &bpriv->details.cs_private_key,
+ &bpub->details.cs_public_key);
+ GNUNET_CRYPTO_hash (&bpub->details.cs_public_key,
+ sizeof (bpub->details.cs_public_key),
+ &bpub->pub_key_hash);
+ denom->priv.bsign_priv_key = bpriv;
+ denom->pub.bsign_pub_key = bpub;
+ }
+ TALER_denom_pub_hash (&denom->pub,
+ &denom->h_denom_pub);
+ /* The same seed may be used by more than one check to name the same
+ denomination; the second call then only has to load it. */
+ if (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_get_denomination_info (pg,
+ &denom->h_denom_pub,
+ &denom->serial,
+ &denom->issue))
+ return;
+ now = GNUNET_TIME_timestamp_get ();
+ f = TDB_amount (fee);
+ denom->issue.start = now;
+ denom->issue.expire_withdraw
+ = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_add (now.abs_time,
+ GNUNET_TIME_UNIT_HOURS));
+ denom->issue.expire_deposit
+ = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_add (now.abs_time,
+ GNUNET_TIME_relative_multiply (
+ GNUNET_TIME_UNIT_HOURS,
+ 2)));
+ denom->issue.expire_legal
+ = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_add (now.abs_time,
+ GNUNET_TIME_relative_multiply (
+ GNUNET_TIME_UNIT_HOURS,
+ 3)));
+ denom->issue.value = TDB_amount (value);
+ denom->issue.fees.withdraw = f;
+ denom->issue.fees.deposit = f;
+ denom->issue.fees.refresh = f;
+ denom->issue.fees.refund = f;
+ denom->issue.denom_hash = denom->h_denom_pub;
+ TDB_fill (&denom->issue.signature,
+ sizeof (denom->issue.signature),
+ seed);
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_insert_denomination_info (pg,
+ &denom->pub,
+ &denom->issue));
+ {
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (&denom->h_denom_pub),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("denominations_serial",
+ &denom->serial),
+ GNUNET_PQ_result_spec_end
+ };
+
+ GNUNET_assert (GNUNET_OK ==
+ GNUNET_PQ_prepare_anon (
+ pg->conn,
+ "SELECT denominations_serial"
+ " FROM denominations"
+ " WHERE denom_pub_hash=$1;"));
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ GNUNET_PQ_eval_prepared_singleton_select (
+ pg->conn,
+ "",
+ params,
+ rs));
+ }
+}
+
+
+void
+TDB_denom_free (struct TDB_Denom *denom)
+{
+ TALER_denom_pub_free (&denom->pub);
+ TALER_denom_priv_free (&denom->priv);
+}
+
+
+void
+TDB_coin (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Denom *denom,
+ uint32_t seed,
+ struct TALER_CoinPublicInfo *coin,
+ uint64_t *known_coin_id)
+{
+ struct TALER_DenominationHashP dh;
+ struct TALER_AgeCommitmentHashP hac;
+ enum TALER_EXCHANGEDB_CoinKnownStatus cks;
+ uint64_t kci;
+
+ memset (coin,
+ 0,
+ sizeof (*coin));
+ TDB_FILL (coin->coin_pub,
+ seed);
+ coin->denom_pub_hash = denom->h_denom_pub;
+ coin->no_age_commitment = true;
+ TDB_denom_sig (seed,
+ &coin->denom_sig);
+ cks = TALER_EXCHANGEDB_do_insert_known_coin (pg,
+ coin,
+ &kci,
+ &dh,
+ &hac);
+ /* CKS_PRESENT is fine: the same seed may name a coin an earlier check
+ already made known. */
+ GNUNET_assert ( (TALER_EXCHANGEDB_CKS_ADDED == cks) ||
+ (TALER_EXCHANGEDB_CKS_PRESENT == cks) );
+ if (NULL != known_coin_id)
+ *known_coin_id = kci;
+}
+
+
+void
+TDB_coin_free (struct TALER_CoinPublicInfo *coin)
+{
+ TALER_denom_sig_free (&coin->denom_sig);
+}
+
+
+void
+TDB_account (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ struct TDB_Account *account)
+{
+ struct TALER_NormalizedPayto npayto;
+ char *fhex;
+ char *nhex;
+
+ GNUNET_asprintf (&account->payto.full_payto,
+ "payto://x-taler-bank/localhost/tdb%u?receiver-name=tdb%u",
+ (unsigned int) seed,
+ (unsigned int) seed);
+ TALER_full_payto_hash (account->payto,
+ &account->h_full);
+ npayto = TALER_payto_normalize (account->payto);
+ GNUNET_assert (NULL != npayto.normalized_payto);
+ TALER_normalized_payto_hash (npayto,
+ &account->h_normalized);
+ GNUNET_free (npayto.normalized_payto);
+ fhex = TDB_hex (&account->h_full,
+ sizeof (account->h_full));
+ nhex = TDB_hex (&account->h_normalized,
+ sizeof (account->h_normalized));
+ GNUNET_assert (GNUNET_OK ==
+ TDB_exec (pg,
+ "INSERT INTO kyc_targets"
+ " (h_normalized_payto,is_wallet)"
+ " VALUES (decode('%s','hex'),FALSE)"
+ " ON CONFLICT DO NOTHING;"
+ "INSERT INTO wire_targets"
+ " (wire_target_h_payto,payto_uri"
+ " ,h_normalized_payto)"
+ " VALUES (decode('%s','hex'),'%s'"
+ " ,decode('%s','hex'))"
+ " ON CONFLICT DO NOTHING;",
+ nhex,
+ fhex,
+ account->payto.full_payto,
+ nhex));
+ GNUNET_free (fhex);
+ GNUNET_free (nhex);
+}
+
+
+void
+TDB_account_free (struct TDB_Account *account)
+{
+ GNUNET_free (account->payto.full_payto);
+}
+
+
+void
+TDB_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ const char *balance,
+ struct TALER_ReservePublicKeyP *reserve_pub)
+{
+ struct TALER_Amount b = TDB_amount (balance);
+ char *hex;
+
+ TDB_fill (reserve_pub,
+ sizeof (*reserve_pub),
+ seed);
+ hex = TDB_hex (reserve_pub,
+ sizeof (*reserve_pub));
+ GNUNET_assert (GNUNET_OK ==
+ TDB_exec (pg,
+ "INSERT INTO reserves"
+ " (reserve_pub,current_balance"
+ " ,expiration_date,gc_date)"
+ " VALUES (decode('%s','hex')"
+ " ,ROW(%llu,%u)::taler_amount"
+ " ,%llu,%llu)"
+ " ON CONFLICT DO NOTHING;",
+ hex,
+ (unsigned long long) b.value,
+ (unsigned int) b.fraction,
+ (unsigned long long) 4000000000000000LLU,
+ (unsigned long long) 5000000000000000LLU));
+ GNUNET_free (hex);
+}
+
+
+void
+TDB_purse (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ const char *amount,
+ struct GNUNET_TIME_Timestamp purse_expiration,
+ struct TDB_Purse *purse)
+{
+ struct TALER_Amount purse_fee = TDB_amount ("0");
+ enum GNUNET_DB_QueryStatus qs;
+ bool in_conflict = false;
+
+ memset (purse,
+ 0,
+ sizeof (*purse));
+ TDB_fill (&purse->purse_pub,
+ sizeof (purse->purse_pub),
+ seed);
+ TDB_fill (&purse->merge_pub,
+ sizeof (purse->merge_pub),
+ seed);
+ TDB_fill (&purse->h_contract_terms,
+ sizeof (purse->h_contract_terms),
+ seed);
+ TDB_fill (&purse->purse_sig,
+ sizeof (purse->purse_sig),
+ seed);
+ purse->purse_expiration = purse_expiration;
+ purse->amount = TDB_amount (amount);
+ qs = TALER_EXCHANGEDB_insert_purse_request (
+ pg,
+ &purse->purse_pub,
+ &purse->merge_pub,
+ purse_expiration,
+ &purse->h_contract_terms,
+ 0,
+ TALER_WAMF_MODE_CREATE_FROM_PURSE_QUOTA,
+ &purse_fee,
+ &purse->amount,
+ &purse->purse_sig,
+ &in_conflict);
+ /* SUCCESS_NO_RESULTS is the answer for an identical replay, which is
+ what a second call with the same seed is. */
+ GNUNET_assert (0 <= qs);
+ GNUNET_assert (! in_conflict);
+}
+
+
+void
+TDB_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Account *account,
+ const struct TALER_CoinPublicInfo *coin,
+ uint32_t seed,
+ const char *amount,
+ const char *fee,
+ struct GNUNET_TIME_Timestamp wire_deadline,
+ struct GNUNET_TIME_Timestamp refund_deadline,
+ struct TDB_Deposit *dep)
+{
+ struct TALER_EXCHANGEDB_CoinDepositInformation cdi;
+ struct TALER_EXCHANGEDB_BatchDeposit bd;
+ struct TALER_Amount fees[1];
+ struct TALER_Amount total;
+ uint32_t bad_balance_index;
+ bool balance_ok;
+ bool in_conflict;
+
+ memset (dep,
+ 0,
+ sizeof (*dep));
+ TDB_fill (&dep->merchant_pub,
+ sizeof (dep->merchant_pub),
+ seed);
+ TDB_fill (&dep->merchant_sig,
+ sizeof (dep->merchant_sig),
+ seed);
+ TDB_fill (&dep->h_contract_terms,
+ sizeof (dep->h_contract_terms),
+ seed);
+ TDB_fill (&dep->wire_salt,
+ sizeof (dep->wire_salt),
+ seed);
+ TALER_merchant_wire_signature_hash (account->payto,
+ &dep->wire_salt,
+ &dep->h_wire);
+ dep->wire_deadline = wire_deadline;
+ dep->refund_deadline = refund_deadline;
+ memset (&cdi,
+ 0,
+ sizeof (cdi));
+ cdi.coin = *coin;
+ TDB_fill (&cdi.csig,
+ sizeof (cdi.csig),
+ seed);
+ cdi.amount_with_fee = TDB_amount (amount);
+ fees[0] = TDB_amount (fee);
+ memset (&bd,
+ 0,
+ sizeof (bd));
+ bd.merchant_pub = dep->merchant_pub;
+ bd.merchant_sig = dep->merchant_sig;
+ bd.h_contract_terms = dep->h_contract_terms;
+ bd.no_wallet_data_hash = true;
+ bd.wire_target_h_payto = account->h_full;
+ bd.wire_salt = dep->wire_salt;
+ bd.wallet_timestamp = wire_deadline;
+ bd.refund_deadline = refund_deadline;
+ bd.wire_deadline = wire_deadline;
+ bd.receiver_wire_account = account->payto;
+ bd.cdis = &cdi;
+ bd.num_cdis = 1;
+ dep->exchange_timestamp = GNUNET_TIME_timestamp_get ();
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_do_deposit (pg,
+ &bd,
+ fees,
+ &dep->exchange_timestamp,
+ &total,
+ &balance_ok,
+ &bad_balance_index,
+ &in_conflict));
+ GNUNET_assert (balance_ok);
+ GNUNET_assert (! in_conflict);
+ {
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (&dep->merchant_pub),
+ GNUNET_PQ_query_param_auto_from_type (&dep->h_contract_terms),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("batch_deposit_serial_id",
+ &dep->serial),
+ GNUNET_PQ_result_spec_end
+ };
+
+ GNUNET_assert (GNUNET_OK ==
+ GNUNET_PQ_prepare_anon (pg->conn,
+ "SELECT batch_deposit_serial_id"
+ " FROM batch_deposits"
+ " WHERE merchant_pub=$1"
+ " AND h_contract_terms=$2;"));
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs));
+ }
+}
+
+
+uint64_t
+TDB_withdraw (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Denom *denom,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ uint32_t seed,
+ const char *amount)
+{
+ struct TALER_EXCHANGEDB_Withdraw wd;
+ struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get ();
+ struct TALER_Amount reserve_balance;
+ struct TALER_BlindedDenominationSignature denom_sig;
+ uint64_t denom_serial = denom->serial;
+ uint64_t withdraw_id;
+ uint16_t allowed_maximum_age;
+ uint16_t noreveal_index;
+ uint32_t reserve_birthday;
+ bool balance_ok;
+ bool age_ok;
+ bool idempotent;
+ bool nonce_reuse;
+
+ memset (&wd,
+ 0,
+ sizeof (wd));
+ wd.amount_with_fee = TDB_amount (amount);
+ wd.reserve_pub = *reserve_pub;
+ TDB_fill (&wd.planchets_h,
+ sizeof (wd.planchets_h),
+ seed);
+ TDB_fill (&wd.reserve_sig,
+ sizeof (wd.reserve_sig),
+ seed);
+ wd.num_coins = 1;
+ wd.denom_serials = &denom_serial;
+ TDB_blinded_denom_sig (seed,
+ &denom_sig);
+ wd.denom_sigs = &denom_sig;
+ wd.no_blinding_seed = true;
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_do_withdraw (pg,
+ &wd,
+ &now,
+ &balance_ok,
+ &reserve_balance,
+ &age_ok,
+ &allowed_maximum_age,
+ &reserve_birthday,
+ &idempotent,
+ &noreveal_index,
+ &nonce_reuse));
+ GNUNET_assert (balance_ok);
+ GNUNET_assert (age_ok);
+ GNUNET_assert (! nonce_reuse);
+ TALER_blinded_denom_sig_free (&denom_sig);
+ {
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (&wd.planchets_h),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("withdraw_id",
+ &withdraw_id),
+ GNUNET_PQ_result_spec_end
+ };
+
+ GNUNET_assert (GNUNET_OK ==
+ GNUNET_PQ_prepare_anon (pg->conn,
+ "SELECT withdraw_id"
+ " FROM withdraw"
+ " WHERE planchets_h=$1;"));
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs));
+ }
+ return withdraw_id;
+}
+
+
+uint64_t
+TDB_reserve_in (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Account *account,
+ uint32_t seed,
+ const char *amount,
+ struct TALER_ReservePublicKeyP *reserve_pub)
+{
+ struct TALER_Amount a = TDB_amount (amount);
+ uint64_t serial;
+ char *rhex;
+ char *ahex;
+
+ TDB_reserve (pg,
+ seed,
+ amount,
+ reserve_pub);
+ rhex = TDB_hex (reserve_pub,
+ sizeof (*reserve_pub));
+ ahex = TDB_hex (&account->h_full,
+ sizeof (account->h_full));
+ GNUNET_assert (GNUNET_OK ==
+ TDB_exec (pg,
+ "INSERT INTO reserves_in"
+ " (reserve_pub,wire_reference,credit"
+ " ,wire_source_h_payto"
+ " ,exchange_account_section,execution_date)"
+ " VALUES (decode('%s','hex'),%u"
+ " ,ROW(%llu,%u)::taler_amount"
+ " ,decode('%s','hex'),'exchange-account-1'"
+ " ,%llu);",
+ rhex,
+ (unsigned int) seed,
+ (unsigned long long) a.value,
+ (unsigned int) a.fraction,
+ ahex,
+ (unsigned long long) 1600000000000000LLU));
+ {
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (reserve_pub),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("reserve_in_serial_id",
+ &serial),
+ GNUNET_PQ_result_spec_end
+ };
+
+ GNUNET_assert (GNUNET_OK ==
+ GNUNET_PQ_prepare_anon (
+ pg->conn,
+ "SELECT reserve_in_serial_id"
+ " FROM reserves_in"
+ " WHERE reserve_pub=$1;"));
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ GNUNET_PQ_eval_prepared_singleton_select (
+ pg->conn,
+ "",
+ params,
+ rs));
+ }
+ GNUNET_free (rhex);
+ GNUNET_free (ahex);
+ return serial;
+}
+
+
+/**
+ * Main function that runs the checks.
+ *
+ * @param cls closure
+ * @param args remaining command-line arguments
+ * @param cfgfile name of the configuration file used
+ * @param cfg configuration
+ */
+static void
+run (void *cls,
+ char *const *args,
+ const char *cfgfile,
+ const struct GNUNET_CONFIGURATION_Handle *cfg)
+{
+ struct TALER_EXCHANGEDB_PostgresContext *pg;
+ unsigned int ran = 0;
+
+ (void) cls;
+ (void) args;
+ (void) cfgfile;
+ pg = TALER_EXCHANGEDB_connect_admin (cfg);
+ if (NULL == pg)
+ {
+ fprintf (stderr,
+ "Failed to connect to the database\n");
+ result = 77;
+ return;
+ }
+ if (GNUNET_OK !=
+ TALER_EXCHANGEDB_create_tables (pg,
+ false,
+ 0))
+ {
+ fprintf (stderr,
+ "Failed to create the database schema\n");
+ result = 77;
+ goto cleanup;
+ }
+ for (unsigned int i = 0; NULL != tdb_tests[i].name; i++)
+ {
+ if ( (NULL != only) &&
+ (0 != strcmp (only,
+ tdb_tests[i].name)) )
+ continue;
+ fprintf (stderr,
+ "Running check `%s'\n",
+ tdb_tests[i].name);
+ ran++;
+ if (0 != tdb_tests[i].fn (pg))
+ {
+ fprintf (stderr,
+ "Check `%s' FAILED\n",
+ tdb_tests[i].name);
+ result = 1;
+ }
+ }
+ if (0 == ran)
+ {
+ fprintf (stderr,
+ "No check matched `%s'\n",
+ only);
+ result = 1;
+ }
+cleanup:
+ TALER_EXCHANGEDB_disconnect (pg);
+}
+
+
+int
+TDB_main (int argc,
+ char *const *argv,
+ const char *binary,
+ const char *description,
+ const struct TDB_Test *tests)
+{
+ struct GNUNET_GETOPT_CommandLineOption options[] = {
+ GNUNET_GETOPT_option_string ('t',
+ "test",
+ "NAME",
+ "only run the check called NAME",
+ &only),
+ GNUNET_GETOPT_OPTION_END
+ };
+ enum GNUNET_GenericReturnValue ret;
+
+ result = 0;
+ tdb_tests = tests;
+ ret = GNUNET_PROGRAM_run (TALER_EXCHANGE_project_data (),
+ argc,
+ argv,
+ binary,
+ description,
+ options,
+ &run,
+ NULL);
+ if (GNUNET_SYSERR == ret)
+ return 3;
+ if (GNUNET_NO == ret)
+ return 0;
+ return result;
+}
+
+
+/* end of test_common.c */
diff --git a/src/exchangedb/test_common.h b/src/exchangedb/test_common.h
@@ -0,0 +1,538 @@
+/*
+ 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/test_common.h
+ * @brief shared scaffolding for the per-table exchangedb tests
+ * @author Christian Grothoff
+ *
+ * Every `test_$TABLE.c` in this directory is a stand-alone binary that runs
+ * the exported database functions whose primary test table is `$TABLE`
+ * against a scratch database. This header holds the bits all of them need:
+ * the main loop, a raw-SQL escape hatch and fixtures for the rows that the
+ * functions under test reference by foreign key.
+ *
+ * The test data is deliberately bogus: keys are derived from a seed byte and
+ * signatures are never verifiable. The database layer does not check any of
+ * that, and building real signatures would only make the tests slower and
+ * harder to read.
+ */
+#ifndef TEST_COMMON_H
+#define TEST_COMMON_H
+
+#include "exchangedb_lib.h"
+#include "helper.h"
+#include "taler/taler_json_lib.h"
+
+
+/**
+ * Currency the scratch database is configured for. Must match
+ * test_table.sh.
+ */
+#define CURRENCY "EUR"
+
+
+/**
+ * Report a failed expectation and return 1 from the calling check.
+ */
+#define FAILIF(cond) \
+ do { \
+ if (! (cond)) break; \
+ GNUNET_break (0); \
+ fprintf (stderr, \
+ "FAILED: %s at %s:%u\n", \
+ # cond, __FILE__, __LINE__); \
+ return 1; \
+ } while (0)
+
+
+/**
+ * Like #FAILIF(), but runs @a cleanup before returning.
+ */
+#define FAILIF_C(cond,cleanup) \
+ do { \
+ if (! (cond)) break; \
+ GNUNET_break (0); \
+ fprintf (stderr, \
+ "FAILED: %s at %s:%u\n", \
+ # cond, __FILE__, __LINE__); \
+ cleanup; \
+ return 1; \
+ } while (0)
+
+
+/**
+ * One check in a `test_$TABLE.c` binary.
+ */
+struct TDB_Test
+{
+ /**
+ * Name of the check, used for the `-t` command-line option. NULL
+ * terminates the array.
+ */
+ const char *name;
+
+ /**
+ * Function to run. Returns 0 on success.
+ */
+ int (*fn)(struct TALER_EXCHANGEDB_PostgresContext *pg);
+};
+
+
+/**
+ * Run the checks in @a tests. Connects to the database, creates the
+ * schema and then runs every check (or only the one named with `-t`).
+ *
+ * @param argc number of arguments in @a argv
+ * @param argv command line
+ * @param binary name of the binary, for the usage message
+ * @param description one-line description of the binary
+ * @param tests NULL-terminated array of checks to run
+ * @return 0 on success, 1 if a check failed, 77 if the database was
+ * unusable, 3 on a command-line error
+ */
+int
+TDB_main (int argc,
+ char *const *argv,
+ const char *binary,
+ const char *description,
+ const struct TDB_Test *tests);
+
+
+/**
+ * Run @a fmt on the database connection, outside of any transaction.
+ * Used to set up rows that no exported function can create, and to
+ * assert over the database from the outside.
+ *
+ * @param pg the database context
+ * @param fmt printf-style SQL statement(s)
+ * @return #GNUNET_OK on success
+ */
+enum GNUNET_GenericReturnValue
+TDB_exec (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const char *fmt,
+ ...)
+__attribute__ ((format (printf, 2, 3)));
+
+
+/**
+ * Count the rows the SQL fragment @a fmt selects. The fragment is
+ * appended to "SELECT COUNT(*) AS n ", so it starts with FROM.
+ *
+ * @param pg the database context
+ * @param fmt printf-style SQL fragment, e.g. "FROM reserves WHERE ..."
+ * @return number of matching rows
+ */
+uint64_t
+TDB_count (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const char *fmt,
+ ...)
+__attribute__ ((format (printf, 2, 3)));
+
+
+/**
+ * Convert @a str to an amount in our currency.
+ *
+ * @param str amount without the currency prefix, e.g. "10.5"
+ * @return the parsed amount
+ */
+struct TALER_Amount
+TDB_amount (const char *str);
+
+
+/**
+ * Fill @a size bytes at @a ptr with data derived from @a seed. Different
+ * seeds give different data, the same seed always gives the same data.
+ *
+ * @param[out] ptr buffer to fill
+ * @param size number of bytes to write to @a ptr
+ * @param seed seed to derive the data from
+ */
+void
+TDB_fill (void *ptr,
+ size_t size,
+ uint32_t seed);
+
+
+/**
+ * Fill the object @a x with data derived from @a seed.
+ */
+#define TDB_FILL(x,seed) TDB_fill (&(x), sizeof (x), seed)
+
+
+/**
+ * Render @a size bytes at @a data as a lowercase hex string for use in an
+ * SQL literal.
+ *
+ * @param data binary data
+ * @param size number of bytes in @a data
+ * @return hex string, to be freed by the caller
+ */
+char *
+TDB_hex (const void *data,
+ size_t size);
+
+
+/**
+ * A denomination that exists in the `denominations` table.
+ */
+struct TDB_Denom
+{
+ /**
+ * Private key; never used to sign anything meaningful.
+ */
+ struct TALER_DenominationPrivateKey priv;
+
+ /**
+ * Public key, as stored in the database.
+ */
+ struct TALER_DenominationPublicKey pub;
+
+ /**
+ * Hash of @e pub, the primary key of the denomination.
+ */
+ struct TALER_DenominationHashP h_denom_pub;
+
+ /**
+ * Row of the denomination in the `denominations` table.
+ */
+ uint64_t serial;
+
+ /**
+ * Value, fees and validity period as inserted.
+ */
+ struct TALER_EXCHANGEDB_DenominationKeyInformation issue;
+};
+
+
+/**
+ * Create a denomination and insert it into the `denominations` table.
+ * Fails hard if the insert does not work: a test that cannot set up its
+ * fixtures has nothing to say about the function under test.
+ *
+ * @param pg the database context
+ * @param seed seed for the key material and the master signature
+ * @param value value of a coin of this denomination, e.g. "5"
+ * @param fee fee charged for every operation on such a coin, e.g. "0.1"
+ * @param[out] denom set to the denomination
+ */
+void
+TDB_denom (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ const char *value,
+ const char *fee,
+ struct TDB_Denom *denom);
+
+
+/**
+ * Release the key material of @a denom. The row stays in the database.
+ *
+ * @param[in,out] denom denomination to clean up
+ */
+void
+TDB_denom_free (struct TDB_Denom *denom);
+
+
+/**
+ * Build an (invalid) denomination signature that the database layer will
+ * happily store and read back.
+ *
+ * @param seed seed to derive the signature from
+ * @param[out] sig set to the signature, to be freed with
+ * TALER_denom_sig_free()
+ */
+void
+TDB_denom_sig (uint32_t seed,
+ struct TALER_DenominationSignature *sig);
+
+
+/**
+ * Build an (invalid) blinded denomination signature that the database
+ * layer will happily store and read back.
+ *
+ * @param seed seed to derive the signature from
+ * @param[out] sig set to the signature, to be freed with
+ * TALER_blinded_denom_sig_free()
+ */
+void
+TDB_blinded_denom_sig (uint32_t seed,
+ struct TALER_BlindedDenominationSignature *sig);
+
+
+/**
+ * Create a coin of denomination @a denom and insert it into the
+ * `known_coins` table. Fails hard if the insert does not work.
+ *
+ * @param pg the database context
+ * @param denom denomination of the coin
+ * @param seed seed for the coin's key and signature
+ * @param[out] coin set to the coin, to be cleaned up with TDB_coin_free()
+ * @param[out] known_coin_id set to the row of the coin, may be NULL
+ */
+void
+TDB_coin (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Denom *denom,
+ uint32_t seed,
+ struct TALER_CoinPublicInfo *coin,
+ uint64_t *known_coin_id);
+
+
+/**
+ * Release the signature of @a coin. The row stays in the database.
+ *
+ * @param[in,out] coin coin to clean up
+ */
+void
+TDB_coin_free (struct TALER_CoinPublicInfo *coin);
+
+
+/**
+ * A bank account known to the exchange.
+ */
+struct TDB_Account
+{
+ /**
+ * Full payto URI of the account.
+ */
+ struct TALER_FullPayto payto;
+
+ /**
+ * Hash of @e payto, the primary key in `wire_targets`.
+ */
+ struct TALER_FullPaytoHashP h_full;
+
+ /**
+ * Hash of the normalized form of @e payto, the primary key in
+ * `kyc_targets`.
+ */
+ struct TALER_NormalizedPaytoHashP h_normalized;
+};
+
+
+/**
+ * Create a `kyc_targets` and a `wire_targets` row for a fresh bank
+ * account. Both tables are referenced by foreign keys all over the
+ * schema, but no exported function creates a row in them on its own.
+ *
+ * @param pg the database context
+ * @param seed seed for the account name
+ * @param[out] account set to the account, to be cleaned up with
+ * TDB_account_free()
+ */
+void
+TDB_account (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ struct TDB_Account *account);
+
+
+/**
+ * Release the payto URI of @a account. The rows stay in the database.
+ *
+ * @param[in,out] account account to clean up
+ */
+void
+TDB_account_free (struct TDB_Account *account);
+
+
+/**
+ * Create a reserve with the given @a balance, as
+ * exchange_do_purse_merge() does: without a `reserves_in` row, so the
+ * reserve has no origin account.
+ *
+ * @param pg the database context
+ * @param seed seed for the reserve's public key
+ * @param balance balance to give the reserve, e.g. "10"
+ * @param[out] reserve_pub set to the public key of the reserve
+ */
+void
+TDB_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ const char *balance,
+ struct TALER_ReservePublicKeyP *reserve_pub);
+
+
+/**
+ * A purse, as TDB_purse() created it.
+ */
+struct TDB_Purse
+{
+ /**
+ * Public key of the purse.
+ */
+ struct TALER_PurseContractPublicKeyP purse_pub;
+
+ /**
+ * Key that allows merging the purse.
+ */
+ struct TALER_PurseMergePublicKeyP merge_pub;
+
+ /**
+ * Contract the purse is for.
+ */
+ struct TALER_PrivateContractHashP h_contract_terms;
+
+ /**
+ * Signature over the purse's meta data.
+ */
+ struct TALER_PurseContractSignatureP purse_sig;
+
+ /**
+ * When the purse expires.
+ */
+ struct GNUNET_TIME_Timestamp purse_expiration;
+
+ /**
+ * Target amount of the purse.
+ */
+ struct TALER_Amount amount;
+};
+
+
+/**
+ * Create a purse with #TALER_EXCHANGEDB_insert_purse_request(). Fails
+ * hard if the purse cannot be created.
+ *
+ * @param pg the database context
+ * @param seed seed for the purse's keys, contract and signature
+ * @param amount target amount of the purse, e.g. "5"
+ * @param purse_expiration when the purse expires
+ * @param[out] purse set to the purse
+ */
+void
+TDB_purse (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ const char *amount,
+ struct GNUNET_TIME_Timestamp purse_expiration,
+ struct TDB_Purse *purse);
+
+
+/**
+ * A batch deposit of one coin, as TDB_deposit() made it.
+ */
+struct TDB_Deposit
+{
+ /**
+ * Merchant the coin was deposited with.
+ */
+ struct TALER_MerchantPublicKeyP merchant_pub;
+
+ /**
+ * Merchant signature over the contract.
+ */
+ struct TALER_MerchantSignatureP merchant_sig;
+
+ /**
+ * Contract the deposit is for.
+ */
+ struct TALER_PrivateContractHashP h_contract_terms;
+
+ /**
+ * Salt the merchant used for @e h_wire.
+ */
+ struct TALER_WireSaltP wire_salt;
+
+ /**
+ * Salted hash of the merchant's account.
+ */
+ struct TALER_MerchantWireHashP h_wire;
+
+ /**
+ * When the merchant wants to be paid.
+ */
+ struct GNUNET_TIME_Timestamp wire_deadline;
+
+ /**
+ * Until when the merchant may refund.
+ */
+ struct GNUNET_TIME_Timestamp refund_deadline;
+
+ /**
+ * Time the exchange recorded for the deposit.
+ */
+ struct GNUNET_TIME_Timestamp exchange_timestamp;
+
+ /**
+ * Row of the deposit in `batch_deposits`.
+ */
+ uint64_t serial;
+};
+
+
+/**
+ * Deposit @a coin with a fresh merchant, creating the `batch_deposits`
+ * and `coin_deposits` rows for it. Fails hard if the deposit does not go
+ * through.
+ *
+ * @param pg the database context
+ * @param account account the merchant wants to be paid at
+ * @param coin coin to deposit
+ * @param seed seed for the merchant key, the contract and the salt
+ * @param amount amount to deposit, e.g. "1"
+ * @param fee deposit fee of the coin's denomination, e.g. "0.1"
+ * @param wire_deadline when the merchant wants to be paid
+ * @param refund_deadline until when the merchant may refund
+ * @param[out] dep set to the deposit
+ */
+void
+TDB_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Account *account,
+ const struct TALER_CoinPublicInfo *coin,
+ uint32_t seed,
+ const char *amount,
+ const char *fee,
+ struct GNUNET_TIME_Timestamp wire_deadline,
+ struct GNUNET_TIME_Timestamp refund_deadline,
+ struct TDB_Deposit *dep);
+
+
+/**
+ * Withdraw one coin of @a denom from @a reserve_pub, creating a `withdraw`
+ * row for it. The reserve must hold at least @a amount.
+ *
+ * @param pg the database context
+ * @param denom denomination of the coin to withdraw
+ * @param reserve_pub reserve to withdraw from
+ * @param seed seed for the planchet hash and the signatures
+ * @param amount amount to withdraw, e.g. "5"
+ * @return row of the new entry in `withdraw`
+ */
+uint64_t
+TDB_withdraw (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Denom *denom,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ uint32_t seed,
+ const char *amount);
+
+
+/**
+ * Wire @a amount from @a account into a fresh reserve, creating a
+ * `reserves_in` row for it.
+ *
+ * @param pg the database context
+ * @param account account the money comes from
+ * @param seed seed for the reserve's public key and the wire subject
+ * @param amount amount that was wired, e.g. "10"
+ * @param[out] reserve_pub set to the public key of the reserve
+ * @return row of the new entry in `reserves_in`
+ */
+uint64_t
+TDB_reserve_in (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Account *account,
+ uint32_t seed,
+ const char *amount,
+ struct TALER_ReservePublicKeyP *reserve_pub);
+
+
+#endif
diff --git a/src/exchangedb/test_contracts.c b/src/exchangedb/test_contracts.c
@@ -0,0 +1,335 @@
+/*
+ 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/test_contracts.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `contracts`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_contract(),
+ * #TALER_EXCHANGEDB_get_contract() and
+ * #TALER_EXCHANGEDB_get_contract_by_purse().
+ *
+ * `contracts` references `purse_requests`: the encrypted contract belongs
+ * to a purse. It is fetched either by the purse or by the Diffie-Hellman
+ * public key the client derived, which is why there are two getters. A
+ * second, different contract for the same purse is reported through
+ * @e in_conflict rather than as an error.
+ */
+#include "test_common.h"
+#include "exchange-database/get_contract.h"
+#include "exchange-database/get_contract_by_purse.h"
+#include "exchange-database/insert_contract.h"
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Build an encrypted contract.
+ *
+ * @param seed seed for the key, signature and payload
+ * @param payload contents of the "encrypted" contract
+ * @param[out] econtract set to the contract; free @e econtract with
+ * GNUNET_free()
+ */
+static void
+make_econtract (uint32_t seed,
+ const char *payload,
+ struct TALER_EncryptedContract *econtract)
+{
+ memset (econtract,
+ 0,
+ sizeof (*econtract));
+ TDB_fill (&econtract->contract_pub,
+ sizeof (econtract->contract_pub),
+ seed);
+ TDB_fill (&econtract->econtract_sig,
+ sizeof (econtract->econtract_sig),
+ seed);
+ econtract->econtract_size = strlen (payload) + 1;
+ econtract->econtract = GNUNET_memdup (payload,
+ econtract->econtract_size);
+}
+
+
+/**
+ * Nothing is known while the table is empty, and a contract for a purse
+ * that does not exist is not stored.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_PurseContractPublicKeyP purse_pub;
+ struct TALER_PurseContractSignatureP econtract_sig;
+ struct TALER_ContractDiffiePublicP pub_ckey;
+ struct TALER_EncryptedContract econtract;
+ size_t econtract_size;
+ void *econtract_data = NULL;
+
+ TDB_FILL (purse_pub,
+ 1);
+ TDB_FILL (pub_ckey,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_contract (pg,
+ &pub_ckey,
+ &purse_pub,
+ &econtract_sig,
+ &econtract_size,
+ &econtract_data));
+ FAILIF (NULL != econtract_data);
+ memset (&econtract,
+ 0,
+ sizeof (econtract));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_contract_by_purse (pg,
+ &purse_pub,
+ &econtract));
+ return 0;
+}
+
+
+/**
+ * A contract is stored and found by both keys.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_EncryptedContract econtract;
+ struct TALER_EncryptedContract got;
+ struct TALER_PurseContractPublicKeyP got_purse;
+ struct TALER_PurseContractSignatureP got_sig;
+ size_t got_size;
+ void *got_data = NULL;
+ bool in_conflict = true;
+
+ TDB_purse (pg,
+ 10,
+ "5",
+ ts (1700000000),
+ &purse);
+ make_econtract (10,
+ "the-contract",
+ &econtract);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_contract (pg,
+ &purse.purse_pub,
+ &econtract,
+ &in_conflict),
+ GNUNET_free (econtract.econtract));
+ FAILIF_C (in_conflict,
+ GNUNET_free (econtract.econtract));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM contracts"),
+ GNUNET_free (econtract.econtract));
+
+ /* by the Diffie-Hellman key the client derived */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_contract (pg,
+ &econtract.contract_pub,
+ &got_purse,
+ &got_sig,
+ &got_size,
+ &got_data),
+ GNUNET_free (econtract.econtract));
+ FAILIF_C (0 != GNUNET_memcmp (&got_purse,
+ &purse.purse_pub),
+ GNUNET_free (got_data); GNUNET_free (econtract.econtract));
+ FAILIF_C (0 != GNUNET_memcmp (&got_sig,
+ &econtract.econtract_sig),
+ GNUNET_free (got_data); GNUNET_free (econtract.econtract));
+ FAILIF_C (got_size != econtract.econtract_size,
+ GNUNET_free (got_data); GNUNET_free (econtract.econtract));
+ FAILIF_C (0 != memcmp (got_data,
+ econtract.econtract,
+ got_size),
+ GNUNET_free (got_data); GNUNET_free (econtract.econtract));
+ GNUNET_free (got_data);
+
+ /* and by the purse */
+ memset (&got,
+ 0,
+ sizeof (got));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_contract_by_purse (pg,
+ &purse.purse_pub,
+ &got),
+ GNUNET_free (econtract.econtract));
+ FAILIF_C (0 != GNUNET_memcmp (&got.contract_pub,
+ &econtract.contract_pub),
+ GNUNET_free (got.econtract); GNUNET_free (econtract.econtract));
+ FAILIF_C (got.econtract_size != econtract.econtract_size,
+ GNUNET_free (got.econtract); GNUNET_free (econtract.econtract));
+ FAILIF_C (0 != memcmp (got.econtract,
+ econtract.econtract,
+ got.econtract_size),
+ GNUNET_free (got.econtract); GNUNET_free (econtract.econtract));
+ GNUNET_free (got.econtract);
+
+ /* re-inserting the identical contract is a no-op */
+ in_conflict = true;
+ FAILIF_C (0 >
+ TALER_EXCHANGEDB_insert_contract (pg,
+ &purse.purse_pub,
+ &econtract,
+ &in_conflict),
+ GNUNET_free (econtract.econtract));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM contracts"),
+ GNUNET_free (econtract.econtract));
+ GNUNET_free (econtract.econtract);
+ return 0;
+}
+
+
+/**
+ * A different contract for the same purse is a conflict.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_conflict (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_EncryptedContract econtract;
+ bool in_conflict = false;
+
+ TDB_purse (pg,
+ 10,
+ "5",
+ ts (1700000000),
+ &purse);
+ make_econtract (11,
+ "another-contract",
+ &econtract);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_contract (pg,
+ &purse.purse_pub,
+ &econtract,
+ &in_conflict),
+ GNUNET_free (econtract.econtract));
+ FAILIF_C (! in_conflict,
+ GNUNET_free (econtract.econtract));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM contracts"),
+ GNUNET_free (econtract.econtract));
+ GNUNET_free (econtract.econtract);
+ return 0;
+}
+
+
+/**
+ * A second purse can have its own contract, and the two do not mix.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_second_purse (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_EncryptedContract econtract;
+ struct TALER_EncryptedContract got;
+ bool in_conflict = true;
+
+ TDB_purse (pg,
+ 12,
+ "3",
+ ts (1700000000),
+ &purse);
+ make_econtract (12,
+ "second-contract",
+ &econtract);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_contract (pg,
+ &purse.purse_pub,
+ &econtract,
+ &in_conflict),
+ GNUNET_free (econtract.econtract));
+ FAILIF_C (in_conflict,
+ GNUNET_free (econtract.econtract));
+ FAILIF_C (2 != TDB_count (pg,
+ "FROM contracts"),
+ GNUNET_free (econtract.econtract));
+ memset (&got,
+ 0,
+ sizeof (got));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_contract_by_purse (pg,
+ &purse.purse_pub,
+ &got),
+ GNUNET_free (econtract.econtract));
+ FAILIF_C (0 != GNUNET_memcmp (&got.contract_pub,
+ &econtract.contract_pub),
+ GNUNET_free (got.econtract); GNUNET_free (econtract.econtract));
+ GNUNET_free (got.econtract);
+ GNUNET_free (econtract.econtract);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "contracts-empty",
+ &check_empty },
+ { "contracts-insert-and-lookup",
+ &check_insert_and_lookup },
+ { "contracts-conflict",
+ &check_conflict },
+ { "contracts-second-purse",
+ &check_second_purse },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-contracts",
+ "Tests for the exchangedb `contracts' table",
+ tests);
+}
+
+
+/* end of test_contracts.c */
diff --git a/src/exchangedb/test_denomination_revocations.c b/src/exchangedb/test_denomination_revocations.c
@@ -0,0 +1,259 @@
+/*
+ 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/test_denomination_revocations.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `denomination_revocations`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_denomination_revocation() and
+ * #TALER_EXCHANGEDB_get_denomination_revocation().
+ *
+ * `denomination_revocations` references `denominations`, so a denomination
+ * has to exist first; that is what TDB_denom() is for. The check for the
+ * `recoup_possible` flag of #TALER_EXCHANGEDB_iterate_denominations() lives
+ * here too, since that flag is what the revocation row is *for*.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_denomination_revocation.h"
+#include "exchange-database/get_denomination_revocation.h"
+#include "exchange-database/iterate_denominations.h"
+
+
+/**
+ * Closure for #recoup_cb().
+ */
+struct RecoupContext
+{
+ /**
+ * Denomination we are looking for.
+ */
+ const struct TALER_DenominationHashP *h_denom_pub;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Recoup flag reported for it.
+ */
+ bool recoup_possible;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_denominations().
+ *
+ * @param cls a `struct RecoupContext *`
+ * @param denom_pub public key of the denomination
+ * @param h_denom_pub hash of @a denom_pub
+ * @param meta meta data of the denomination
+ * @param master_sig master signature over the denomination
+ * @param recoup_possible true if the denomination was revoked
+ */
+static void
+recoup_cb (void *cls,
+ const struct TALER_DenominationPublicKey *denom_pub,
+ const struct TALER_DenominationHashP *h_denom_pub,
+ const struct TALER_EXCHANGEDB_DenominationKeyMetaData *meta,
+ const struct TALER_MasterSignatureP *master_sig,
+ bool recoup_possible)
+{
+ struct RecoupContext *ctx = cls;
+
+ (void) denom_pub;
+ (void) meta;
+ (void) master_sig;
+ if (0 != GNUNET_memcmp (h_denom_pub,
+ ctx->h_denom_pub))
+ return;
+ ctx->matched++;
+ ctx->recoup_possible = recoup_possible;
+}
+
+
+/**
+ * A denomination that does not exist cannot be revoked, and is reported as
+ * not revoked.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_unknown_denomination (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_DenominationHashP h_denom_pub;
+ struct TALER_MasterSignatureP master_sig;
+ struct TALER_MasterSignatureP got;
+ uint64_t rowid;
+
+ TDB_FILL (h_denom_pub,
+ 1);
+ TDB_FILL (master_sig,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_denomination_revocation (pg,
+ &h_denom_pub,
+ &got,
+ &rowid));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_insert_denomination_revocation (pg,
+ &h_denom_pub,
+ &master_sig));
+ FAILIF (0 != TDB_count (pg,
+ "FROM denomination_revocations"));
+ return 0;
+}
+
+
+/**
+ * Revoking a denomination records the signature and flips
+ * `recoup_possible`.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_revoke (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Denom revoked;
+ struct TDB_Denom intact;
+ struct TALER_MasterSignatureP master_sig;
+ struct TALER_MasterSignatureP got;
+ struct RecoupContext ctx;
+ uint64_t rowid = 0;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &revoked);
+ TDB_denom (pg,
+ 11,
+ "5",
+ "0.1",
+ &intact);
+ TDB_FILL (master_sig,
+ 42);
+
+ /* before the revocation, recoup is not possible */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.h_denom_pub = &revoked.h_denom_pub;
+ FAILIF_C (0 >=
+ TALER_EXCHANGEDB_iterate_denominations (pg,
+ &recoup_cb,
+ &ctx),
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+ FAILIF_C (1 != ctx.matched,
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+ FAILIF_C (ctx.recoup_possible,
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_denomination_revocation (
+ pg,
+ &revoked.h_denom_pub,
+ &master_sig),
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+ memset (&got,
+ 0,
+ sizeof (got));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_denomination_revocation (
+ pg,
+ &revoked.h_denom_pub,
+ &got,
+ &rowid),
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+ FAILIF_C (0 != GNUNET_memcmp (&got,
+ &master_sig),
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+ FAILIF_C (0 == rowid,
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+
+ /* now recoup is possible for the revoked denomination... */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.h_denom_pub = &revoked.h_denom_pub;
+ FAILIF_C (0 >=
+ TALER_EXCHANGEDB_iterate_denominations (pg,
+ &recoup_cb,
+ &ctx),
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+ FAILIF_C (1 != ctx.matched,
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+ FAILIF_C (! ctx.recoup_possible,
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+
+ /* ...and only for that one */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.h_denom_pub = &intact.h_denom_pub;
+ FAILIF_C (0 >=
+ TALER_EXCHANGEDB_iterate_denominations (pg,
+ &recoup_cb,
+ &ctx),
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+ FAILIF_C (1 != ctx.matched,
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+ FAILIF_C (ctx.recoup_possible,
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_denomination_revocation (
+ pg,
+ &intact.h_denom_pub,
+ &got,
+ &rowid),
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM denomination_revocations"),
+ TDB_denom_free (&revoked); TDB_denom_free (&intact));
+ TDB_denom_free (&revoked);
+ TDB_denom_free (&intact);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "denomination-revocations-unknown-denomination",
+ &check_unknown_denomination },
+ { "denomination-revocations-revoke",
+ &check_revoke },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-denomination-revocations",
+ "Tests for the exchangedb `denomination_revocations' table",
+ tests);
+}
+
+
+/* end of test_denomination_revocations.c */
diff --git a/src/exchangedb/test_denominations.c b/src/exchangedb/test_denominations.c
@@ -0,0 +1,384 @@
+/*
+ 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/test_denominations.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `denominations`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_denomination_info(),
+ * #TALER_EXCHANGEDB_get_denomination_info(),
+ * #TALER_EXCHANGEDB_get_denomination_by_serial(),
+ * #TALER_EXCHANGEDB_get_denomination_meta(),
+ * #TALER_EXCHANGEDB_iterate_denomination_info() and
+ * #TALER_EXCHANGEDB_iterate_denominations().
+ *
+ * `denominations` has no foreign keys, so the checks need no fixtures. The
+ * empty-table checks run first, before any denomination exists.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_denomination_info.h"
+#include "exchange-database/get_denomination_info.h"
+#include "exchange-database/get_denomination_by_serial.h"
+#include "exchange-database/get_denomination_meta.h"
+#include "exchange-database/iterate_denomination_info.h"
+#include "exchange-database/iterate_denominations.h"
+
+
+/**
+ * Check that nothing is found while the table is still empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_DenominationHashP h_denom_pub;
+ struct TALER_EXCHANGEDB_DenominationKeyInformation issue;
+ struct TALER_EXCHANGEDB_DenominationKeyMetaData meta;
+ uint64_t serial;
+
+ TDB_FILL (h_denom_pub,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_denomination_info (pg,
+ &h_denom_pub,
+ &serial,
+ &issue));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_denomination_by_serial (pg,
+ 42,
+ &issue));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_denomination_meta (pg,
+ &h_denom_pub,
+ &meta));
+ return 0;
+}
+
+
+/**
+ * Closure for #count_info_cb() and #count_denoms_cb().
+ */
+struct CountContext
+{
+ /**
+ * Denomination we are looking for.
+ */
+ const struct TDB_Denom *denom;
+
+ /**
+ * How many rows did the callback see in total?
+ */
+ unsigned int total;
+
+ /**
+ * How many times did we see @e denom?
+ */
+ unsigned int matched;
+
+ /**
+ * Set to true if @e denom was reported with data that does not match
+ * what we inserted.
+ */
+ bool mismatch;
+
+ /**
+ * Recoup flag reported for @e denom.
+ */
+ bool recoup_possible;
+
+ /**
+ * Row reported for @e denom.
+ */
+ uint64_t serial;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_denomination_info().
+ *
+ * @param cls a `struct CountContext *`
+ * @param denom_serial table row of the denomination
+ * @param denom_pub public key of the denomination
+ * @param issue information about the denomination
+ */
+static void
+count_info_cb (void *cls,
+ uint64_t denom_serial,
+ const struct TALER_DenominationPublicKey *denom_pub,
+ const struct TALER_EXCHANGEDB_DenominationKeyInformation *issue)
+{
+ struct CountContext *ctx = cls;
+
+ ctx->total++;
+ if (0 != GNUNET_memcmp (&issue->denom_hash,
+ &ctx->denom->h_denom_pub))
+ return;
+ ctx->matched++;
+ ctx->serial = denom_serial;
+ if ( (0 != TALER_denom_pub_cmp (denom_pub,
+ &ctx->denom->pub)) ||
+ (0 != TALER_amount_cmp (&issue->value,
+ &ctx->denom->issue.value)) ||
+ (0 != TALER_amount_cmp (&issue->fees.deposit,
+ &ctx->denom->issue.fees.deposit)) )
+ ctx->mismatch = true;
+}
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_denominations().
+ *
+ * @param cls a `struct CountContext *`
+ * @param denom_pub public key of the denomination
+ * @param h_denom_pub hash of @a denom_pub
+ * @param meta meta data of the denomination
+ * @param master_sig master signature over the denomination
+ * @param recoup_possible true if the denomination was revoked
+ */
+static void
+count_denoms_cb (void *cls,
+ const struct TALER_DenominationPublicKey *denom_pub,
+ const struct TALER_DenominationHashP *h_denom_pub,
+ const struct TALER_EXCHANGEDB_DenominationKeyMetaData *meta,
+ const struct TALER_MasterSignatureP *master_sig,
+ bool recoup_possible)
+{
+ struct CountContext *ctx = cls;
+
+ ctx->total++;
+ if (0 != GNUNET_memcmp (h_denom_pub,
+ &ctx->denom->h_denom_pub))
+ return;
+ ctx->matched++;
+ ctx->serial = meta->serial;
+ ctx->recoup_possible = recoup_possible;
+ if ( (0 != TALER_denom_pub_cmp (denom_pub,
+ &ctx->denom->pub)) ||
+ (0 != GNUNET_memcmp (master_sig,
+ &ctx->denom->issue.signature)) ||
+ (0 != TALER_amount_cmp (&meta->value,
+ &ctx->denom->issue.value)) ||
+ (GNUNET_TIME_timestamp_cmp (meta->start,
+ !=,
+ ctx->denom->issue.start)) )
+ ctx->mismatch = true;
+}
+
+
+/**
+ * Insert a denomination and read it back through every lookup the
+ * table offers.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Denom denom;
+ struct TALER_EXCHANGEDB_DenominationKeyInformation issue;
+ struct TALER_EXCHANGEDB_DenominationKeyMetaData meta;
+ struct TALER_DenominationHashP other;
+ uint64_t serial;
+
+ /* TDB_denom() inserts and looks up the row it created. */
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ memset (&issue,
+ 0,
+ sizeof (issue));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_denomination_info (pg,
+ &denom.h_denom_pub,
+ &serial,
+ &issue),
+ TDB_denom_free (&denom));
+ FAILIF_C (0 != GNUNET_memcmp (&issue,
+ &denom.issue),
+ TDB_denom_free (&denom));
+ FAILIF_C (serial != denom.serial,
+ TDB_denom_free (&denom));
+
+ /* the denom_serial output is optional */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_denomination_info (pg,
+ &denom.h_denom_pub,
+ NULL,
+ &issue),
+ TDB_denom_free (&denom));
+
+ memset (&issue,
+ 0,
+ sizeof (issue));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_denomination_by_serial (pg,
+ serial,
+ &issue),
+ TDB_denom_free (&denom));
+ FAILIF_C (0 != GNUNET_memcmp (&issue,
+ &denom.issue),
+ TDB_denom_free (&denom));
+
+ memset (&meta,
+ 0,
+ sizeof (meta));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_denomination_meta (pg,
+ &denom.h_denom_pub,
+ &meta),
+ TDB_denom_free (&denom));
+ FAILIF_C (meta.serial != serial,
+ TDB_denom_free (&denom));
+ FAILIF_C (0 != TALER_amount_cmp (&meta.value,
+ &denom.issue.value),
+ TDB_denom_free (&denom));
+ FAILIF_C (0 != TALER_amount_cmp (&meta.fees.refund,
+ &denom.issue.fees.refund),
+ TDB_denom_free (&denom));
+ FAILIF_C (GNUNET_TIME_timestamp_cmp (meta.expire_legal,
+ !=,
+ denom.issue.expire_legal),
+ TDB_denom_free (&denom));
+ FAILIF_C (meta.age_mask.bits != denom.issue.age_mask.bits,
+ TDB_denom_free (&denom));
+
+ /* ...and a denomination that was never inserted is still not found. */
+ TDB_FILL (other,
+ 99);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_denomination_info (pg,
+ &other,
+ &serial,
+ &issue),
+ TDB_denom_free (&denom));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_denomination_meta (pg,
+ &other,
+ &meta),
+ TDB_denom_free (&denom));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_denomination_by_serial (pg,
+ denom.serial + 1000,
+ &issue),
+ TDB_denom_free (&denom));
+ TDB_denom_free (&denom);
+ return 0;
+}
+
+
+/**
+ * Both iterators must report every denomination in the table.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Denom d1;
+ struct TDB_Denom d2;
+ struct CountContext ctx;
+ unsigned int seen;
+
+ TDB_denom (pg,
+ 20,
+ "1",
+ "0.01",
+ &d1);
+ TDB_denom (pg,
+ 21,
+ "2",
+ "0.02",
+ &d2);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.denom = &d2;
+ FAILIF_C (0 >=
+ TALER_EXCHANGEDB_iterate_denomination_info (pg,
+ &count_info_cb,
+ &ctx),
+ TDB_denom_free (&d1); TDB_denom_free (&d2));
+ FAILIF_C (1 != ctx.matched,
+ TDB_denom_free (&d1); TDB_denom_free (&d2));
+ FAILIF_C (ctx.mismatch,
+ TDB_denom_free (&d1); TDB_denom_free (&d2));
+ FAILIF_C (ctx.serial != d2.serial,
+ TDB_denom_free (&d1); TDB_denom_free (&d2));
+ seen = ctx.total;
+ FAILIF_C (seen < 2,
+ TDB_denom_free (&d1); TDB_denom_free (&d2));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.denom = &d1;
+ FAILIF_C (0 >=
+ TALER_EXCHANGEDB_iterate_denominations (pg,
+ &count_denoms_cb,
+ &ctx),
+ TDB_denom_free (&d1); TDB_denom_free (&d2));
+ FAILIF_C (1 != ctx.matched,
+ TDB_denom_free (&d1); TDB_denom_free (&d2));
+ FAILIF_C (ctx.mismatch,
+ TDB_denom_free (&d1); TDB_denom_free (&d2));
+ FAILIF_C (ctx.serial != d1.serial,
+ TDB_denom_free (&d1); TDB_denom_free (&d2));
+ /* nothing was revoked in this binary */
+ FAILIF_C (ctx.recoup_possible,
+ TDB_denom_free (&d1); TDB_denom_free (&d2));
+ /* both iterators walk the same table, so they must agree on its size */
+ FAILIF_C (ctx.total != seen,
+ TDB_denom_free (&d1); TDB_denom_free (&d2));
+ TDB_denom_free (&d1);
+ TDB_denom_free (&d2);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "denominations-empty",
+ &check_empty },
+ { "denominations-insert-and-lookup",
+ &check_insert_and_lookup },
+ { "denominations-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-denominations",
+ "Tests for the exchangedb `denominations' table",
+ tests);
+}
+
+
+/* end of test_denominations.c */
diff --git a/src/exchangedb/test_exchange_sign_keys.c b/src/exchangedb/test_exchange_sign_keys.c
@@ -0,0 +1,291 @@
+/*
+ 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/test_exchange_sign_keys.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `exchange_sign_keys`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_signkey(),
+ * #TALER_EXCHANGEDB_get_signkey() and
+ * #TALER_EXCHANGEDB_iterate_active_signkeys().
+ *
+ * `exchange_sign_keys` has no foreign keys. The interesting part is what
+ * "active" means for the iterator: not expired for signing, and not
+ * revoked. The revocation half is covered by test_signkey_revocations.c.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_signkey.h"
+#include "exchange-database/get_signkey.h"
+#include "exchange-database/iterate_active_signkeys.h"
+
+
+/**
+ * Build meta data for a signing key that is valid from an hour ago until
+ * @a sign_offset from now.
+ *
+ * @param sign_offset how long the key remains valid for signing; may be
+ * negative to build an already expired key
+ * @return the meta data
+ */
+static struct TALER_EXCHANGEDB_SignkeyMetaData
+make_meta (struct GNUNET_TIME_Relative sign_offset)
+{
+ struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get ();
+ struct TALER_EXCHANGEDB_SignkeyMetaData meta;
+
+ meta.start
+ = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_subtract (now,
+ GNUNET_TIME_UNIT_HOURS));
+ meta.expire_sign
+ = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_add (now,
+ sign_offset));
+ meta.expire_legal
+ = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_add (now,
+ GNUNET_TIME_relative_multiply (
+ GNUNET_TIME_UNIT_HOURS,
+ 24)));
+ return meta;
+}
+
+
+/**
+ * Nothing is known about a signing key that was never inserted.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ExchangePublicKeyP exchange_pub;
+ struct TALER_EXCHANGEDB_SignkeyMetaData meta;
+
+ TDB_FILL (exchange_pub,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_signkey (pg,
+ &exchange_pub,
+ &meta));
+ FAILIF (0 != TDB_count (pg,
+ "FROM exchange_sign_keys"));
+ return 0;
+}
+
+
+/**
+ * Closure for #count_signkeys_cb().
+ */
+struct CountContext
+{
+ /**
+ * Key we are looking for.
+ */
+ const struct TALER_ExchangePublicKeyP *exchange_pub;
+
+ /**
+ * How many keys did the callback see in total?
+ */
+ unsigned int total;
+
+ /**
+ * How many times did we see @e exchange_pub?
+ */
+ unsigned int matched;
+
+ /**
+ * Set if @e exchange_pub was reported with unexpected data.
+ */
+ bool mismatch;
+
+ /**
+ * Meta data reported for @e exchange_pub.
+ */
+ struct TALER_EXCHANGEDB_SignkeyMetaData meta;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_active_signkeys().
+ *
+ * @param cls a `struct CountContext *`
+ * @param exchange_pub public key of the exchange
+ * @param meta meta data of @a exchange_pub
+ * @param master_sig master signature over @a exchange_pub
+ */
+static void
+count_signkeys_cb (void *cls,
+ const struct TALER_ExchangePublicKeyP *exchange_pub,
+ const struct TALER_EXCHANGEDB_SignkeyMetaData *meta,
+ const struct TALER_MasterSignatureP *master_sig)
+{
+ struct CountContext *ctx = cls;
+ struct TALER_MasterSignatureP expected;
+
+ ctx->total++;
+ if (0 != GNUNET_memcmp (exchange_pub,
+ ctx->exchange_pub))
+ return;
+ ctx->matched++;
+ ctx->meta = *meta;
+ TDB_FILL (expected,
+ 2);
+ if (0 != GNUNET_memcmp (master_sig,
+ &expected))
+ ctx->mismatch = true;
+}
+
+
+/**
+ * Insert a signing key and read it back.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ExchangePublicKeyP exchange_pub;
+ struct TALER_MasterSignatureP master_sig;
+ struct TALER_EXCHANGEDB_SignkeyMetaData meta;
+ struct TALER_EXCHANGEDB_SignkeyMetaData got;
+ struct CountContext ctx;
+
+ TDB_FILL (exchange_pub,
+ 2);
+ TDB_FILL (master_sig,
+ 2);
+ meta = make_meta (GNUNET_TIME_UNIT_HOURS);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_signkey (pg,
+ &exchange_pub,
+ &meta,
+ &master_sig));
+ memset (&got,
+ 0,
+ sizeof (got));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_signkey (pg,
+ &exchange_pub,
+ &got));
+ FAILIF (0 != GNUNET_memcmp (&got,
+ &meta));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.exchange_pub = &exchange_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_active_signkeys (pg,
+ &count_signkeys_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (ctx.mismatch);
+ FAILIF (0 != GNUNET_memcmp (&ctx.meta,
+ &meta));
+ return 0;
+}
+
+
+/**
+ * A key whose signing period is over is on file but not active.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_expired_is_inactive (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ExchangePublicKeyP exchange_pub;
+ struct TALER_MasterSignatureP master_sig;
+ struct TALER_EXCHANGEDB_SignkeyMetaData meta;
+ struct TALER_EXCHANGEDB_SignkeyMetaData got;
+ struct CountContext ctx;
+
+ TDB_FILL (exchange_pub,
+ 3);
+ TDB_FILL (master_sig,
+ 3);
+ /* expired for signing half an hour ago */
+ meta = make_meta (GNUNET_TIME_UNIT_ZERO);
+ meta.expire_sign
+ = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_subtract (
+ GNUNET_TIME_absolute_get (),
+ GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_MINUTES,
+ 30)));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_signkey (pg,
+ &exchange_pub,
+ &meta,
+ &master_sig));
+ /* the row is there... */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_signkey (pg,
+ &exchange_pub,
+ &got));
+ /* ...but the iterator skips it */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.exchange_pub = &exchange_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_active_signkeys (pg,
+ &count_signkeys_cb,
+ &ctx));
+ FAILIF (0 != ctx.matched);
+ FAILIF (ctx.total !=
+ TDB_count (pg,
+ "FROM exchange_sign_keys"
+ " WHERE expire_sign > %llu",
+ (unsigned long long)
+ GNUNET_TIME_absolute_get ().abs_value_us));
+ FAILIF (2 != TDB_count (pg,
+ "FROM exchange_sign_keys"));
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "exchange-sign-keys-empty",
+ &check_empty },
+ { "exchange-sign-keys-insert-and-lookup",
+ &check_insert_and_lookup },
+ { "exchange-sign-keys-expired-is-inactive",
+ &check_expired_is_inactive },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-exchange-sign-keys",
+ "Tests for the exchangedb `exchange_sign_keys' table",
+ tests);
+}
+
+
+/* end of test_exchange_sign_keys.c */
diff --git a/src/exchangedb/test_global_fee.c b/src/exchangedb/test_global_fee.c
@@ -0,0 +1,419 @@
+/*
+ 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/test_global_fee.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `global_fee`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_global_fee(),
+ * #TALER_EXCHANGEDB_get_global_fee(),
+ * #TALER_EXCHANGEDB_get_global_fee_by_time() and
+ * #TALER_EXCHANGEDB_iterate_global_fees().
+ *
+ * `global_fee` has no foreign keys and, unlike `wire_fee`, no wire method
+ * to key on -- the validity interval is all there is. As with the wire
+ * fees, the by-time lookup returns an invalid amount when the range covers
+ * two different fee sets.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_global_fee.h"
+#include "exchange-database/get_global_fee.h"
+#include "exchange-database/get_global_fee_by_time.h"
+#include "exchange-database/iterate_global_fees.h"
+
+
+/**
+ * One hour, in seconds.
+ */
+#define HOUR 3600
+
+
+/**
+ * Start of the first fee period, in seconds since the epoch. It has to be
+ * recent: #TALER_EXCHANGEDB_iterate_global_fees() only reports periods
+ * that started within the last year.
+ *
+ * @return an hour ago, rounded down to a whole second
+ */
+static uint64_t
+t0 (void)
+{
+ static uint64_t cached;
+
+ if (0 == cached)
+ cached = GNUNET_TIME_absolute_get ().abs_value_us
+ / (1000LLU * 1000LLU) - HOUR;
+ return cached;
+}
+
+
+/**
+ * Convenience alias for the start of the first fee period.
+ */
+#define T0 (t0 ())
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #global_fee_cb().
+ */
+struct FeeContext
+{
+ /**
+ * How many periods did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Sum of the whole-unit parts of the history fees seen.
+ */
+ uint64_t value_sum;
+
+ /**
+ * Purse account limit of the last period seen.
+ */
+ uint32_t purse_account_limit;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_global_fees().
+ *
+ * @param cls a `struct FeeContext *`
+ * @param fees the fees of this period
+ * @param purse_timeout when purses time out
+ * @param history_expiration how long histories are kept
+ * @param purse_account_limit free purses per account
+ * @param start_date start of the period
+ * @param end_date end of the period
+ * @param master_sig signature over the period
+ */
+static void
+global_fee_cb (void *cls,
+ const struct TALER_GlobalFeeSet *fees,
+ struct GNUNET_TIME_Relative purse_timeout,
+ struct GNUNET_TIME_Relative history_expiration,
+ uint32_t purse_account_limit,
+ struct GNUNET_TIME_Timestamp start_date,
+ struct GNUNET_TIME_Timestamp end_date,
+ const struct TALER_MasterSignatureP *master_sig)
+{
+ struct FeeContext *ctx = cls;
+
+ (void) purse_timeout;
+ (void) history_expiration;
+ (void) start_date;
+ (void) end_date;
+ (void) master_sig;
+ ctx->total++;
+ ctx->value_sum += fees->history.value;
+ ctx->purse_account_limit = purse_account_limit;
+}
+
+
+/**
+ * Insert a fee period.
+ *
+ * @param pg the database context
+ * @param from start of the period
+ * @param until end of the period
+ * @param history history fee, e.g. "1"
+ * @param limit free purses per account
+ * @param seed seed for the master signature
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+add_fee (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint64_t from,
+ uint64_t until,
+ const char *history,
+ uint32_t limit,
+ uint32_t seed)
+{
+ struct TALER_GlobalFeeSet fees;
+ struct TALER_MasterSignatureP master_sig;
+
+ fees.history = TDB_amount (history);
+ fees.account = TDB_amount ("0.5");
+ fees.purse = TDB_amount ("0.25");
+ TDB_FILL (master_sig,
+ seed);
+ return TALER_EXCHANGEDB_insert_global_fee (pg,
+ ts (from),
+ ts (until),
+ &fees,
+ GNUNET_TIME_UNIT_HOURS,
+ GNUNET_TIME_UNIT_DAYS,
+ limit,
+ &master_sig);
+}
+
+
+/**
+ * No fees means no lookups succeed and the iterator sees nothing.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct GNUNET_TIME_Timestamp start_date;
+ struct GNUNET_TIME_Timestamp end_date;
+ struct GNUNET_TIME_Relative purse_timeout;
+ struct GNUNET_TIME_Relative history_expiration;
+ struct TALER_GlobalFeeSet fees;
+ struct TALER_MasterSignatureP master_sig;
+ struct FeeContext ctx = { 0 };
+ uint32_t purse_account_limit;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_global_fee (pg,
+ ts (T0),
+ &start_date,
+ &end_date,
+ &fees,
+ &purse_timeout,
+ &history_expiration,
+ &purse_account_limit,
+ &master_sig));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_global_fee_by_time (pg,
+ ts (T0),
+ ts (T0 + HOUR),
+ &fees,
+ &purse_timeout,
+ &history_expiration,
+ &purse_account_limit));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_global_fees (pg,
+ &global_fee_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * A single fee period is found inside its interval and nowhere else.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_single_period (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct GNUNET_TIME_Timestamp start_date;
+ struct GNUNET_TIME_Timestamp end_date;
+ struct GNUNET_TIME_Relative purse_timeout;
+ struct GNUNET_TIME_Relative history_expiration;
+ struct TALER_GlobalFeeSet fees;
+ struct TALER_Amount expect_history = TDB_amount ("1");
+ struct TALER_MasterSignatureP master_sig;
+ struct TALER_MasterSignatureP expect_sig;
+ uint32_t purse_account_limit = 0;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_fee (pg,
+ T0,
+ T0 + HOUR,
+ "1",
+ 4,
+ 20));
+ TDB_FILL (expect_sig,
+ 20);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_global_fee (pg,
+ ts (T0),
+ &start_date,
+ &end_date,
+ &fees,
+ &purse_timeout,
+ &history_expiration,
+ &purse_account_limit,
+ &master_sig));
+ FAILIF (GNUNET_TIME_timestamp_cmp (start_date,
+ !=,
+ ts (T0)));
+ FAILIF (GNUNET_TIME_timestamp_cmp (end_date,
+ !=,
+ ts (T0 + HOUR)));
+ FAILIF (0 != TALER_amount_cmp (&fees.history,
+ &expect_history));
+ FAILIF (4 != purse_account_limit);
+ FAILIF (GNUNET_TIME_relative_cmp (purse_timeout,
+ !=,
+ GNUNET_TIME_UNIT_HOURS));
+ FAILIF (GNUNET_TIME_relative_cmp (history_expiration,
+ !=,
+ GNUNET_TIME_UNIT_DAYS));
+ FAILIF (0 != GNUNET_memcmp (&master_sig,
+ &expect_sig));
+
+ /* the interval is half-open */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_global_fee (pg,
+ ts (T0 + HOUR),
+ &start_date,
+ &end_date,
+ &fees,
+ &purse_timeout,
+ &history_expiration,
+ &purse_account_limit,
+ &master_sig));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_global_fee (pg,
+ ts (T0 - 1),
+ &start_date,
+ &end_date,
+ &fees,
+ &purse_timeout,
+ &history_expiration,
+ &purse_account_limit,
+ &master_sig));
+ return 0;
+}
+
+
+/**
+ * Over a range with one fee set, get_global_fee_by_time() returns it; over
+ * a range spanning two different ones it returns an invalid amount.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_by_time (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct GNUNET_TIME_Relative purse_timeout;
+ struct GNUNET_TIME_Relative history_expiration;
+ struct TALER_GlobalFeeSet fees;
+ struct TALER_Amount expect_history = TDB_amount ("1");
+ struct FeeContext ctx;
+ uint32_t purse_account_limit;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_fee (pg,
+ T0 + HOUR,
+ T0 + 2 * HOUR,
+ "1",
+ 4,
+ 21));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_fee (pg,
+ T0 + 2 * HOUR,
+ T0 + 3 * HOUR,
+ "9",
+ 4,
+ 22));
+
+ /* Two periods that agree: still the fee. The status is the number of
+ rows the range covered, so it is 2 rather than ONE_RESULT. */
+ memset (&fees,
+ 0,
+ sizeof (fees));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_get_global_fee_by_time (pg,
+ ts (T0),
+ ts (T0 + 2 * HOUR),
+ &fees,
+ &purse_timeout,
+ &history_expiration,
+ &purse_account_limit));
+ FAILIF (0 != TALER_amount_cmp (&fees.history,
+ &expect_history));
+ FAILIF (4 != purse_account_limit);
+
+ memset (&fees,
+ 0,
+ sizeof (fees));
+ FAILIF (0 >
+ TALER_EXCHANGEDB_get_global_fee_by_time (pg,
+ ts (T0),
+ ts (T0 + 3 * HOUR),
+ &fees,
+ &purse_timeout,
+ &history_expiration,
+ &purse_account_limit));
+ FAILIF (GNUNET_OK ==
+ TALER_amount_is_valid (&fees.history));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_global_fee_by_time (pg,
+ ts (T0 + 10 * HOUR),
+ ts (T0 + 11 * HOUR),
+ &fees,
+ &purse_timeout,
+ &history_expiration,
+ &purse_account_limit));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_global_fees (pg,
+ &global_fee_cb,
+ &ctx));
+ FAILIF (3 != ctx.total);
+ FAILIF (1 + 1 + 9 != ctx.value_sum);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "global-fee-empty",
+ &check_empty },
+ { "global-fee-single-period",
+ &check_single_period },
+ { "global-fee-by-time",
+ &check_by_time },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-global-fee",
+ "Tests for the exchangedb `global_fee' table",
+ tests);
+}
+
+
+/* end of test_global_fee.c */
diff --git a/src/exchangedb/test_known_coins.c b/src/exchangedb/test_known_coins.c
@@ -0,0 +1,436 @@
+/*
+ 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/test_known_coins.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `known_coins`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_insert_known_coin(),
+ * #TALER_EXCHANGEDB_get_known_coin(),
+ * #TALER_EXCHANGEDB_get_coin_denomination(),
+ * #TALER_EXCHANGEDB_get_signature_for_known_coin() and
+ * #TALER_EXCHANGEDB_get_count_known_coins().
+ *
+ * `known_coins` references `denominations`, so a denomination is created
+ * first with TDB_denom(). do_insert_known_coin() is the interesting one:
+ * it is idempotent, but only for a coin that comes back with the *same*
+ * denomination and age commitment -- the conflicting cases are what its
+ * negative status codes are for.
+ */
+#include "test_common.h"
+#include "exchange-database/do_insert_known_coin.h"
+#include "exchange-database/get_known_coin.h"
+#include "exchange-database/get_coin_denomination.h"
+#include "exchange-database/get_signature_for_known_coin.h"
+#include "exchange-database/get_count_known_coins.h"
+
+
+/**
+ * Nothing is known about a coin that was never inserted.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinSpendPublicKeyP coin_pub;
+ struct TALER_CoinPublicInfo info;
+ struct TALER_DenominationHashP h_denom_pub;
+ struct TALER_DenominationPublicKey denom_pub = { 0 };
+ struct TALER_DenominationSignature denom_sig = { 0 };
+ uint64_t known_coin_id;
+
+ TDB_FILL (coin_pub,
+ 1);
+ TDB_FILL (h_denom_pub,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_known_coin (pg,
+ &coin_pub,
+ &info));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_coin_denomination (pg,
+ &coin_pub,
+ &known_coin_id,
+ &h_denom_pub));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_signature_for_known_coin (pg,
+ &coin_pub,
+ &denom_pub,
+ &denom_sig));
+ FAILIF (NULL != denom_pub.bsign_pub_key);
+ FAILIF (NULL != denom_sig.unblinded_sig);
+ /* a denomination that does not exist has no coins */
+ FAILIF (0 != TALER_EXCHANGEDB_get_count_known_coins (pg,
+ &h_denom_pub));
+ return 0;
+}
+
+
+/**
+ * A coin of an unknown denomination cannot be made known.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_unknown_denomination (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_DenominationHashP dh;
+ struct TALER_AgeCommitmentHashP hac;
+ uint64_t known_coin_id;
+
+ memset (&coin,
+ 0,
+ sizeof (coin));
+ TDB_FILL (coin.coin_pub,
+ 2);
+ TDB_FILL (coin.denom_pub_hash,
+ 2);
+ coin.no_age_commitment = true;
+ TDB_denom_sig (2,
+ &coin.denom_sig);
+ /* the "dd" CTE finds no denomination, so nothing is inserted and the
+ UNION's second branch finds no coin either */
+ FAILIF_C (TALER_EXCHANGEDB_CKS_HARD_FAIL !=
+ TALER_EXCHANGEDB_do_insert_known_coin (pg,
+ &coin,
+ &known_coin_id,
+ &dh,
+ &hac),
+ TALER_denom_sig_free (&coin.denom_sig));
+ TALER_denom_sig_free (&coin.denom_sig);
+ FAILIF (0 != TDB_count (pg,
+ "FROM known_coins"));
+ return 0;
+}
+
+
+/**
+ * Making a coin known stores it, and every lookup finds it.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Denom denom;
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_CoinPublicInfo got;
+ struct TALER_DenominationHashP h_denom_pub;
+ struct TALER_DenominationPublicKey denom_pub = { 0 };
+ struct TALER_DenominationSignature denom_sig = { 0 };
+ uint64_t known_coin_id = 0;
+ uint64_t id2 = 0;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ &known_coin_id);
+ FAILIF_C (0 == known_coin_id,
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+
+ memset (&got,
+ 0,
+ sizeof (got));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_known_coin (pg,
+ &coin.coin_pub,
+ &got),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (0 != GNUNET_memcmp (&got.denom_pub_hash,
+ &denom.h_denom_pub),
+ TALER_denom_sig_free (&got.denom_sig);
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (! got.no_age_commitment,
+ TALER_denom_sig_free (&got.denom_sig);
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (0 != TALER_denom_sig_cmp (&got.denom_sig,
+ &coin.denom_sig),
+ TALER_denom_sig_free (&got.denom_sig);
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ TALER_denom_sig_free (&got.denom_sig);
+
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_coin_denomination (pg,
+ &coin.coin_pub,
+ &id2,
+ &h_denom_pub),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (id2 != known_coin_id,
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (0 != GNUNET_memcmp (&h_denom_pub,
+ &denom.h_denom_pub),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_signature_for_known_coin (pg,
+ &coin.coin_pub,
+ &denom_pub,
+ &denom_sig),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (0 != TALER_denom_pub_cmp (&denom_pub,
+ &denom.pub),
+ TALER_denom_pub_free (&denom_pub);
+ TALER_denom_sig_free (&denom_sig);
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (0 != TALER_denom_sig_cmp (&denom_sig,
+ &coin.denom_sig),
+ TALER_denom_pub_free (&denom_pub);
+ TALER_denom_sig_free (&denom_sig);
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ TALER_denom_pub_free (&denom_pub);
+ TALER_denom_sig_free (&denom_sig);
+
+ FAILIF_C (1 != TALER_EXCHANGEDB_get_count_known_coins (pg,
+ &denom.h_denom_pub),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ TDB_coin_free (&coin);
+ TDB_denom_free (&denom);
+ return 0;
+}
+
+
+/**
+ * Re-inserting the same coin is a no-op; re-inserting it under a different
+ * denomination or with an age commitment is a conflict.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_conflicts (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Denom denom;
+ struct TDB_Denom other;
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_DenominationHashP dh;
+ struct TALER_AgeCommitmentHashP hac;
+ uint64_t known_coin_id = 0;
+ uint64_t id2 = 0;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_denom (pg,
+ 11,
+ "5",
+ "0.1",
+ &other);
+ memset (&coin,
+ 0,
+ sizeof (coin));
+ TDB_FILL (coin.coin_pub,
+ 20);
+ coin.denom_pub_hash = denom.h_denom_pub;
+ coin.no_age_commitment = true;
+ TDB_denom_sig (20,
+ &coin.denom_sig);
+
+ /* the coin from the previous check: already present, no conflict */
+ FAILIF_C (TALER_EXCHANGEDB_CKS_PRESENT !=
+ TALER_EXCHANGEDB_do_insert_known_coin (pg,
+ &coin,
+ &known_coin_id,
+ &dh,
+ &hac),
+ TALER_denom_sig_free (&coin.denom_sig);
+ TDB_denom_free (&denom); TDB_denom_free (&other));
+ FAILIF_C (0 == known_coin_id,
+ TALER_denom_sig_free (&coin.denom_sig);
+ TDB_denom_free (&denom); TDB_denom_free (&other));
+
+ /* same coin key, different denomination: conflict, and the stored
+ denomination is handed back so the caller can report it */
+ coin.denom_pub_hash = other.h_denom_pub;
+ FAILIF_C (TALER_EXCHANGEDB_CKS_DENOM_CONFLICT !=
+ TALER_EXCHANGEDB_do_insert_known_coin (pg,
+ &coin,
+ &id2,
+ &dh,
+ &hac),
+ TALER_denom_sig_free (&coin.denom_sig);
+ TDB_denom_free (&denom); TDB_denom_free (&other));
+ FAILIF_C (0 != GNUNET_memcmp (&dh,
+ &denom.h_denom_pub),
+ TALER_denom_sig_free (&coin.denom_sig);
+ TDB_denom_free (&denom); TDB_denom_free (&other));
+
+ /* same coin key and denomination, but now with an age commitment where
+ the stored row has none: the caller should have passed NULL */
+ coin.denom_pub_hash = denom.h_denom_pub;
+ coin.no_age_commitment = false;
+ TDB_FILL (coin.h_age_commitment,
+ 21);
+ FAILIF_C (TALER_EXCHANGEDB_CKS_AGE_CONFLICT_EXPECTED_NULL !=
+ TALER_EXCHANGEDB_do_insert_known_coin (pg,
+ &coin,
+ &id2,
+ &dh,
+ &hac),
+ TALER_denom_sig_free (&coin.denom_sig);
+ TDB_denom_free (&denom); TDB_denom_free (&other));
+ TALER_denom_sig_free (&coin.denom_sig);
+
+ /* the conflicting attempts did not add anything */
+ FAILIF_C (1 != TALER_EXCHANGEDB_get_count_known_coins (pg,
+ &denom.h_denom_pub),
+ TDB_denom_free (&denom); TDB_denom_free (&other));
+ FAILIF_C (0 != TALER_EXCHANGEDB_get_count_known_coins (pg,
+ &other.h_denom_pub),
+ TDB_denom_free (&denom); TDB_denom_free (&other));
+ TDB_denom_free (&denom);
+ TDB_denom_free (&other);
+ return 0;
+}
+
+
+/**
+ * A coin with an age commitment round-trips, and the mirror-image
+ * conflict is reported.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_age_commitment (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Denom denom;
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_CoinPublicInfo got;
+ struct TALER_DenominationHashP dh;
+ struct TALER_AgeCommitmentHashP hac;
+ uint64_t known_coin_id = 0;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ memset (&coin,
+ 0,
+ sizeof (coin));
+ TDB_FILL (coin.coin_pub,
+ 30);
+ coin.denom_pub_hash = denom.h_denom_pub;
+ coin.no_age_commitment = false;
+ TDB_FILL (coin.h_age_commitment,
+ 31);
+ TDB_denom_sig (30,
+ &coin.denom_sig);
+ FAILIF_C (TALER_EXCHANGEDB_CKS_ADDED !=
+ TALER_EXCHANGEDB_do_insert_known_coin (pg,
+ &coin,
+ &known_coin_id,
+ &dh,
+ &hac),
+ TALER_denom_sig_free (&coin.denom_sig);
+ TDB_denom_free (&denom));
+ memset (&got,
+ 0,
+ sizeof (got));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_known_coin (pg,
+ &coin.coin_pub,
+ &got),
+ TALER_denom_sig_free (&coin.denom_sig);
+ TDB_denom_free (&denom));
+ FAILIF_C (got.no_age_commitment,
+ TALER_denom_sig_free (&got.denom_sig);
+ TALER_denom_sig_free (&coin.denom_sig);
+ TDB_denom_free (&denom));
+ FAILIF_C (0 != GNUNET_memcmp (&got.h_age_commitment,
+ &coin.h_age_commitment),
+ TALER_denom_sig_free (&got.denom_sig);
+ TALER_denom_sig_free (&coin.denom_sig);
+ TDB_denom_free (&denom));
+ TALER_denom_sig_free (&got.denom_sig);
+
+ /* a different age commitment for the same coin is a conflict... */
+ TDB_FILL (coin.h_age_commitment,
+ 32);
+ FAILIF_C (TALER_EXCHANGEDB_CKS_AGE_CONFLICT_VALUE_DIFFERS !=
+ TALER_EXCHANGEDB_do_insert_known_coin (pg,
+ &coin,
+ &known_coin_id,
+ &dh,
+ &hac),
+ TALER_denom_sig_free (&coin.denom_sig);
+ TDB_denom_free (&denom));
+ /* ...and so is no age commitment at all, where one is on file */
+ coin.no_age_commitment = true;
+ FAILIF_C (TALER_EXCHANGEDB_CKS_AGE_CONFLICT_EXPECTED_NON_NULL !=
+ TALER_EXCHANGEDB_do_insert_known_coin (pg,
+ &coin,
+ &known_coin_id,
+ &dh,
+ &hac),
+ TALER_denom_sig_free (&coin.denom_sig);
+ TDB_denom_free (&denom));
+ TALER_denom_sig_free (&coin.denom_sig);
+
+ /* two coins of this denomination by now */
+ FAILIF_C (2 != TALER_EXCHANGEDB_get_count_known_coins (pg,
+ &denom.h_denom_pub),
+ TDB_denom_free (&denom));
+ TDB_denom_free (&denom);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "known-coins-empty",
+ &check_empty },
+ { "known-coins-unknown-denomination",
+ &check_unknown_denomination },
+ { "known-coins-insert-and-lookup",
+ &check_insert_and_lookup },
+ { "known-coins-conflicts",
+ &check_conflicts },
+ { "known-coins-age-commitment",
+ &check_age_commitment },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-known-coins",
+ "Tests for the exchangedb `known_coins' table",
+ tests);
+}
+
+
+/* end of test_known_coins.c */
diff --git a/src/exchangedb/test_kyc_alerts.c b/src/exchangedb/test_kyc_alerts.c
@@ -0,0 +1,235 @@
+/*
+ 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/test_kyc_alerts.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `kyc_alerts`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_drain_kyc_alert().
+ *
+ * `kyc_alerts` is a work queue: the stored procedures that change the KYC
+ * state of an account append a row, and taler-exchange-aggregator drains
+ * it one row at a time. Nothing exported writes to it directly, so the
+ * rows here are produced through
+ * #TALER_EXCHANGEDB_insert_aml_program_failure(), the cheapest of the
+ * three producers.
+ */
+#include "test_common.h"
+#include "exchange-database/do_drain_kyc_alert.h"
+#include "exchange-database/insert_aml_program_failure.h"
+
+
+/**
+ * Trigger type the aggregator drains, and the only one any stored
+ * procedure ever writes.
+ */
+#define AGGREGATOR_TRIGGER 1
+
+
+/**
+ * The two accounts we raise alerts for.
+ */
+static struct TDB_Account account[2];
+
+
+/**
+ * Nothing to drain while the queue is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_NormalizedPaytoHashP h_payto;
+
+ for (unsigned int i = 0; i < 2; i++)
+ TDB_account (pg,
+ 10 + i,
+ &account[i]);
+ memset (&h_payto,
+ 0,
+ sizeof (h_payto));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_do_drain_kyc_alert (pg,
+ AGGREGATOR_TRIGGER,
+ &h_payto));
+ return 0;
+}
+
+
+/**
+ * A KYC state change queues exactly one alert per account.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_raise (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ for (unsigned int i = 0; i < 2; i++)
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_aml_program_failure (
+ pg,
+ 0,
+ &account[i].h_normalized,
+ "no such AML program",
+ TALER_EC_EXCHANGE_GENERIC_KYC_FAILED));
+ FAILIF (2 != TDB_count (pg,
+ "FROM kyc_alerts"
+ " WHERE trigger_type=%d",
+ AGGREGATOR_TRIGGER));
+
+ /* a second state change on an account that is already queued does not
+ queue it twice */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_aml_program_failure (
+ pg,
+ 0,
+ &account[0].h_normalized,
+ "still no such AML program",
+ TALER_EC_EXCHANGE_GENERIC_KYC_FAILED));
+ FAILIF (2 != TDB_count (pg,
+ "FROM kyc_alerts"));
+ return 0;
+}
+
+
+/**
+ * Draining returns the queued accounts one by one and removes them.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_drain (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_NormalizedPaytoHashP first;
+ struct TALER_NormalizedPaytoHashP second;
+
+ /* nobody listens for this trigger type, so the queue looks empty */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_do_drain_kyc_alert (pg,
+ AGGREGATOR_TRIGGER + 1,
+ &first));
+ FAILIF (2 != TDB_count (pg,
+ "FROM kyc_alerts"));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_drain_kyc_alert (pg,
+ AGGREGATOR_TRIGGER,
+ &first));
+ FAILIF (1 != TDB_count (pg,
+ "FROM kyc_alerts"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_drain_kyc_alert (pg,
+ AGGREGATOR_TRIGGER,
+ &second));
+ FAILIF (0 != TDB_count (pg,
+ "FROM kyc_alerts"));
+
+ /* both of our accounts came back, each exactly once */
+ FAILIF (0 == GNUNET_memcmp (&first,
+ &second));
+ for (unsigned int i = 0; i < 2; i++)
+ {
+ unsigned int seen = 0;
+
+ if (0 == GNUNET_memcmp (&first,
+ &account[i].h_normalized))
+ seen++;
+ if (0 == GNUNET_memcmp (&second,
+ &account[i].h_normalized))
+ seen++;
+ FAILIF (1 != seen);
+ }
+
+ /* and the queue is empty again */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_do_drain_kyc_alert (pg,
+ AGGREGATOR_TRIGGER,
+ &first));
+ return 0;
+}
+
+
+/**
+ * An account that was drained can be queued again.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_requeue (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_NormalizedPaytoHashP h_payto;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_aml_program_failure (
+ pg,
+ 0,
+ &account[1].h_normalized,
+ "and again",
+ TALER_EC_EXCHANGE_GENERIC_KYC_FAILED));
+ FAILIF (1 != TDB_count (pg,
+ "FROM kyc_alerts"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_drain_kyc_alert (pg,
+ AGGREGATOR_TRIGGER,
+ &h_payto));
+ FAILIF (0 != GNUNET_memcmp (&h_payto,
+ &account[1].h_normalized));
+ FAILIF (0 != TDB_count (pg,
+ "FROM kyc_alerts"));
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "kyc-alerts-empty",
+ &check_empty },
+ { "kyc-alerts-raise",
+ &check_raise },
+ { "kyc-alerts-drain",
+ &check_drain },
+ { "kyc-alerts-requeue",
+ &check_requeue },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-kyc-alerts",
+ "Tests for the exchangedb `kyc_alerts' table",
+ tests);
+ for (unsigned int i = 0; i < 2; i++)
+ TDB_account_free (&account[i]);
+ return ret;
+}
+
+
+/* end of test_kyc_alerts.c */
diff --git a/src/exchangedb/test_kyc_attributes.c b/src/exchangedb/test_kyc_attributes.c
@@ -0,0 +1,956 @@
+/*
+ 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/test_kyc_attributes.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `kyc_attributes`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_insert_kyc_attributes(),
+ * #TALER_EXCHANGEDB_iterate_kyc_attributes(),
+ * #TALER_EXCHANGEDB_iterate_all_kyc_attributes(),
+ * #TALER_EXCHANGEDB_iterate_aml_attributes(),
+ * #TALER_EXCHANGEDB_iterate_kyc_history(),
+ * #TALER_EXCHANGEDB_kyc_history_builder() and
+ * #TALER_EXCHANGEDB_current_attributes_builder().
+ *
+ * A `kyc_attributes` row is the (encrypted) personal data one KYC process
+ * collected, so every row needs a `legitimization_processes` row to hang
+ * off; those in turn need a legitimization measure on a known account.
+ * That is what setup_account() builds before the first insert. The AML
+ * officer path is exercised too, because it is the only way to get a row
+ * with `by_aml_officer` set, which is what the AML attribute iterator
+ * reports on.
+ */
+#include "test_common.h"
+#include "exchange-database/account_history.h"
+#include "exchange-database/do_insert_kyc_attributes.h"
+#include "exchange-database/do_trigger_kyc_rule_for_account.h"
+#include "exchange-database/insert_aml_decision.h"
+#include "exchange-database/insert_aml_officer.h"
+#include "exchange-database/insert_legitimization_process.h"
+#include "exchange-database/iterate_all_kyc_attributes.h"
+#include "exchange-database/iterate_aml_attributes.h"
+#include "exchange-database/iterate_kyc_attributes.h"
+#include "exchange-database/iterate_kyc_history.h"
+
+
+/**
+ * Account the attributes are about.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Row of the legitimization measures on @e account.
+ */
+static uint64_t measure_row;
+
+
+/**
+ * Rows of the two KYC processes we run on @e account.
+ */
+static uint64_t process_row[2];
+
+
+/**
+ * Key the attributes are encrypted with.
+ */
+static struct TALER_AttributeEncryptionKeyP attribute_key;
+
+
+/**
+ * Name the AML officer is on file under.
+ */
+#define OFFICER_NAME "Alex Officer"
+
+
+/**
+ * Build the attribute set of @a name for storage.
+ *
+ * @param name value of the NAME attribute
+ * @param[out] enc set to the encrypted attributes, to be freed by the
+ * caller
+ * @param[out] enc_size set to the number of bytes in @a enc
+ */
+static void
+encrypt_attributes (const char *name,
+ void **enc,
+ size_t *enc_size)
+{
+ json_t *attrs;
+
+ attrs = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("FORM_ID",
+ "test-form"),
+ GNUNET_JSON_pack_string ("NAME",
+ name));
+ GNUNET_assert (NULL != attrs);
+ TALER_CRYPTO_kyc_attributes_encrypt (&attribute_key,
+ attrs,
+ enc,
+ enc_size);
+ json_decref (attrs);
+}
+
+
+/**
+ * Closure for the various attribute callbacks.
+ */
+struct AttributeContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Value of the NAME attribute of the last row seen, owned by this
+ * struct.
+ */
+ char *name;
+
+ /**
+ * Provider of the last row seen, owned by this struct.
+ */
+ char *provider;
+
+ /**
+ * Officer of the last row seen, owned by this struct.
+ */
+ char *officer;
+
+ /**
+ * Row ID of the last row seen.
+ */
+ uint64_t row_id;
+
+ /**
+ * Was the last row filed by an AML officer?
+ */
+ bool by_aml_officer;
+
+ /**
+ * Was the KYC process of the last row finished?
+ */
+ bool finished;
+};
+
+
+/**
+ * Release what a `struct AttributeContext` owns.
+ *
+ * @param[in,out] ctx context to clean up
+ */
+static void
+ac_free (struct AttributeContext *ctx)
+{
+ GNUNET_free (ctx->name);
+ GNUNET_free (ctx->provider);
+ GNUNET_free (ctx->officer);
+}
+
+
+/**
+ * Decrypt @a enc and remember its NAME attribute in @a ctx.
+ *
+ * @param[in,out] ctx context to update
+ * @param enc_size number of bytes in @a enc
+ * @param enc encrypted attributes
+ */
+static void
+remember_name (struct AttributeContext *ctx,
+ size_t enc_size,
+ const void *enc)
+{
+ json_t *attrs;
+ const char *name;
+
+ GNUNET_free (ctx->name);
+ ctx->name = NULL;
+ attrs = TALER_CRYPTO_kyc_attributes_decrypt (&attribute_key,
+ enc,
+ enc_size);
+ if (NULL == attrs)
+ return;
+ name = json_string_value (json_object_get (attrs,
+ "NAME"));
+ if (NULL != name)
+ ctx->name = GNUNET_strdup (name);
+ json_decref (attrs);
+}
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_kyc_attributes().
+ *
+ * @param cls a `struct AttributeContext *`
+ * @param h_payto account the attributes are about
+ * @param provider_name provider that collected them
+ * @param collection_time when they were collected
+ * @param expiration_time when they expire
+ * @param enc_attributes_size number of bytes in @a enc_attributes
+ * @param enc_attributes the encrypted attributes
+ */
+static void
+attribute_cb (void *cls,
+ const struct TALER_NormalizedPaytoHashP *h_payto,
+ const char *provider_name,
+ struct GNUNET_TIME_Timestamp collection_time,
+ struct GNUNET_TIME_Timestamp expiration_time,
+ size_t enc_attributes_size,
+ const void *enc_attributes)
+{
+ struct AttributeContext *ctx = cls;
+
+ (void) h_payto;
+ (void) collection_time;
+ (void) expiration_time;
+ ctx->total++;
+ GNUNET_free (ctx->provider);
+ ctx->provider = (NULL == provider_name)
+ ? NULL
+ : GNUNET_strdup (provider_name);
+ remember_name (ctx,
+ enc_attributes_size,
+ enc_attributes);
+}
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_all_kyc_attributes().
+ *
+ * @param cls a `struct AttributeContext *`
+ * @param row_id row of the attributes
+ * @param h_payto account the attributes are about
+ * @param provider_name provider that collected them
+ * @param collection_time when they were collected
+ * @param expiration_time when they expire
+ * @param properties properties set for the account
+ * @param enc_attributes_size number of bytes in @a enc_attributes
+ * @param enc_attributes the encrypted attributes
+ * @return true to continue iterating
+ */
+static bool
+all_attributes_cb (void *cls,
+ uint64_t row_id,
+ const struct TALER_NormalizedPaytoHashP *h_payto,
+ const char *provider_name,
+ struct GNUNET_TIME_Timestamp collection_time,
+ struct GNUNET_TIME_Timestamp expiration_time,
+ const json_t *properties,
+ size_t enc_attributes_size,
+ const void *enc_attributes)
+{
+ struct AttributeContext *ctx = cls;
+
+ (void) h_payto;
+ (void) collection_time;
+ (void) expiration_time;
+ (void) properties;
+ ctx->total++;
+ ctx->row_id = row_id;
+ GNUNET_free (ctx->provider);
+ ctx->provider = (NULL == provider_name)
+ ? NULL
+ : GNUNET_strdup (provider_name);
+ remember_name (ctx,
+ enc_attributes_size,
+ enc_attributes);
+ return ! ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) );
+}
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_aml_attributes().
+ *
+ * @param cls a `struct AttributeContext *`
+ * @param row_id row of the attributes
+ * @param collection_time when they were collected
+ * @param by_aml_officer were they filed by an AML officer
+ * @param officer_name name of that officer, NULL if none
+ * @param enc_attributes_size number of bytes in @a enc_attributes
+ * @param enc_attributes the encrypted attributes
+ */
+static void
+aml_attribute_cb (void *cls,
+ uint64_t row_id,
+ struct GNUNET_TIME_Timestamp collection_time,
+ bool by_aml_officer,
+ const char *officer_name,
+ size_t enc_attributes_size,
+ const void *enc_attributes)
+{
+ struct AttributeContext *ctx = cls;
+
+ (void) collection_time;
+ ctx->total++;
+ ctx->row_id = row_id;
+ ctx->by_aml_officer = by_aml_officer;
+ GNUNET_free (ctx->officer);
+ ctx->officer = (NULL == officer_name)
+ ? NULL
+ : GNUNET_strdup (officer_name);
+ remember_name (ctx,
+ enc_attributes_size,
+ enc_attributes);
+}
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_kyc_history().
+ *
+ * @param cls a `struct AttributeContext *`
+ * @param provider_name provider that ran the process
+ * @param finished did the process finish
+ * @param error_code error the process ran into
+ * @param error_message message for @a error_code
+ * @param provider_user_id account ID at the provider
+ * @param provider_legitimization_id process ID at the provider
+ * @param collection_time when the data was collected
+ * @param expiration_time when the data expires
+ * @param encrypted_attributes_len number of bytes in @a encrypted_attributes
+ * @param encrypted_attributes the encrypted attributes
+ */
+static void
+kyc_history_cb (void *cls,
+ const char *provider_name,
+ bool finished,
+ enum TALER_ErrorCode error_code,
+ const char *error_message,
+ const char *provider_user_id,
+ const char *provider_legitimization_id,
+ struct GNUNET_TIME_Timestamp collection_time,
+ struct GNUNET_TIME_Absolute expiration_time,
+ size_t encrypted_attributes_len,
+ const void *encrypted_attributes)
+{
+ struct AttributeContext *ctx = cls;
+
+ (void) error_code;
+ (void) error_message;
+ (void) provider_user_id;
+ (void) provider_legitimization_id;
+ (void) collection_time;
+ (void) expiration_time;
+ ctx->total++;
+ ctx->finished = finished;
+ GNUNET_free (ctx->provider);
+ ctx->provider = (NULL == provider_name)
+ ? NULL
+ : GNUNET_strdup (provider_name);
+ remember_name (ctx,
+ encrypted_attributes_len,
+ encrypted_attributes);
+}
+
+
+/**
+ * Create the account, put a legitimization measure on it and start the
+ * two KYC processes the attributes will belong to.
+ *
+ * @param pg the database context
+ */
+static void
+setup_account (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ json_t *jmeasures;
+ bool bad_kyc_auth;
+
+ if (0 != measure_row)
+ return;
+ TDB_FILL (attribute_key,
+ 42);
+ TDB_account (pg,
+ 10,
+ &account);
+ jmeasures = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_array_steal (
+ "measures",
+ json_pack ("[{s:s,s:s,s:s}]",
+ "check_name",
+ "verify-id",
+ "prog_name",
+ "skip",
+ "context",
+ "none")),
+ GNUNET_JSON_pack_bool ("is_and_combinator",
+ true));
+ GNUNET_assert (NULL != jmeasures);
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_do_trigger_kyc_rule_for_account (
+ pg,
+ account.payto,
+ &account.h_normalized,
+ NULL,
+ NULL,
+ jmeasures,
+ 1,
+ &measure_row,
+ &bad_kyc_auth));
+ json_decref (jmeasures);
+ GNUNET_assert (0 != measure_row);
+ for (unsigned int i = 0; i < 2; i++)
+ {
+ char *legi_id;
+
+ GNUNET_asprintf (&legi_id,
+ "legi-%u",
+ i);
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_insert_legitimization_process (
+ pg,
+ &account.h_normalized,
+ i,
+ measure_row,
+ (0 == i) ? "provider-a" : "provider-b",
+ "provider-account",
+ legi_id,
+ &process_row[i]));
+ GNUNET_free (legi_id);
+ GNUNET_assert (0 != process_row[i]);
+ }
+}
+
+
+/**
+ * Nothing is known while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct AttributeContext ctx = { 0 };
+ struct TALER_EXCHANGEDB_HistoryBuilderContext hbc;
+ json_t *j;
+
+ setup_account (pg);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_kyc_attributes (pg,
+ &account.h_normalized,
+ &attribute_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_all_kyc_attributes (pg,
+ 0,
+ &all_attributes_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_attributes (pg,
+ &account.h_normalized,
+ 0,
+ 16,
+ &aml_attribute_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_kyc_history (pg,
+ &account.h_normalized,
+ &kyc_history_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ hbc.account = &account.h_normalized;
+ hbc.pg = pg;
+ hbc.attribute_key = &attribute_key;
+ hbc.is_wallet = false;
+ j = TALER_EXCHANGEDB_kyc_history_builder (&hbc);
+ FAILIF (NULL == j);
+ FAILIF_C (0 != json_array_size (j),
+ json_decref (j));
+ json_decref (j);
+ /* with no attributes at all, the "current" ones are an empty object */
+ j = TALER_EXCHANGEDB_current_attributes_builder (&hbc);
+ FAILIF (NULL == j);
+ FAILIF_C (! json_is_object (j),
+ json_decref (j));
+ FAILIF_C (0 != json_object_size (j),
+ json_decref (j));
+ json_decref (j);
+ return 0;
+}
+
+
+/**
+ * Storing attributes creates the row and finishes the KYC process they
+ * were collected by.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ void *enc;
+ size_t enc_size;
+ enum GNUNET_DB_QueryStatus qs;
+
+ encrypt_attributes ("Alice",
+ &enc,
+ &enc_size);
+ qs = TALER_EXCHANGEDB_do_insert_kyc_attributes (
+ pg,
+ process_row[0],
+ &account.h_normalized,
+ "provider-a",
+ "provider-account",
+ "legi-0",
+ 0,
+ GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_DAYS),
+ "test-form",
+ enc_size,
+ enc);
+ GNUNET_free (enc);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs);
+ FAILIF (1 != TDB_count (pg,
+ "FROM kyc_attributes"));
+ FAILIF (1 != TDB_count (pg,
+ "FROM kyc_attributes"
+ " WHERE NOT by_aml_officer"
+ " AND form_name='test-form'"));
+ /* the KYC process the attributes came from is now finished */
+ FAILIF (1 != TDB_count (pg,
+ "FROM legitimization_processes"
+ " WHERE legitimization_process_serial_id=%llu"
+ " AND finished",
+ (unsigned long long) process_row[0]));
+ /* ... and the aggregator was told to have another look */
+ FAILIF (0 == TDB_count (pg,
+ "FROM kyc_alerts"));
+ return 0;
+}
+
+
+/**
+ * Naming a provider that does not own the KYC process stores the
+ * attributes but leaves the process unfinished.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_wrong_provider (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ void *enc;
+ size_t enc_size;
+ enum GNUNET_DB_QueryStatus qs;
+
+ encrypt_attributes ("Bob",
+ &enc,
+ &enc_size);
+ qs = TALER_EXCHANGEDB_do_insert_kyc_attributes (
+ pg,
+ process_row[1],
+ &account.h_normalized,
+ /* process_row[1] belongs to provider-b */
+ "provider-a",
+ "provider-account",
+ "legi-1",
+ 0,
+ GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_DAYS),
+ NULL,
+ enc_size,
+ enc);
+ GNUNET_free (enc);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs);
+ FAILIF (2 != TDB_count (pg,
+ "FROM kyc_attributes"));
+ /* the attributes were stored, but the process was not finished; the
+ stored procedure reports that through out_ok, which the C wrapper
+ drops on the floor (EDBT-16) */
+ FAILIF (0 != TDB_count (pg,
+ "FROM legitimization_processes"
+ " WHERE legitimization_process_serial_id=%llu"
+ " AND finished",
+ (unsigned long long) process_row[1]));
+ FAILIF (1 != TDB_count (pg,
+ "FROM kyc_attributes"
+ " WHERE form_name IS NULL"));
+ return 0;
+}
+
+
+/**
+ * The per-account and the global iterator both find the stored rows.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct AttributeContext ctx;
+ struct TALER_NormalizedPaytoHashP other;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_kyc_attributes (pg,
+ &account.h_normalized,
+ &attribute_cb,
+ &ctx));
+ FAILIF_C (2 != ctx.total,
+ ac_free (&ctx));
+ FAILIF_C (NULL == ctx.name,
+ ac_free (&ctx));
+ ac_free (&ctx);
+
+ /* an account without attributes has none */
+ TDB_FILL (other,
+ 99);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_kyc_attributes (pg,
+ &other,
+ &attribute_cb,
+ &ctx));
+ FAILIF_C (0 != ctx.total,
+ ac_free (&ctx));
+ ac_free (&ctx);
+
+ /* the global iterator sees both rows, in ascending row order */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_all_kyc_attributes (pg,
+ 0,
+ &all_attributes_cb,
+ &ctx));
+ FAILIF_C (2 != ctx.total,
+ ac_free (&ctx));
+ FAILIF_C (0 != strcmp (ctx.name,
+ "Bob"),
+ ac_free (&ctx));
+ {
+ uint64_t first = ctx.row_id - 1;
+
+ ac_free (&ctx);
+ /* ... and honours the minimum row ID */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_all_kyc_attributes (pg,
+ first,
+ &all_attributes_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.total,
+ ac_free (&ctx));
+ ac_free (&ctx);
+ }
+
+ /* a callback that says stop is not called again */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_all_kyc_attributes (pg,
+ 0,
+ &all_attributes_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.total,
+ ac_free (&ctx));
+ FAILIF_C (0 != strcmp (ctx.name,
+ "Alice"),
+ ac_free (&ctx));
+ ac_free (&ctx);
+ return 0;
+}
+
+
+/**
+ * The KYC history reports the state of the process each set of
+ * attributes came from.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_kyc_history (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct AttributeContext ctx;
+ struct TALER_EXCHANGEDB_HistoryBuilderContext hbc = {
+ .account = &account.h_normalized,
+ .pg = pg,
+ .attribute_key = &attribute_key,
+ .is_wallet = false
+ };
+ json_t *j;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_kyc_history (pg,
+ &account.h_normalized,
+ &kyc_history_cb,
+ &ctx));
+ FAILIF_C (2 != ctx.total,
+ ac_free (&ctx));
+ ac_free (&ctx);
+
+ j = TALER_EXCHANGEDB_kyc_history_builder (&hbc);
+ FAILIF (NULL == j);
+ FAILIF_C (2 != json_array_size (j),
+ json_decref (j));
+ {
+ json_t *e = json_array_get (j,
+ 0);
+ const json_t *attrs = json_object_get (e,
+ "attributes");
+
+ FAILIF_C (NULL == attrs,
+ json_decref (j));
+ FAILIF_C (NULL == json_object_get (attrs,
+ "NAME"),
+ json_decref (j));
+ }
+ json_decref (j);
+ return 0;
+}
+
+
+/**
+ * Attributes filed by an AML officer are reported as such, together with
+ * the officer's name.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_aml_attributes (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AmlOfficerPublicKeyP officer_pub;
+ struct TALER_AmlOfficerSignatureP officer_sig;
+ struct TALER_MasterSignatureP master_sig;
+ struct GNUNET_TIME_Timestamp previous_change;
+ struct TALER_FullPayto null_payto = { NULL };
+ struct GNUNET_HashCode attributes_hash;
+ const char *no_events[1] = { NULL };
+ struct AttributeContext ctx;
+ json_t *new_rules;
+ void *enc;
+ size_t enc_size;
+ bool invalid_officer;
+ bool unknown_account;
+ bool is_wallet;
+ uint64_t lms;
+ struct GNUNET_TIME_Timestamp last_date;
+ enum GNUNET_DB_QueryStatus qs;
+
+ TDB_FILL (officer_pub,
+ 20);
+ TDB_FILL (officer_sig,
+ 21);
+ TDB_FILL (master_sig,
+ 22);
+ TDB_FILL (attributes_hash,
+ 23);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_aml_officer (pg,
+ &officer_pub,
+ &master_sig,
+ OFFICER_NAME,
+ true,
+ false,
+ GNUNET_TIME_timestamp_get (),
+ &previous_change));
+ encrypt_attributes ("Carol",
+ &enc,
+ &enc_size);
+ new_rules = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_array_steal ("rules",
+ json_array ()));
+ GNUNET_assert (NULL != new_rules);
+ qs = TALER_EXCHANGEDB_insert_aml_decision (
+ pg,
+ null_payto,
+ &account.h_normalized,
+ GNUNET_TIME_timestamp_get (),
+ GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS),
+ NULL,
+ new_rules,
+ false,
+ NULL,
+ NULL,
+ "collected in person",
+ &officer_pub,
+ &officer_sig,
+ 0,
+ no_events,
+ "test-form",
+ enc_size,
+ enc,
+ &attributes_hash,
+ GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_DAYS),
+ &invalid_officer,
+ &unknown_account,
+ &last_date,
+ &lms,
+ &is_wallet);
+ json_decref (new_rules);
+ GNUNET_free (enc);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != qs);
+ FAILIF (invalid_officer);
+ FAILIF (3 != TDB_count (pg,
+ "FROM kyc_attributes"));
+ FAILIF (1 != TDB_count (pg,
+ "FROM kyc_attributes"
+ " WHERE by_aml_officer"));
+
+ /* ascending from the start: all three rows, the officer's last */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_attributes (pg,
+ &account.h_normalized,
+ 0,
+ 16,
+ &aml_attribute_cb,
+ &ctx));
+ FAILIF_C (3 != ctx.total,
+ ac_free (&ctx));
+ FAILIF_C (! ctx.by_aml_officer,
+ ac_free (&ctx));
+ FAILIF_C (NULL == ctx.officer,
+ ac_free (&ctx));
+ FAILIF_C (0 != strcmp (ctx.officer,
+ OFFICER_NAME),
+ ac_free (&ctx));
+ FAILIF_C (0 != strcmp (ctx.name,
+ "Carol"),
+ ac_free (&ctx));
+ ac_free (&ctx);
+
+ /* descending from the end: only the newest row */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_attributes (pg,
+ &account.h_normalized,
+ INT64_MAX,
+ -1,
+ &aml_attribute_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.total,
+ ac_free (&ctx));
+ FAILIF_C (! ctx.by_aml_officer,
+ ac_free (&ctx));
+ ac_free (&ctx);
+
+ /* ascending past the last row: nothing */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_attributes (pg,
+ &account.h_normalized,
+ INT64_MAX,
+ 16,
+ &aml_attribute_cb,
+ &ctx));
+ FAILIF_C (0 != ctx.total,
+ ac_free (&ctx));
+ ac_free (&ctx);
+ return 0;
+}
+
+
+/**
+ * The "current attributes" builder returns the newest set, decrypted.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_current_attributes (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_HistoryBuilderContext hbc = {
+ .account = &account.h_normalized,
+ .pg = pg,
+ .attribute_key = &attribute_key,
+ .is_wallet = false
+ };
+ json_t *j;
+ const char *name;
+
+ j = TALER_EXCHANGEDB_current_attributes_builder (&hbc);
+ FAILIF (NULL == j);
+ name = json_string_value (json_object_get (j,
+ "NAME"));
+ FAILIF_C (NULL == name,
+ json_decref (j));
+ FAILIF_C (0 != strcmp (name,
+ "Carol"),
+ json_decref (j));
+ json_decref (j);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "kyc-attributes-empty",
+ &check_empty },
+ { "kyc-attributes-insert",
+ &check_insert },
+ { "kyc-attributes-insert-wrong-provider",
+ &check_insert_wrong_provider },
+ { "kyc-attributes-iterate",
+ &check_iterate },
+ { "kyc-attributes-kyc-history",
+ &check_kyc_history },
+ { "kyc-attributes-aml-attributes",
+ &check_aml_attributes },
+ { "kyc-attributes-current-attributes",
+ &check_current_attributes },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-kyc-attributes",
+ "Tests for the exchangedb `kyc_attributes' table",
+ tests);
+ TDB_account_free (&account);
+ return ret;
+}
+
+
+/* end of test_kyc_attributes.c */
diff --git a/src/exchangedb/test_kyc_events.c b/src/exchangedb/test_kyc_events.c
@@ -0,0 +1,438 @@
+/*
+ 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/test_kyc_events.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `kyc_events`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_iterate_aml_statistics().
+ *
+ * `kyc_events` is a plain counter log: AML decisions may name events,
+ * each of which becomes one row, and the statistics query groups those
+ * rows by name over a time range. Two of the names are special --
+ * ACCOUNT_OPEN and ACCOUNT_IDLE also move the account's open/close time
+ * in `kyc_targets` -- so those are checked as well.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_aml_decision.h"
+#include "exchange-database/insert_aml_officer.h"
+#include "exchange-database/iterate_aml_statistics.h"
+
+
+/**
+ * One second, in microseconds.
+ */
+#define SECOND 1000000LLU
+
+
+/**
+ * Times at which we take our three decisions, in seconds since the
+ * epoch.
+ */
+#define T1 1600000000
+#define T2 1600003600
+#define T3 1600007200
+
+
+/**
+ * Account the decisions are about.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Officer taking the decisions.
+ */
+static struct TALER_AmlOfficerPublicKeyP officer_pub;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * SECOND
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #statistics_cb().
+ */
+struct StatsContext
+{
+ /**
+ * How many counters did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Name we are interested in.
+ */
+ const char *name;
+
+ /**
+ * Count reported for @e name, UINT64_MAX if it was not reported.
+ */
+ uint64_t cnt;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_aml_statistics().
+ *
+ * @param cls a `struct StatsContext *`
+ * @param name name of the counter
+ * @param cnt value of the counter
+ */
+static void
+statistics_cb (void *cls,
+ const char *name,
+ uint64_t cnt)
+{
+ struct StatsContext *ctx = cls;
+
+ ctx->total++;
+ if ( (NULL != ctx->name) &&
+ (0 == strcmp (name,
+ ctx->name)) )
+ ctx->cnt = cnt;
+}
+
+
+/**
+ * Take an AML decision that triggers @a events.
+ *
+ * @param pg the database context
+ * @param seed seed for the officer's signature
+ * @param when when the decision is taken, in seconds since the epoch
+ * @param num_events length of @a events
+ * @param events the events to trigger
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+decide (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ uint64_t when,
+ size_t num_events,
+ const char *events[static num_events])
+{
+ struct TALER_AmlOfficerSignatureP decider_sig;
+ struct TALER_FullPayto null_payto = { NULL };
+ struct GNUNET_TIME_Timestamp last_date;
+ json_t *new_rules;
+ bool invalid_officer;
+ bool unknown_account;
+ bool is_wallet;
+ uint64_t lms;
+ enum GNUNET_DB_QueryStatus qs;
+
+ TDB_fill (&decider_sig,
+ sizeof (decider_sig),
+ seed);
+ new_rules = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_array_steal ("rules",
+ json_array ()));
+ GNUNET_assert (NULL != new_rules);
+ qs = TALER_EXCHANGEDB_insert_aml_decision (
+ pg,
+ null_payto,
+ &account.h_normalized,
+ ts (when),
+ GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS),
+ NULL,
+ new_rules,
+ false,
+ NULL,
+ NULL,
+ "statistics",
+ &officer_pub,
+ &decider_sig,
+ num_events,
+ events,
+ NULL,
+ 0,
+ NULL,
+ NULL,
+ GNUNET_TIME_UNIT_ZERO_TS,
+ &invalid_officer,
+ &unknown_account,
+ &last_date,
+ &lms,
+ &is_wallet);
+ json_decref (new_rules);
+ GNUNET_assert (! invalid_officer);
+ return qs;
+}
+
+
+/**
+ * No counters while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ const char *names[] = { "ACCOUNT_OPEN", "sar-filed" };
+ struct TALER_MasterSignatureP master_sig;
+ struct GNUNET_TIME_Timestamp previous_change;
+ struct StatsContext ctx = { 0 };
+
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_FILL (officer_pub,
+ 20);
+ TDB_FILL (master_sig,
+ 21);
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_insert_aml_officer (pg,
+ &officer_pub,
+ &master_sig,
+ "Alex Officer",
+ true,
+ false,
+ ts (T1 - 1),
+ &previous_change));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_statistics (pg,
+ 2,
+ names,
+ ts (0),
+ ts (T3 + 1),
+ &statistics_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * Decisions record their events, and the two account-lifecycle events
+ * also move the account's open and close time.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_record (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ const char *open_and_sar[] = { "ACCOUNT_OPEN", "sar-filed" };
+ const char *sar[] = { "sar-filed" };
+ const char *idle[] = { "ACCOUNT_IDLE" };
+ const char *none[1] = { NULL };
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ decide (pg,
+ 30,
+ T1,
+ 2,
+ open_and_sar));
+ FAILIF (2 != TDB_count (pg,
+ "FROM kyc_events"));
+ FAILIF (1 != TDB_count (pg,
+ "FROM kyc_targets"
+ " WHERE open_time=%llu"
+ " AND close_time IS NULL",
+ (unsigned long long) (T1 * SECOND)));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ decide (pg,
+ 31,
+ T2,
+ 1,
+ sar));
+ FAILIF (3 != TDB_count (pg,
+ "FROM kyc_events"));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ decide (pg,
+ 32,
+ T3,
+ 1,
+ idle));
+ FAILIF (4 != TDB_count (pg,
+ "FROM kyc_events"));
+ FAILIF (1 != TDB_count (pg,
+ "FROM kyc_targets"
+ " WHERE close_time=%llu",
+ (unsigned long long) (T3 * SECOND)));
+
+ /* a decision without events records none */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ decide (pg,
+ 33,
+ T3 + 1,
+ 0,
+ none));
+ FAILIF (4 != TDB_count (pg,
+ "FROM kyc_events"));
+ return 0;
+}
+
+
+/**
+ * The statistics group the events by name over the requested range.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_statistics (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ const char *all[] = { "ACCOUNT_OPEN", "ACCOUNT_IDLE", "sar-filed" };
+ const char *sar[] = { "sar-filed" };
+ const char *unknown[] = { "no-such-event" };
+ struct StatsContext ctx;
+
+ /* over the whole range every requested name that occurred is
+ reported, once */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.name = "sar-filed";
+ ctx.cnt = UINT64_MAX;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_statistics (pg,
+ 3,
+ all,
+ ts (0),
+ ts (T3 + 2),
+ &statistics_cb,
+ &ctx));
+ FAILIF (3 != ctx.total);
+ FAILIF (2 != ctx.cnt);
+
+ /* a name that was not asked for is not reported, even though it
+ happened */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.name = "sar-filed";
+ ctx.cnt = UINT64_MAX;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_statistics (pg,
+ 1,
+ sar,
+ ts (0),
+ ts (T3 + 2),
+ &statistics_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ FAILIF (2 != ctx.cnt);
+
+ /* the start of the range is inclusive, the end is exclusive */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.name = "sar-filed";
+ ctx.cnt = UINT64_MAX;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_statistics (pg,
+ 1,
+ sar,
+ ts (T2),
+ ts (T3),
+ &statistics_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ FAILIF (1 != ctx.cnt);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_statistics (pg,
+ 1,
+ sar,
+ ts (T1),
+ ts (T1),
+ &statistics_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* a range after everything that happened is empty */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_statistics (pg,
+ 3,
+ all,
+ ts (T3 + 2),
+ ts (T3 + 100),
+ &statistics_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* and so is a name nobody ever recorded */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_statistics (pg,
+ 1,
+ unknown,
+ ts (0),
+ ts (T3 + 2),
+ &statistics_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "kyc-events-empty",
+ &check_empty },
+ { "kyc-events-record",
+ &check_record },
+ { "kyc-events-statistics",
+ &check_statistics },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-kyc-events",
+ "Tests for the exchangedb `kyc_events' table",
+ tests);
+ TDB_account_free (&account);
+ return ret;
+}
+
+
+/* end of test_kyc_events.c */
diff --git a/src/exchangedb/test_kyc_targets.c b/src/exchangedb/test_kyc_targets.c
@@ -0,0 +1,489 @@
+/*
+ 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/test_kyc_targets.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `kyc_targets`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_get_aml_file_number(),
+ * #TALER_EXCHANGEDB_get_h_payto_by_access_token(),
+ * #TALER_EXCHANGEDB_update_to_aml_locked(),
+ * #TALER_EXCHANGEDB_update_to_aml_unlocked() and
+ * #TALER_EXCHANGEDB_iterate_kyc_accounts().
+ *
+ * `kyc_targets` is the exchange's list of accounts it knows in a KYC
+ * sense, keyed by the hash of the *normalized* payto URI. No exported
+ * function creates a row -- the deposit and credit paths do, as a side
+ * effect -- so the checks use TDB_account() for that. The access token
+ * is generated by the database itself, so the checks read it back out.
+ */
+#include "test_common.h"
+#include "exchange-database/get_aml_file_number.h"
+#include "exchange-database/get_h_payto_by_access_token.h"
+#include "exchange-database/iterate_kyc_accounts.h"
+#include "exchange-database/update_to_aml_locked.h"
+#include "exchange-database/update_to_aml_unlocked.h"
+
+
+/**
+ * Accounts the checks work on.
+ */
+static struct TDB_Account account;
+
+/**
+ * A second account.
+ */
+static struct TDB_Account other;
+
+
+/**
+ * Read the access token the database generated for an account.
+ *
+ * @param pg the database context
+ * @param acc account to look up
+ * @param[out] access_token set to the account's access token
+ */
+static void
+get_access_token (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Account *acc,
+ struct TALER_AccountAccessTokenP *access_token)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (&acc->h_normalized),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_auto_from_type ("access_token",
+ access_token),
+ GNUNET_PQ_result_spec_end
+ };
+
+ GNUNET_assert (GNUNET_OK ==
+ GNUNET_PQ_prepare_anon (pg->conn,
+ "SELECT access_token"
+ " FROM kyc_targets"
+ " WHERE h_normalized_payto=$1;"));
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs));
+}
+
+
+/**
+ * Closure for #account_cb().
+ */
+struct AccountContext
+{
+ /**
+ * How many accounts did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Account we are looking for, NULL to match nothing.
+ */
+ const struct TALER_NormalizedPaytoHashP *h_payto;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Row of the first account seen.
+ */
+ uint64_t first_row;
+
+ /**
+ * Was the matching account flagged for investigation?
+ */
+ bool to_investigate;
+
+ /**
+ * Was the matching account flagged high risk?
+ */
+ bool high_risk;
+
+ /**
+ * Was a payto URI reported for it?
+ */
+ bool have_payto;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_kyc_accounts().
+ *
+ * @param cls a `struct AccountContext *`
+ * @param row_id row of the account
+ * @param h_payto the account
+ * @param open_time when the account was opened
+ * @param close_time when it was closed
+ * @param comments file note on the account
+ * @param high_risk whether it is a high-risk relationship
+ * @param to_investigate whether it is flagged for investigation
+ * @param payto the account's payto URI
+ */
+static void
+account_cb (void *cls,
+ uint64_t row_id,
+ const struct TALER_NormalizedPaytoHashP *h_payto,
+ struct GNUNET_TIME_Timestamp open_time,
+ struct GNUNET_TIME_Timestamp close_time,
+ const char *comments,
+ bool high_risk,
+ bool to_investigate,
+ struct TALER_FullPayto payto)
+{
+ struct AccountContext *ctx = cls;
+
+ (void) open_time;
+ (void) close_time;
+ (void) comments;
+ if (0 == ctx->total++)
+ ctx->first_row = row_id;
+ if ( (NULL != ctx->h_payto) &&
+ (0 == GNUNET_memcmp (h_payto,
+ ctx->h_payto)) )
+ {
+ ctx->matched++;
+ ctx->to_investigate = to_investigate;
+ ctx->high_risk = high_risk;
+ ctx->have_payto = (NULL != payto.full_payto);
+ }
+}
+
+
+/**
+ * Nothing is known about an account the exchange never saw.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_NormalizedPaytoHashP h_payto;
+ struct TALER_AccountAccessTokenP access_token;
+ struct GNUNET_TIME_Absolute existing_lock;
+ struct AccountContext ctx = { 0 };
+ uint64_t kyc_target_row;
+ bool is_wallet;
+
+ TDB_FILL (h_payto,
+ 1);
+ TDB_FILL (access_token,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_aml_file_number (pg,
+ &h_payto,
+ &kyc_target_row,
+ &is_wallet));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_h_payto_by_access_token (pg,
+ &access_token,
+ &h_payto,
+ &is_wallet));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_to_aml_locked (pg,
+ &h_payto,
+ GNUNET_TIME_UNIT_HOURS,
+ &existing_lock));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_to_aml_unlocked (pg,
+ &h_payto));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_kyc_accounts (pg,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 10,
+ &account_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * A known account is found by its hash and by its access token.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AccountAccessTokenP access_token;
+ struct TALER_NormalizedPaytoHashP h_payto;
+ uint64_t kyc_target_row = 0;
+ bool is_wallet = true;
+
+ TDB_account (pg,
+ 10,
+ &account);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_aml_file_number (pg,
+ &account.h_normalized,
+ &kyc_target_row,
+ &is_wallet));
+ FAILIF (0 == kyc_target_row);
+ /* TDB_account() creates bank accounts, not wallet accounts */
+ FAILIF (is_wallet);
+
+ get_access_token (pg,
+ &account,
+ &access_token);
+ is_wallet = true;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_h_payto_by_access_token (pg,
+ &access_token,
+ &h_payto,
+ &is_wallet));
+ FAILIF (0 != GNUNET_memcmp (&h_payto,
+ &account.h_normalized));
+ FAILIF (is_wallet);
+
+ /* an access token nobody was given finds nothing */
+ {
+ struct TALER_AccountAccessTokenP other_token;
+
+ TDB_FILL (other_token,
+ 99);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_h_payto_by_access_token (pg,
+ &other_token,
+ &h_payto,
+ &is_wallet));
+ }
+ return 0;
+}
+
+
+/**
+ * Locking an account reports the lock to whoever tries next, and
+ * unlocking clears it.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_lock (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct GNUNET_TIME_Absolute existing_lock;
+
+ /* no lock yet */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_to_aml_locked (pg,
+ &account.h_normalized,
+ GNUNET_TIME_UNIT_HOURS,
+ &existing_lock));
+ FAILIF (! GNUNET_TIME_absolute_is_zero (existing_lock));
+
+ /* a second attempt is told when the first lock runs out */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_to_aml_locked (pg,
+ &account.h_normalized,
+ GNUNET_TIME_UNIT_HOURS,
+ &existing_lock));
+ FAILIF (GNUNET_TIME_absolute_is_zero (existing_lock));
+ FAILIF (GNUNET_TIME_absolute_is_past (existing_lock));
+
+ /* unlocking clears it */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_to_aml_unlocked (pg,
+ &account.h_normalized));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_to_aml_locked (pg,
+ &account.h_normalized,
+ GNUNET_TIME_UNIT_HOURS,
+ &existing_lock));
+ FAILIF (! GNUNET_TIME_absolute_is_zero (existing_lock));
+ /* unlocking an account that is not locked is fine */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_to_aml_unlocked (pg,
+ &account.h_normalized));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_to_aml_unlocked (pg,
+ &account.h_normalized));
+ return 0;
+}
+
+
+/**
+ * The account listing reports the accounts, with the filters applied.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct AccountContext ctx;
+ uint64_t lowest;
+
+ TDB_account (pg,
+ 11,
+ &other);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.h_payto = &account.h_normalized;
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_kyc_accounts (pg,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 10,
+ &account_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (! ctx.have_payto);
+ /* no AML decision has been taken, so neither flag is set */
+ FAILIF (ctx.to_investigate);
+ FAILIF (ctx.high_risk);
+ lowest = ctx.first_row;
+
+ /* the limit caps the result set */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_kyc_accounts (pg,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 1,
+ &account_cb,
+ &ctx));
+ FAILIF (lowest != ctx.first_row);
+
+ /* a negative limit walks the list the other way */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_kyc_accounts (pg,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ 1000,
+ -10,
+ &account_cb,
+ &ctx));
+ FAILIF (lowest == ctx.first_row);
+
+ /* nothing is under investigation... */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_kyc_accounts (pg,
+ TALER_EXCHANGE_YNA_YES,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 10,
+ &account_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ /* ...so everything comes back under "not under investigation" */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_kyc_accounts (pg,
+ TALER_EXCHANGE_YNA_NO,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 10,
+ &account_cb,
+ &ctx));
+
+ /* nothing is high risk either */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_kyc_accounts (pg,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_YES,
+ 0,
+ 10,
+ &account_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* and no account has an AML outcome, which is what "open" means here */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_kyc_accounts (pg,
+ TALER_EXCHANGE_YNA_ALL,
+ TALER_EXCHANGE_YNA_NO,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 10,
+ &account_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "kyc-targets-empty",
+ &check_empty },
+ { "kyc-targets-lookup",
+ &check_lookup },
+ { "kyc-targets-lock",
+ &check_lock },
+ { "kyc-targets-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-kyc-targets",
+ "Tests for the exchangedb `kyc_targets' table",
+ tests);
+ TDB_account_free (&account);
+ TDB_account_free (&other);
+ return ret;
+}
+
+
+/* end of test_kyc_targets.c */
diff --git a/src/exchangedb/test_kycauths_in.c b/src/exchangedb/test_kycauths_in.c
@@ -0,0 +1,622 @@
+/*
+ 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/test_kycauths_in.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `kycauths_in`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_import_credits() (for its KYC-auth half; the
+ * reserve half is in test_reserves_in.c),
+ * #TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account() and
+ * #TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers().
+ *
+ * A KYC-auth transfer carries an account public key in its wire subject.
+ * The import creates the `kyc_targets` and `wire_targets` rows for the
+ * debited account on first sight and only sets `target_pub` afterwards, so
+ * the checks import from the same account twice. The primary key is
+ * (account, wire reference), which is what makes a re-import a no-op.
+ */
+#include "test_common.h"
+#include "exchange-database/begin_shard.h"
+#include "exchange-database/do_import_credits.h"
+#include "exchange-database/iterate_exchange_kycauth_transfers.h"
+#include "exchange-database/iterate_kycauth_in_above_serial_id_by_account.h"
+
+
+/**
+ * Account the first two transfers come from.
+ */
+#define PAYTO_A "payto://x-taler-bank/localhost/ka-a?receiver-name=A"
+
+/**
+ * Account the third transfer comes from.
+ */
+#define PAYTO_B "payto://x-taler-bank/localhost/ka-b?receiver-name=B"
+
+
+/**
+ * Closure for #kycauth_cb().
+ */
+struct AuthContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Account key we are looking for, NULL to match nothing.
+ */
+ const union TALER_AccountPublicKeyP *account_pub;
+
+ /**
+ * How many times did we see @e account_pub?
+ */
+ unsigned int matched;
+
+ /**
+ * Credit reported for @e account_pub.
+ */
+ struct TALER_Amount credit;
+
+ /**
+ * Sender account reported for @e account_pub, owned by this struct.
+ */
+ char *sender;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account().
+ *
+ * @param cls a `struct AuthContext *`
+ * @param rowid row of the transfer
+ * @param account_pub key the transfer associated with the account
+ * @param credit amount that was transferred
+ * @param sender_account_details account the money came from
+ * @param wire_reference bank's identifier for the transfer
+ * @param execution_date when the transfer was made
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+kycauth_cb (void *cls,
+ uint64_t rowid,
+ const union TALER_AccountPublicKeyP *account_pub,
+ const struct TALER_Amount *credit,
+ const struct TALER_FullPayto sender_account_details,
+ uint64_t wire_reference,
+ struct GNUNET_TIME_Timestamp execution_date)
+{
+ struct AuthContext *ctx = cls;
+
+ (void) rowid;
+ (void) wire_reference;
+ (void) execution_date;
+ ctx->total++;
+ if ( (NULL != ctx->account_pub) &&
+ (0 == GNUNET_memcmp (account_pub,
+ ctx->account_pub)) )
+ {
+ ctx->matched++;
+ ctx->credit = *credit;
+ GNUNET_free (ctx->sender);
+ ctx->sender = (NULL == sender_account_details.full_payto)
+ ? NULL
+ : GNUNET_strdup (sender_account_details.full_payto);
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Closure for #transfer_cb().
+ */
+struct TransferContext
+{
+ /**
+ * How many transfers did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Row of the first transfer seen.
+ */
+ uint64_t first_row;
+
+ /**
+ * Sum of the whole-unit parts of the amounts seen.
+ */
+ uint64_t value_sum;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers().
+ *
+ * @param cls a `struct TransferContext *`
+ * @param row_id row of the transfer
+ * @param payto_uri account the money came from
+ * @param execution_time when the transfer was made
+ * @param amount amount that was transferred
+ */
+static void
+transfer_cb (void *cls,
+ uint64_t row_id,
+ const char *payto_uri,
+ struct GNUNET_TIME_Absolute execution_time,
+ const struct TALER_Amount *amount)
+{
+ struct TransferContext *ctx = cls;
+
+ (void) payto_uri;
+ (void) execution_time;
+ if (0 == ctx->total++)
+ ctx->first_row = row_id;
+ ctx->value_sum += amount->value;
+}
+
+
+/**
+ * Import one KYC authentication transfer, advancing a work shard of its
+ * own along with it.
+ *
+ * @param pg the database context
+ * @param job_name job whose shard to open and advance
+ * @param account_name exchange bank account the transfer arrived at
+ * @param payto account the money came from
+ * @param seed seed for the account public key
+ * @param wire_reference bank's identifier for the transfer
+ * @param amount amount that was transferred, e.g. "10"
+ * @param[out] account_pub set to the key the transfer carried
+ * @return transaction status of the import
+ */
+static enum GNUNET_DB_QueryStatus
+import_one (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const char *job_name,
+ const char *account_name,
+ const char *payto,
+ uint32_t seed,
+ uint64_t wire_reference,
+ const char *amount,
+ union TALER_AccountPublicKeyP *account_pub)
+{
+ struct TALER_Amount balance = TDB_amount (amount);
+ struct TALER_EXCHANGEDB_KycauthInInfo info;
+ struct TALER_EXCHANGEDB_CreditBatch batch;
+ uint64_t start;
+ uint64_t end;
+ uint64_t progress;
+
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_begin_shard (pg,
+ job_name,
+ GNUNET_TIME_UNIT_HOURS,
+ 1024,
+ &start,
+ &end,
+ &progress));
+ TDB_fill (account_pub,
+ sizeof (*account_pub),
+ seed);
+ memset (&info,
+ 0,
+ sizeof (info));
+ info.account_pub = account_pub;
+ info.balance = &balance;
+ info.execution_time = GNUNET_TIME_timestamp_get ();
+ info.sender_account_details.full_payto = (char *) payto;
+ info.wire_reference = wire_reference;
+ memset (&batch,
+ 0,
+ sizeof (batch));
+ batch.exchange_account_name = account_name;
+ batch.kycauths = &info;
+ batch.kycauths_length = 1;
+ batch.job_name = job_name;
+ batch.shard_start = start;
+ batch.shard_end = end;
+ batch.progress_row = end;
+ batch.lease = GNUNET_TIME_UNIT_HOURS;
+ return TALER_EXCHANGEDB_do_import_credits (pg,
+ &batch,
+ NULL);
+}
+
+
+/**
+ * Nothing is known while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct AuthContext ctx = { 0 };
+ struct TransferContext tctx = { 0 };
+ struct TALER_Amount zero = TDB_amount ("0");
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account (
+ pg,
+ "exchange-account-1",
+ 0,
+ &kycauth_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg,
+ &zero,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &tctx));
+ FAILIF (0 != ctx.total);
+ FAILIF (0 != tctx.total);
+ FAILIF (0 != TDB_count (pg,
+ "FROM kycauths_in"));
+ return 0;
+}
+
+
+/**
+ * Importing a transfer registers the account key and creates the account
+ * rows; importing it again is a no-op.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_import (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ union TALER_AccountPublicKeyP account_pub;
+ struct AuthContext ctx;
+ struct TALER_Amount expect = TDB_amount ("10");
+ char *hex;
+
+ FAILIF (0 >
+ import_one (pg,
+ "ka-job-a",
+ "exchange-account-1",
+ PAYTO_A,
+ 10,
+ 1,
+ "10",
+ &account_pub));
+ FAILIF (1 != TDB_count (pg,
+ "FROM kycauths_in"));
+ /* the account is now known and carries the key from the wire subject */
+ hex = TDB_hex (&account_pub,
+ sizeof (account_pub));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM kyc_targets"
+ " WHERE target_pub=decode('%s','hex')",
+ hex),
+ GNUNET_free (hex));
+ GNUNET_free (hex);
+ FAILIF (1 != TDB_count (pg,
+ "FROM wire_targets"));
+
+ /* the same transfer again: the primary key is (account, wire reference) */
+ FAILIF (0 >
+ import_one (pg,
+ "ka-job-a",
+ "exchange-account-1",
+ PAYTO_A,
+ 10,
+ 1,
+ "10",
+ &account_pub));
+ FAILIF (1 != TDB_count (pg,
+ "FROM kycauths_in"));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.account_pub = &account_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account (
+ pg,
+ "exchange-account-1",
+ 0,
+ &kycauth_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.matched,
+ GNUNET_free (ctx.sender));
+ FAILIF_C (0 != TALER_amount_cmp (&ctx.credit,
+ &expect),
+ GNUNET_free (ctx.sender));
+ FAILIF_C (0 != strcmp (ctx.sender,
+ PAYTO_A),
+ GNUNET_free (ctx.sender));
+ GNUNET_free (ctx.sender);
+ return 0;
+}
+
+
+/**
+ * With several transfers on file the by-account iterator filters by the
+ * exchange bank account and honours an aborting callback.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ union TALER_AccountPublicKeyP a2;
+ union TALER_AccountPublicKeyP a3;
+ struct AuthContext ctx;
+
+ /* a second transfer from the same account, replacing the key */
+ FAILIF (0 >
+ import_one (pg,
+ "ka-job-a",
+ "exchange-account-1",
+ PAYTO_A,
+ 11,
+ 2,
+ "2",
+ &a2));
+ /* and one from a different account to a different exchange account */
+ FAILIF (0 >
+ import_one (pg,
+ "ka-job-b",
+ "exchange-account-2",
+ PAYTO_B,
+ 12,
+ 3,
+ "5",
+ &a3));
+ FAILIF (3 != TDB_count (pg,
+ "FROM kycauths_in"));
+ /* the second transfer from PAYTO_A took over its target_pub */
+ {
+ char *hex = TDB_hex (&a2,
+ sizeof (a2));
+
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM kyc_targets"
+ " WHERE target_pub=decode('%s','hex')",
+ hex),
+ GNUNET_free (hex));
+ GNUNET_free (hex);
+ }
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.account_pub = &a3;
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account (
+ pg,
+ "exchange-account-1",
+ 0,
+ &kycauth_cb,
+ &ctx));
+ FAILIF_C (0 != ctx.matched,
+ GNUNET_free (ctx.sender));
+ GNUNET_free (ctx.sender);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.account_pub = &a3;
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account (
+ pg,
+ "exchange-account-2",
+ 0,
+ &kycauth_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.matched,
+ GNUNET_free (ctx.sender));
+ FAILIF_C (0 != strcmp (ctx.sender,
+ PAYTO_B),
+ GNUNET_free (ctx.sender));
+ GNUNET_free (ctx.sender);
+
+ /* an exchange account nobody wired to */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account (
+ pg,
+ "exchange-account-nowhere",
+ 0,
+ &kycauth_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* the serial bound is inclusive, so a bound past the end finds nothing */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account (
+ pg,
+ "exchange-account-1",
+ 1000,
+ &kycauth_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* a callback that gives up stops the iteration without erroring out */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_kycauth_in_above_serial_id_by_account (
+ pg,
+ "exchange-account-1",
+ 0,
+ &kycauth_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ GNUNET_free (ctx.sender);
+ return 0;
+}
+
+
+/**
+ * The AML view of the same rows filters by amount and by account, and can
+ * walk the table backwards.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_kycauth_transfers (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_NormalizedPayto npayto;
+ struct TALER_NormalizedPaytoHashP h_payto;
+ struct TALER_FullPayto payto_a = {
+ .full_payto = (char *) PAYTO_A
+ };
+ struct TALER_Amount zero = TDB_amount ("0");
+ struct TALER_Amount five = TDB_amount ("5");
+ struct TALER_Amount huge = TDB_amount ("1000");
+ struct TransferContext ctx;
+ uint64_t lowest;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg,
+ &zero,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (10 + 2 + 5 != ctx.value_sum);
+ lowest = ctx.first_row;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg,
+ &five,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (10 + 5 != ctx.value_sum);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg,
+ &huge,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ npayto = TALER_payto_normalize (payto_a);
+ GNUNET_assert (NULL != npayto.normalized_payto);
+ TALER_normalized_payto_hash (npayto,
+ &h_payto);
+ GNUNET_free (npayto.normalized_payto);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg,
+ &zero,
+ 0,
+ 10,
+ &h_payto,
+ &transfer_cb,
+ &ctx));
+ FAILIF (10 + 2 != ctx.value_sum);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg,
+ &zero,
+ 0,
+ 1,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (lowest != ctx.first_row);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_exchange_kycauth_transfers (pg,
+ &zero,
+ 1000,
+ -10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (lowest == ctx.first_row);
+ FAILIF (10 + 2 + 5 != ctx.value_sum);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "kycauths-in-empty",
+ &check_empty },
+ { "kycauths-in-import",
+ &check_import },
+ { "kycauths-in-iterate",
+ &check_iterate },
+ { "kycauths-in-transfers",
+ &check_kycauth_transfers },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-kycauths-in",
+ "Tests for the exchangedb `kycauths_in' table",
+ tests);
+}
+
+
+/* end of test_kycauths_in.c */
diff --git a/src/exchangedb/test_legitimization_measures.c b/src/exchangedb/test_legitimization_measures.c
@@ -0,0 +1,838 @@
+/*
+ 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/test_legitimization_measures.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `legitimization_measures`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_trigger_kyc_rule_for_account(),
+ * #TALER_EXCHANGEDB_insert_active_legitimization_measure(),
+ * #TALER_EXCHANGEDB_insert_successor_measure(),
+ * #TALER_EXCHANGEDB_get_pending_legitimization(),
+ * #TALER_EXCHANGEDB_get_active_legitimization(),
+ * #TALER_EXCHANGEDB_get_completed_legitimization(),
+ * #TALER_EXCHANGEDB_get_kyc_status_by_token(),
+ * #TALER_EXCHANGEDB_get_open_legitimization_measure(),
+ * #TALER_EXCHANGEDB_get_legitimization_requirement_by_row() and
+ * #TALER_EXCHANGEDB_iterate_aml_measures().
+ *
+ * A row of `legitimization_measures` is one set of KYC measures the
+ * exchange put on an account. It is keyed by the account's access token,
+ * so `kyc_targets` has to exist first -- do_trigger_kyc_rule_for_account()
+ * creates it when it has to. Only one measure set per account is active
+ * at a time: triggering a different set finishes the previous one.
+ */
+#include "test_common.h"
+#include "exchange-database/do_trigger_kyc_rule_for_account.h"
+#include "exchange-database/get_active_legitimization.h"
+#include "exchange-database/get_completed_legitimization.h"
+#include "exchange-database/get_kyc_status_by_token.h"
+#include "exchange-database/get_legitimization_requirement_by_row.h"
+#include "exchange-database/get_open_legitimization_measure.h"
+#include "exchange-database/get_pending_legitimization.h"
+#include "exchange-database/insert_active_legitimization_measure.h"
+#include "exchange-database/insert_legitimization_process.h"
+#include "exchange-database/insert_successor_measure.h"
+#include "exchange-database/iterate_aml_measures.h"
+
+
+/**
+ * Account the checks put measures on.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Row of the measure set that check_trigger() created.
+ */
+static uint64_t measure_row;
+
+
+/**
+ * Key the account may use to authorise KYC work on itself.
+ */
+static union TALER_AccountPublicKeyP account_pub;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Build a measure set naming @a check.
+ *
+ * @param check name of the check to demand
+ * @return the measure set, to be freed with json_decref()
+ */
+static json_t *
+make_measures (const char *check)
+{
+ json_t *j;
+
+ j = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_array_steal (
+ "measures",
+ json_pack ("[{s:s,s:s,s:s}]",
+ "check_name",
+ check,
+ "prog_name",
+ "skip",
+ "context",
+ "none")),
+ GNUNET_JSON_pack_bool ("is_and_combinator",
+ true));
+ GNUNET_assert (NULL != j);
+ return j;
+}
+
+
+/**
+ * Read the access token the database generated for our account.
+ *
+ * @param pg the database context
+ * @param[out] access_token set to the account's access token
+ */
+static void
+get_access_token (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ struct TALER_AccountAccessTokenP *access_token)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (&account.h_normalized),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_auto_from_type ("access_token",
+ access_token),
+ GNUNET_PQ_result_spec_end
+ };
+
+ GNUNET_assert (GNUNET_OK ==
+ GNUNET_PQ_prepare_anon (pg->conn,
+ "SELECT access_token"
+ " FROM kyc_targets"
+ " WHERE h_normalized_payto=$1;"));
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs));
+}
+
+
+/**
+ * Closure for #measure_cb().
+ */
+struct MeasureContext
+{
+ /**
+ * How many measures did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Row we are looking for, 0 to match nothing.
+ */
+ uint64_t measure_row;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Was it reported as finished?
+ */
+ bool is_finished;
+
+ /**
+ * Account it was reported for.
+ */
+ struct TALER_NormalizedPaytoHashP h_payto;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_aml_measures().
+ *
+ * @param cls a `struct MeasureContext *`
+ * @param h_payto account the measure applies to
+ * @param start_time when the measure was imposed
+ * @param jmeasures the measures
+ * @param is_finished whether the measure was completed
+ * @param measure_serial_id row of the measure
+ */
+static void
+measure_cb (void *cls,
+ struct TALER_NormalizedPaytoHashP *h_payto,
+ struct GNUNET_TIME_Absolute start_time,
+ const json_t *jmeasures,
+ bool is_finished,
+ uint64_t measure_serial_id)
+{
+ struct MeasureContext *ctx = cls;
+
+ (void) start_time;
+ (void) jmeasures;
+ ctx->total++;
+ if ( (0 != ctx->measure_row) &&
+ (measure_serial_id == ctx->measure_row) )
+ {
+ ctx->matched++;
+ ctx->is_finished = is_finished;
+ ctx->h_payto = *h_payto;
+ }
+}
+
+
+/**
+ * Nothing is known while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_NormalizedPaytoHashP h_payto;
+ struct TALER_AccountAccessTokenP access_token;
+ struct GNUNET_TIME_Absolute start_time;
+ struct MeasureContext ctx = { 0 };
+ json_t *jmeasures = NULL;
+ uint64_t row;
+ bool is_finished;
+ bool is_wallet;
+
+ TDB_FILL (h_payto,
+ 1);
+ TDB_FILL (access_token,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_pending_legitimization (pg,
+ 1,
+ &access_token,
+ &h_payto,
+ &jmeasures,
+ &is_finished,
+ &is_wallet));
+ FAILIF (NULL != jmeasures);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_kyc_status_by_token (pg,
+ &access_token,
+ &row,
+ &jmeasures));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_open_legitimization_measure (pg,
+ &h_payto,
+ &row,
+ &start_time));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_measures (pg,
+ NULL,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 10,
+ &measure_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * Triggering a rule creates the account and the measure set.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_trigger (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_NormalizedPayto npayto;
+ struct TALER_FullPayto payto;
+ struct TALER_NormalizedPaytoHashP h_payto;
+ struct TALER_AccountAccessTokenP access_token;
+ struct TALER_AccountAccessTokenP got_token;
+ struct TALER_NormalizedPaytoHashP got_payto;
+ json_t *jmeasures = make_measures ("verify-id");
+ json_t *got = NULL;
+ uint64_t row = 0;
+ bool bad_kyc_auth = false;
+ bool is_finished = true;
+ bool is_wallet = true;
+
+ payto.full_payto = (char *)
+ "payto://x-taler-bank/localhost/lm?receiver-name=LM";
+ TALER_full_payto_hash (payto,
+ &account.h_full);
+ npayto = TALER_payto_normalize (payto);
+ GNUNET_assert (NULL != npayto.normalized_payto);
+ TALER_normalized_payto_hash (npayto,
+ &h_payto);
+ GNUNET_free (npayto.normalized_payto);
+ account.payto.full_payto = GNUNET_strdup (payto.full_payto);
+ account.h_normalized = h_payto;
+
+ TDB_FILL (account_pub,
+ 42);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_trigger_kyc_rule_for_account (
+ pg,
+ payto,
+ &h_payto,
+ &account_pub,
+ NULL,
+ jmeasures,
+ 1,
+ &row,
+ &bad_kyc_auth),
+ json_decref (jmeasures));
+ FAILIF_C (0 == row,
+ json_decref (jmeasures));
+ /* The account was not known, so there was no key to match against; the
+ function reports that even though it just set one. */
+ FAILIF_C (! bad_kyc_auth,
+ json_decref (jmeasures));
+ measure_row = row;
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM legitimization_measures"),
+ json_decref (jmeasures));
+ /* the account was created along the way */
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM kyc_targets"),
+ json_decref (jmeasures));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM wire_targets"),
+ json_decref (jmeasures));
+
+ /* the measure set can be read back by its row... */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_pending_legitimization (pg,
+ row,
+ &got_token,
+ &got_payto,
+ &got,
+ &is_finished,
+ &is_wallet),
+ json_decref (jmeasures));
+ FAILIF_C (0 != GNUNET_memcmp (&got_payto,
+ &h_payto),
+ json_decref (got); json_decref (jmeasures));
+ FAILIF_C (is_finished,
+ json_decref (got); json_decref (jmeasures));
+ FAILIF_C (is_wallet,
+ json_decref (got); json_decref (jmeasures));
+ FAILIF_C (1 != json_equal (got,
+ jmeasures),
+ json_decref (got); json_decref (jmeasures));
+ json_decref (got);
+ got = NULL;
+
+ /* ...and by the account's access token */
+ get_access_token (pg,
+ &access_token);
+ FAILIF_C (0 != GNUNET_memcmp (&got_token,
+ &access_token),
+ json_decref (jmeasures));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_kyc_status_by_token (pg,
+ &access_token,
+ &row,
+ &got),
+ json_decref (jmeasures));
+ FAILIF_C (row != measure_row,
+ json_decref (got); json_decref (jmeasures));
+ json_decref (got);
+
+ /* triggering the very same measure set again keeps the one row */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_trigger_kyc_rule_for_account (
+ pg,
+ payto,
+ &h_payto,
+ NULL,
+ NULL,
+ jmeasures,
+ 1,
+ &row,
+ &bad_kyc_auth),
+ json_decref (jmeasures));
+ FAILIF_C (row != measure_row,
+ json_decref (jmeasures));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM legitimization_measures"),
+ json_decref (jmeasures));
+ json_decref (jmeasures);
+ return 0;
+}
+
+
+/**
+ * A different measure set supersedes the previous one, which is marked
+ * finished.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_supersede (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ json_t *jmeasures = make_measures ("verify-address");
+ uint64_t row = 0;
+ bool bad_kyc_auth = false;
+
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_trigger_kyc_rule_for_account (
+ pg,
+ account.payto,
+ &account.h_normalized,
+ NULL,
+ NULL,
+ jmeasures,
+ 2,
+ &row,
+ &bad_kyc_auth),
+ json_decref (jmeasures));
+ json_decref (jmeasures);
+ FAILIF (row == measure_row);
+ FAILIF (2 != TDB_count (pg,
+ "FROM legitimization_measures"));
+ FAILIF (1 != TDB_count (pg,
+ "FROM legitimization_measures"
+ " WHERE is_finished"));
+ measure_row = row;
+ return 0;
+}
+
+
+/**
+ * The account's rule requirement and its open measure are reported.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_requirement (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AccountAccessTokenP access_token;
+ struct TALER_AccountAccessTokenP got_token;
+ struct GNUNET_TIME_Absolute start_time;
+ enum GNUNET_GenericReturnValue is_wallet;
+ json_t *jrules = NULL;
+ uint64_t rule_gen = 42;
+ uint64_t row = 0;
+ bool aml_review = true;
+ bool kyc_required = false;
+
+ get_access_token (pg,
+ &access_token);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_legitimization_requirement_by_row (
+ pg,
+ &account.h_normalized,
+ &account_pub,
+ &is_wallet,
+ &got_token,
+ &rule_gen,
+ &jrules,
+ &aml_review,
+ &kyc_required));
+ FAILIF_C (0 != GNUNET_memcmp (&got_token,
+ &access_token),
+ json_decref (jrules));
+ FAILIF_C (GNUNET_NO != is_wallet,
+ json_decref (jrules));
+ /* no AML decision has been taken for the account */
+ FAILIF_C (aml_review,
+ json_decref (jrules));
+ FAILIF_C (0 != rule_gen,
+ json_decref (jrules));
+ /* ...but a measure is pending, so KYC is required */
+ FAILIF_C (! kyc_required,
+ json_decref (jrules));
+ json_decref (jrules);
+
+ /* the exchange's own account of why it is holding back */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_open_legitimization_measure (
+ pg,
+ &account.h_normalized,
+ &row,
+ &start_time));
+ /* the oldest undecided measure is the one from check_trigger() */
+ FAILIF (0 == row);
+ FAILIF (GNUNET_TIME_absolute_is_zero (start_time));
+
+ /* an account nobody put a measure on has none */
+ {
+ struct TALER_NormalizedPaytoHashP other;
+
+ TDB_FILL (other,
+ 98);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_open_legitimization_measure (pg,
+ &other,
+ &row,
+ &start_time));
+ }
+ return 0;
+}
+
+
+/**
+ * A legitimization process ties a measure set to the check it is
+ * satisfying.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_process (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AccountAccessTokenP access_token;
+ struct TALER_NormalizedPaytoHashP h_payto;
+ json_t *jmeasures = NULL;
+ size_t attributes_len = 42;
+ void *attributes = NULL;
+ uint64_t process_row = 0;
+ uint32_t measure_index = 99;
+ bool is_finished = true;
+ bool is_wallet = true;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_legitimization_process (
+ pg,
+ &account.h_normalized,
+ 0,
+ measure_row,
+ "test-provider",
+ "provider-account-1",
+ "provider-legi-1",
+ &process_row));
+ FAILIF (0 == process_row);
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_active_legitimization (pg,
+ process_row,
+ &measure_index,
+ &jmeasures));
+ FAILIF_C (0 != measure_index,
+ json_decref (jmeasures));
+ FAILIF_C (NULL == jmeasures,
+ json_decref (jmeasures));
+ json_decref (jmeasures);
+ jmeasures = NULL;
+
+ /* a process that does not exist has no active measure */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_active_legitimization (pg,
+ process_row + 1000,
+ &measure_index,
+ &jmeasures));
+
+ /* the completed view reaches the same measure through the process */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_completed_legitimization (pg,
+ measure_row,
+ 0,
+ &access_token,
+ &h_payto,
+ &is_wallet,
+ &jmeasures,
+ &is_finished,
+ &attributes_len,
+ &attributes));
+ FAILIF_C (0 != GNUNET_memcmp (&h_payto,
+ &account.h_normalized),
+ json_decref (jmeasures); GNUNET_free (attributes));
+ FAILIF_C (is_finished,
+ json_decref (jmeasures); GNUNET_free (attributes));
+ /* no attributes have been collected yet */
+ FAILIF_C (NULL != attributes,
+ json_decref (jmeasures); GNUNET_free (attributes));
+ json_decref (jmeasures);
+ jmeasures = NULL;
+
+ /* a measure index no process is working on is not found */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_completed_legitimization (pg,
+ measure_row,
+ 7,
+ &access_token,
+ &h_payto,
+ &is_wallet,
+ &jmeasures,
+ &is_finished,
+ &attributes_len,
+ &attributes));
+ return 0;
+}
+
+
+/**
+ * A measure can also be created directly against an access token.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_active (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AccountAccessTokenP access_token;
+ json_t *jmeasures = make_measures ("verify-phone");
+ uint64_t row = 0;
+
+ get_access_token (pg,
+ &access_token);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_active_legitimization_measure (
+ pg,
+ &access_token,
+ jmeasures,
+ &row),
+ json_decref (jmeasures));
+ json_decref (jmeasures);
+ FAILIF (0 == row);
+ FAILIF (3 != TDB_count (pg,
+ "FROM legitimization_measures"));
+ /* it becomes the one active measure for the account */
+ FAILIF (1 != TDB_count (pg,
+ "FROM legitimization_measures"
+ " WHERE NOT is_finished"));
+ measure_row = row;
+
+ return 0;
+}
+
+
+/**
+ * A successor measure replaces what is active for the account.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_successor (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct GNUNET_TIME_Timestamp last_date;
+ json_t *jmeasures = make_measures ("verify-income");
+ bool unknown_account = true;
+
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_successor_measure (
+ pg,
+ &account.h_normalized,
+ ts (1600000000),
+ "verify-income",
+ jmeasures,
+ &unknown_account,
+ &last_date),
+ json_decref (jmeasures));
+ FAILIF_C (unknown_account,
+ json_decref (jmeasures));
+ FAILIF_C (4 != TDB_count (pg,
+ "FROM legitimization_measures"),
+ json_decref (jmeasures));
+
+ /* an older decision is ignored */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_successor_measure (
+ pg,
+ &account.h_normalized,
+ ts (1500000000),
+ "verify-income",
+ jmeasures,
+ &unknown_account,
+ &last_date),
+ json_decref (jmeasures));
+ FAILIF_C (GNUNET_TIME_timestamp_cmp (last_date,
+ !=,
+ ts (1600000000)),
+ json_decref (jmeasures));
+ FAILIF_C (4 != TDB_count (pg,
+ "FROM legitimization_measures"),
+ json_decref (jmeasures));
+
+ /* an account the exchange never saw is reported as unknown */
+ {
+ struct TALER_NormalizedPaytoHashP other;
+
+ TDB_FILL (other,
+ 95);
+ unknown_account = false;
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_successor_measure (
+ pg,
+ &other,
+ ts (1600000000),
+ "verify-income",
+ jmeasures,
+ &unknown_account,
+ &last_date),
+ json_decref (jmeasures));
+ FAILIF_C (! unknown_account,
+ json_decref (jmeasures));
+ }
+ json_decref (jmeasures);
+ return 0;
+}
+
+
+/**
+ * The measure listing reports the account's measures, filtered.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct MeasureContext ctx;
+ unsigned int all;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_measures (pg,
+ &account.h_normalized,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 100,
+ &measure_cb,
+ &ctx));
+ all = ctx.total;
+ FAILIF (0 == all);
+
+ /* the account filter matters */
+ {
+ struct TALER_NormalizedPaytoHashP other;
+
+ TDB_FILL (other,
+ 94);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aml_measures (pg,
+ &other,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 100,
+ &measure_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ }
+
+ /* only one measure is still open */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_measures (pg,
+ &account.h_normalized,
+ TALER_EXCHANGE_YNA_YES,
+ 0,
+ 100,
+ &measure_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+
+ /* the finished ones make up the rest */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_aml_measures (pg,
+ &account.h_normalized,
+ TALER_EXCHANGE_YNA_NO,
+ 0,
+ 100,
+ &measure_cb,
+ &ctx));
+ FAILIF (all - 1 != ctx.total);
+
+ /* the limit caps the result set */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_aml_measures (pg,
+ NULL,
+ TALER_EXCHANGE_YNA_ALL,
+ 0,
+ 1,
+ &measure_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "legitimization-measures-empty",
+ &check_empty },
+ { "legitimization-measures-trigger",
+ &check_trigger },
+ { "legitimization-measures-supersede",
+ &check_supersede },
+ { "legitimization-measures-requirement",
+ &check_requirement },
+ { "legitimization-measures-process",
+ &check_process },
+ { "legitimization-measures-insert-active",
+ &check_insert_active },
+ { "legitimization-measures-successor",
+ &check_successor },
+ { "legitimization-measures-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-legitimization-measures",
+ "Tests for the exchangedb `legitimization_measures' table",
+ tests);
+ TDB_account_free (&account);
+ return ret;
+}
+
+
+/* end of test_legitimization_measures.c */
diff --git a/src/exchangedb/test_legitimization_outcomes.c b/src/exchangedb/test_legitimization_outcomes.c
@@ -0,0 +1,542 @@
+/*
+ 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/test_legitimization_outcomes.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `legitimization_outcomes`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_get_kyc_rules(),
+ * #TALER_EXCHANGEDB_get_kyc_rules_with_account(),
+ * #TALER_EXCHANGEDB_get_rules_by_access_token(),
+ * #TALER_EXCHANGEDB_insert_sanction_list_hit(),
+ * #TALER_EXCHANGEDB_do_persist_aml_program_result(),
+ * #TALER_EXCHANGEDB_current_rule_builder(),
+ * #TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id() and,
+ * as far as a synchronous test can, #TALER_EXCHANGEDB_begin_rule_update()
+ * and #TALER_EXCHANGEDB_begin_rule_update_cancel().
+ *
+ * A row of `legitimization_outcomes` is one decision about an account: the
+ * rule set that applies to it from then on. Only one row per account is
+ * active at a time; writing a new one deactivates the old.
+ *
+ * begin_rule_update() answers through the scheduler, so a check that runs
+ * inside one scheduler task cannot wait for it. What is checked here is
+ * that it starts and that cancelling it releases everything.
+ */
+#include "test_common.h"
+#include "exchange-database/account_history.h"
+#include "exchange-database/begin_rule_update.h"
+#include "exchange-database/do_persist_aml_program_result.h"
+#include "exchange-database/do_trigger_kyc_rule_for_account.h"
+#include "exchange-database/get_kyc_rules.h"
+#include "exchange-database/get_rules_by_access_token.h"
+#include "exchange-database/insert_sanction_list_hit.h"
+#include "exchange-database/iterate_legitimization_outcomes_above_serial_id.h"
+
+
+/**
+ * Account the checks decide about.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Build a rule set naming @a name as its only rule.
+ *
+ * @param name name of the rule
+ * @return the rule set, to be freed with json_decref()
+ */
+static json_t *
+make_rules (const char *name)
+{
+ json_t *j;
+
+ j = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("rule_name",
+ name),
+ GNUNET_JSON_pack_array_steal ("rules",
+ json_array ()));
+ GNUNET_assert (NULL != j);
+ return j;
+}
+
+
+/**
+ * Closure for #outcome_cb().
+ */
+struct OutcomeContext
+{
+ /**
+ * How many outcomes did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Account we are looking for, NULL to match nothing.
+ */
+ const struct TALER_NormalizedPaytoHashP *h_payto;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Whether the last matching outcome had an AML officer behind it.
+ */
+ bool has_aml_decision;
+
+ /**
+ * Whether it had a legitimization process behind it.
+ */
+ bool has_legitimization_process;
+
+ /**
+ * Whether an earlier outcome had expired by then.
+ */
+ bool has_expired_predecessor;
+};
+
+
+/**
+ * Callback for
+ * #TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id().
+ *
+ * @param cls a `struct OutcomeContext *`
+ * @param rowid row of the outcome
+ * @param h_payto account the outcome is about
+ * @param decision_time when it was decided
+ * @param expiration_time when it expires
+ * @param has_aml_decision whether an AML officer signed it
+ * @param has_legitimization_process whether a KYC process preceded it
+ * @param has_expired_predecessor whether it succeeded an expired outcome
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+outcome_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_NormalizedPaytoHashP *h_payto,
+ struct GNUNET_TIME_Timestamp decision_time,
+ struct GNUNET_TIME_Timestamp expiration_time,
+ bool has_aml_decision,
+ bool has_legitimization_process,
+ bool has_expired_predecessor)
+{
+ struct OutcomeContext *ctx = cls;
+
+ (void) rowid;
+ (void) decision_time;
+ (void) expiration_time;
+ ctx->total++;
+ if ( (NULL != ctx->h_payto) &&
+ (0 == GNUNET_memcmp (h_payto,
+ ctx->h_payto)) )
+ {
+ ctx->matched++;
+ ctx->has_aml_decision = has_aml_decision;
+ ctx->has_legitimization_process = has_legitimization_process;
+ ctx->has_expired_predecessor = has_expired_predecessor;
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_begin_rule_update(), which a
+ * synchronous check never gets to see.
+ *
+ * @param cls closure
+ * @param rur the rule set that was worked out
+ */
+static void
+rules_cb (void *cls,
+ struct TALER_EXCHANGEDB_RuleUpdaterResult *rur)
+{
+ (void) cls;
+ (void) rur;
+ GNUNET_break (0);
+}
+
+
+/**
+ * An account nobody decided about has no custom rules.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_NormalizedPaytoHashP h_payto;
+ struct OutcomeContext ctx = { 0 };
+ json_t *jrules = NULL;
+ uint64_t rowid;
+
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_FILL (h_payto,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_kyc_rules (pg,
+ &h_payto,
+ &jrules));
+ FAILIF (NULL != jrules);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_rules_by_access_token (pg,
+ &h_payto,
+ &jrules,
+ &rowid));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id (
+ pg,
+ 0,
+ &outcome_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ /* an account that exists but has no decision has none either */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_rules_by_access_token (pg,
+ &account.h_normalized,
+ &jrules,
+ &rowid));
+ return 0;
+}
+
+
+/**
+ * An AML program result is persisted as an outcome, and the rules it set
+ * become the account's active rules.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_persist (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_KYCLOGIC_AmlProgramResult apr;
+ enum TALER_EXCHANGEDB_PersistProgramResultStatus pprs;
+ json_t *new_rules = make_rules ("strict");
+ json_t *properties = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_string ("FILE_NOTE",
+ "checked by hand"));
+ json_t *got = NULL;
+ uint64_t rowid = 0;
+
+ GNUNET_assert (NULL != properties);
+ memset (&apr,
+ 0,
+ sizeof (apr));
+ apr.status = TALER_KYCLOGIC_AMLR_SUCCESS;
+ apr.details.success.account_properties = properties;
+ apr.details.success.new_rules = new_rules;
+ apr.details.success.expiration_time
+ = GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS);
+ apr.details.success.to_investigate = true;
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_persist_aml_program_result (
+ pg,
+ 0,
+ &account.h_normalized,
+ &apr,
+ &pprs),
+ json_decref (new_rules); json_decref (properties));
+ FAILIF_C (TALER_EXCHANGEDB_PPRS_OK != pprs,
+ json_decref (new_rules); json_decref (properties));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM legitimization_outcomes"),
+ json_decref (new_rules); json_decref (properties));
+
+ /* the rules are now the account's active rules */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_kyc_rules (pg,
+ &account.h_normalized,
+ &got),
+ json_decref (new_rules); json_decref (properties));
+ FAILIF_C (1 != json_equal (got,
+ new_rules),
+ json_decref (got);
+ json_decref (new_rules); json_decref (properties));
+ json_decref (got);
+ got = NULL;
+
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_rules_by_access_token (
+ pg,
+ &account.h_normalized,
+ &got,
+ &rowid),
+ json_decref (new_rules); json_decref (properties));
+ FAILIF_C (0 == rowid,
+ json_decref (got);
+ json_decref (new_rules); json_decref (properties));
+ json_decref (got);
+ json_decref (new_rules);
+ json_decref (properties);
+ return 0;
+}
+
+
+/**
+ * The account-aware rule lookup also reports the keys that may act for
+ * the account.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_rules_with_account (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ union TALER_AccountPublicKeyP account_pub;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ json_t *jrules = NULL;
+ bool no_account_pub = false;
+ bool no_reserve_pub = false;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_kyc_rules_with_account (pg,
+ &account.h_normalized,
+ NULL,
+ &no_account_pub,
+ &account_pub,
+ &no_reserve_pub,
+ &reserve_pub,
+ &jrules));
+ FAILIF_C (NULL == jrules,
+ json_decref (jrules));
+ json_decref (jrules);
+ /* nobody ever registered a key for this account */
+ FAILIF (! no_account_pub);
+ FAILIF (! no_reserve_pub);
+ return 0;
+}
+
+
+/**
+ * A sanction list hit replaces the account's rules and flags it.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_sanction_hit (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ json_t *new_rules = make_rules ("frozen");
+ json_t *properties = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_bool ("HIGH_RISK_CUSTOMER",
+ true));
+ json_t *got = NULL;
+ const char *events[] = { "sanction-hit" };
+
+ GNUNET_assert (NULL != properties);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_sanction_list_hit (
+ pg,
+ &account.h_normalized,
+ true,
+ new_rules,
+ properties,
+ 1,
+ events),
+ json_decref (new_rules); json_decref (properties));
+ /* the previous outcome was deactivated, not deleted */
+ FAILIF_C (2 != TDB_count (pg,
+ "FROM legitimization_outcomes"),
+ json_decref (new_rules); json_decref (properties));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM legitimization_outcomes"
+ " WHERE is_active"),
+ json_decref (new_rules); json_decref (properties));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_kyc_rules (pg,
+ &account.h_normalized,
+ &got),
+ json_decref (new_rules); json_decref (properties));
+ FAILIF_C (1 != json_equal (got,
+ new_rules),
+ json_decref (got);
+ json_decref (new_rules); json_decref (properties));
+ json_decref (got);
+ json_decref (new_rules);
+ json_decref (properties);
+ /* the events the hit asked for were recorded */
+ FAILIF (1 != TDB_count (pg,
+ "FROM kyc_events"
+ " WHERE event_type='sanction-hit'"));
+ return 0;
+}
+
+
+/**
+ * The rule builder renders the account's active rules as JSON.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_rule_builder (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AttributeEncryptionKeyP attribute_key;
+ struct TALER_EXCHANGEDB_HistoryBuilderContext hbc = {
+ .account = &account.h_normalized,
+ .pg = pg,
+ .attribute_key = &attribute_key,
+ .is_wallet = false
+ };
+ json_t *j;
+
+ TDB_FILL (attribute_key,
+ 1);
+ j = TALER_EXCHANGEDB_current_rule_builder (&hbc);
+ FAILIF (NULL == j);
+ json_decref (j);
+ return 0;
+}
+
+
+/**
+ * The outcome listing reports the decisions with what could account for
+ * them.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct OutcomeContext ctx;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.h_payto = &account.h_normalized;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id (
+ pg,
+ 0,
+ &outcome_cb,
+ &ctx));
+ FAILIF (2 != ctx.total);
+ FAILIF (2 != ctx.matched);
+ /* no AML officer signed either decision */
+ FAILIF (ctx.has_aml_decision);
+ /* and there was no KYC process behind them */
+ FAILIF (ctx.has_legitimization_process);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id (
+ pg,
+ 1000,
+ &outcome_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_legitimization_outcomes_above_serial_id (
+ pg,
+ 0,
+ &outcome_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * Starting a rule update and cancelling it releases everything.
+ *
+ * The update answers through the scheduler, so a check that itself runs
+ * inside a scheduler task cannot wait for the answer; what is checked is
+ * that the handle is created and that cancelling it is clean.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_rule_update (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_AttributeEncryptionKeyP attribute_key;
+ struct TALER_EXCHANGEDB_RuleUpdater *ru;
+
+ TDB_FILL (attribute_key,
+ 1);
+ ru = TALER_EXCHANGEDB_begin_rule_update (pg,
+ &attribute_key,
+ &account.h_normalized,
+ false,
+ &rules_cb,
+ NULL);
+ FAILIF (NULL == ru);
+ TALER_EXCHANGEDB_begin_rule_update_cancel (ru);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "legitimization-outcomes-empty",
+ &check_empty },
+ { "legitimization-outcomes-persist",
+ &check_persist },
+ { "legitimization-outcomes-rules-with-account",
+ &check_rules_with_account },
+ { "legitimization-outcomes-sanction-hit",
+ &check_sanction_hit },
+ { "legitimization-outcomes-rule-builder",
+ &check_rule_builder },
+ { "legitimization-outcomes-iterate",
+ &check_iterate },
+ { "legitimization-outcomes-rule-update",
+ &check_rule_update },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-legitimization-outcomes",
+ "Tests for the exchangedb `legitimization_outcomes' table",
+ tests);
+ TDB_account_free (&account);
+ return ret;
+}
+
+
+/* end of test_legitimization_outcomes.c */
diff --git a/src/exchangedb/test_legitimization_processes.c b/src/exchangedb/test_legitimization_processes.c
@@ -0,0 +1,612 @@
+/*
+ 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/test_legitimization_processes.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `legitimization_processes`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_legitimization_process(),
+ * #TALER_EXCHANGEDB_update_legitimization_process_by_row(),
+ * #TALER_EXCHANGEDB_insert_kyc_failure(),
+ * #TALER_EXCHANGEDB_insert_aml_program_failure(),
+ * #TALER_EXCHANGEDB_get_legitimization_process_by_account(),
+ * #TALER_EXCHANGEDB_get_pending_legitimization_process(),
+ * #TALER_EXCHANGEDB_get_kyc_provider_account() and
+ * #TALER_EXCHANGEDB_iterate_kyc_references().
+ *
+ * A row of `legitimization_processes` is one run of one KYC provider
+ * against one account, working on one measure of a
+ * `legitimization_measures` set. The checks therefore create an account
+ * and a measure set first, and then walk a process from "started" through
+ * "redirect known" to "finished".
+ */
+#include "test_common.h"
+#include "exchange-database/do_trigger_kyc_rule_for_account.h"
+#include "exchange-database/get_kyc_provider_account.h"
+#include "exchange-database/get_legitimization_process_by_account.h"
+#include "exchange-database/get_pending_legitimization_process.h"
+#include "exchange-database/insert_aml_program_failure.h"
+#include "exchange-database/insert_kyc_failure.h"
+#include "exchange-database/insert_legitimization_process.h"
+#include "exchange-database/iterate_kyc_references.h"
+#include "exchange-database/update_legitimization_process_by_row.h"
+
+
+/**
+ * Account the checks run processes against.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Measure set the processes work on.
+ */
+static uint64_t measure_row;
+
+
+/**
+ * Row of the process check_start() created.
+ */
+static uint64_t process_row;
+
+
+/**
+ * Closure for #reference_cb().
+ */
+struct ReferenceContext
+{
+ /**
+ * How many references did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Provider we are looking for, NULL to match nothing.
+ */
+ const char *provider;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Provider account id reported for it, owned by this struct.
+ */
+ char *user_id;
+
+ /**
+ * Provider legitimization id reported for it, owned by this struct.
+ */
+ char *legi_id;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_kyc_references().
+ *
+ * @param cls a `struct ReferenceContext *`
+ * @param kyc_provider_name provider that ran the process
+ * @param provider_user_id account id at the provider
+ * @param legi_id legitimization id at the provider
+ */
+static void
+reference_cb (void *cls,
+ const char *kyc_provider_name,
+ const char *provider_user_id,
+ const char *legi_id)
+{
+ struct ReferenceContext *ctx = cls;
+
+ ctx->total++;
+ if ( (NULL != ctx->provider) &&
+ (0 == strcmp (kyc_provider_name,
+ ctx->provider)) )
+ {
+ ctx->matched++;
+ GNUNET_free (ctx->user_id);
+ GNUNET_free (ctx->legi_id);
+ ctx->user_id = (NULL == provider_user_id)
+ ? NULL
+ : GNUNET_strdup (provider_user_id);
+ ctx->legi_id = (NULL == legi_id)
+ ? NULL
+ : GNUNET_strdup (legi_id);
+ }
+}
+
+
+/**
+ * Put a measure on our account, creating the account on the way.
+ *
+ * @param pg the database context
+ */
+static void
+setup_account (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ json_t *jmeasures;
+ uint64_t row = 0;
+ bool bad_kyc_auth;
+
+ if (0 != measure_row)
+ return;
+ TDB_account (pg,
+ 10,
+ &account);
+ jmeasures = GNUNET_JSON_PACK (
+ GNUNET_JSON_pack_array_steal (
+ "measures",
+ json_pack ("[{s:s,s:s,s:s}]",
+ "check_name",
+ "verify-id",
+ "prog_name",
+ "skip",
+ "context",
+ "none")),
+ GNUNET_JSON_pack_bool ("is_and_combinator",
+ true));
+ GNUNET_assert (NULL != jmeasures);
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_do_trigger_kyc_rule_for_account (
+ pg,
+ account.payto,
+ &account.h_normalized,
+ NULL,
+ NULL,
+ jmeasures,
+ 1,
+ &row,
+ &bad_kyc_auth));
+ json_decref (jmeasures);
+ GNUNET_assert (0 != row);
+ measure_row = row;
+}
+
+
+/**
+ * Nothing is known while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_NormalizedPaytoHashP h_payto;
+ struct GNUNET_TIME_Absolute expiration;
+ struct ReferenceContext ctx = { 0 };
+ char *provider_account_id = NULL;
+ char *provider_legitimization_id = NULL;
+ char *redirect_url = NULL;
+ uint64_t row;
+ bool is_wallet;
+
+ setup_account (pg);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_legitimization_process_by_account (
+ pg,
+ "test-provider",
+ &account.h_normalized,
+ &row,
+ &expiration,
+ &provider_account_id,
+ &provider_legitimization_id,
+ &is_wallet));
+ FAILIF (NULL != provider_account_id);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_pending_legitimization_process (
+ pg,
+ &account.h_normalized,
+ "test-provider",
+ &redirect_url));
+ FAILIF (NULL != redirect_url);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_kyc_provider_account (pg,
+ "test-provider",
+ "legi-1",
+ &h_payto,
+ &is_wallet,
+ &row));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_kyc_references (pg,
+ &account.h_normalized,
+ &reference_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * Starting a process records it and makes it findable by provider and by
+ * the provider's own identifiers.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_start (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_NormalizedPaytoHashP h_payto;
+ struct GNUNET_TIME_Absolute expiration;
+ struct ReferenceContext ctx;
+ char *provider_account_id = NULL;
+ char *provider_legitimization_id = NULL;
+ uint64_t row = 0;
+ bool is_wallet = true;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_legitimization_process (
+ pg,
+ &account.h_normalized,
+ 0,
+ measure_row,
+ "test-provider",
+ "provider-account-1",
+ "legi-1",
+ &process_row));
+ FAILIF (0 == process_row);
+ FAILIF (1 != TDB_count (pg,
+ "FROM legitimization_processes"));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_legitimization_process_by_account (
+ pg,
+ "test-provider",
+ &account.h_normalized,
+ &row,
+ &expiration,
+ &provider_account_id,
+ &provider_legitimization_id,
+ &is_wallet));
+ FAILIF (row != process_row);
+ FAILIF_C (0 != strcmp (provider_account_id,
+ "provider-account-1"),
+ GNUNET_free (provider_account_id);
+ GNUNET_free (provider_legitimization_id));
+ FAILIF_C (0 != strcmp (provider_legitimization_id,
+ "legi-1"),
+ GNUNET_free (provider_account_id);
+ GNUNET_free (provider_legitimization_id));
+ GNUNET_free (provider_account_id);
+ GNUNET_free (provider_legitimization_id);
+ FAILIF (is_wallet);
+
+ /* another provider has no process on this account */
+ provider_account_id = NULL;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_legitimization_process_by_account (
+ pg,
+ "other-provider",
+ &account.h_normalized,
+ &row,
+ &expiration,
+ &provider_account_id,
+ &provider_legitimization_id,
+ &is_wallet));
+
+ /* the provider's own identifier resolves back to the account */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_kyc_provider_account (pg,
+ "test-provider",
+ "legi-1",
+ &h_payto,
+ &is_wallet,
+ &row));
+ FAILIF (0 != GNUNET_memcmp (&h_payto,
+ &account.h_normalized));
+ FAILIF (row != process_row);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_kyc_provider_account (pg,
+ "test-provider",
+ "legi-does-not-exist",
+ &h_payto,
+ &is_wallet,
+ &row));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.provider = "test-provider";
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_kyc_references (pg,
+ &account.h_normalized,
+ &reference_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.matched,
+ GNUNET_free (ctx.user_id); GNUNET_free (ctx.legi_id));
+ FAILIF_C (0 != strcmp (ctx.user_id,
+ "provider-account-1"),
+ GNUNET_free (ctx.user_id); GNUNET_free (ctx.legi_id));
+ GNUNET_free (ctx.user_id);
+ GNUNET_free (ctx.legi_id);
+ return 0;
+}
+
+
+/**
+ * Updating a process records the redirect URL and can mark it finished.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_update (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct GNUNET_TIME_Absolute expiration
+ = GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_HOURS);
+ char *redirect_url = NULL;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_legitimization_process_by_row (
+ pg,
+ process_row,
+ "test-provider",
+ &account.h_normalized,
+ "provider-account-1",
+ "legi-1",
+ "https://kyc.example/start",
+ expiration,
+ TALER_EC_NONE,
+ NULL,
+ false));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_pending_legitimization_process (
+ pg,
+ &account.h_normalized,
+ "test-provider",
+ &redirect_url));
+ FAILIF (NULL == redirect_url);
+ FAILIF_C (0 != strcmp (redirect_url,
+ "https://kyc.example/start"),
+ GNUNET_free (redirect_url));
+ GNUNET_free (redirect_url);
+
+ /* a row that does not exist is not created */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_legitimization_process_by_row (
+ pg,
+ process_row + 1000,
+ "test-provider",
+ &account.h_normalized,
+ "provider-account-1",
+ "legi-1",
+ "https://kyc.example/start",
+ expiration,
+ TALER_EC_NONE,
+ NULL,
+ false));
+ FAILIF (1 != TDB_count (pg,
+ "FROM legitimization_processes"));
+
+ /* marking it finished takes it out of the pending view */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_legitimization_process_by_row (
+ pg,
+ process_row,
+ "test-provider",
+ &account.h_normalized,
+ "provider-account-1",
+ "legi-1",
+ "https://kyc.example/start",
+ expiration,
+ TALER_EC_NONE,
+ NULL,
+ true));
+ redirect_url = NULL;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_pending_legitimization_process (
+ pg,
+ &account.h_normalized,
+ "test-provider",
+ &redirect_url));
+ FAILIF (NULL != redirect_url);
+ return 0;
+}
+
+
+/**
+ * A failed KYC check marks the process finished with an error.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_kyc_failure (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ uint64_t row = 0;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_legitimization_process (
+ pg,
+ &account.h_normalized,
+ 1,
+ measure_row,
+ "failing-provider",
+ "provider-account-2",
+ "legi-2",
+ &row));
+ FAILIF (2 != TDB_count (pg,
+ "FROM legitimization_processes"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_kyc_failure (
+ pg,
+ row,
+ &account.h_normalized,
+ "failing-provider",
+ "provider-account-2",
+ "legi-2",
+ "the provider said no",
+ TALER_EC_EXCHANGE_KYC_GENERIC_PROVIDER_UNEXPECTED_REPLY));
+ FAILIF (1 != TDB_count (pg,
+ "FROM legitimization_processes"
+ " WHERE finished"
+ " AND error_code=%u",
+ (unsigned int)
+ TALER_EC_EXCHANGE_KYC_GENERIC_PROVIDER_UNEXPECTED_REPLY));
+ /* a row that does not exist is not created */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_insert_kyc_failure (
+ pg,
+ row + 1000,
+ &account.h_normalized,
+ "failing-provider",
+ "provider-account-2",
+ "legi-2",
+ "the provider said no",
+ TALER_EC_EXCHANGE_KYC_GENERIC_PROVIDER_UNEXPECTED_REPLY));
+ FAILIF (2 != TDB_count (pg,
+ "FROM legitimization_processes"));
+ return 0;
+}
+
+
+/**
+ * A failed AML program marks its process finished with an error too.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_aml_program_failure (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ uint64_t row = 0;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_legitimization_process (
+ pg,
+ &account.h_normalized,
+ 2,
+ measure_row,
+ "aml-provider",
+ "provider-account-3",
+ "legi-3",
+ &row));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_aml_program_failure (
+ pg,
+ row,
+ &account.h_normalized,
+ "the AML program crashed",
+ TALER_EC_EXCHANGE_KYC_AML_PROGRAM_FAILURE));
+ FAILIF (1 != TDB_count (pg,
+ "FROM legitimization_processes"
+ " WHERE finished"
+ " AND error_code=%u",
+ (unsigned int)
+ TALER_EC_EXCHANGE_KYC_AML_PROGRAM_FAILURE));
+ /* Unlike insert_kyc_failure(), a row that does not exist is *created*
+ here rather than ignored -- the stored procedure would rather keep a
+ record of the failed AML program than lose it. */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_aml_program_failure (
+ pg,
+ row + 1000,
+ &account.h_normalized,
+ "the AML program crashed",
+ TALER_EC_EXCHANGE_KYC_AML_PROGRAM_FAILURE));
+ FAILIF (4 != TDB_count (pg,
+ "FROM legitimization_processes"));
+ return 0;
+}
+
+
+/**
+ * The reference listing reports every provider that worked on the
+ * account.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_references (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct ReferenceContext ctx;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.provider = "aml-provider";
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_kyc_references (pg,
+ &account.h_normalized,
+ &reference_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.matched,
+ GNUNET_free (ctx.user_id); GNUNET_free (ctx.legi_id));
+ FAILIF_C (4 != ctx.total,
+ GNUNET_free (ctx.user_id); GNUNET_free (ctx.legi_id));
+ GNUNET_free (ctx.user_id);
+ GNUNET_free (ctx.legi_id);
+
+ /* an account nobody checked has no references */
+ {
+ struct TALER_NormalizedPaytoHashP other;
+
+ TDB_FILL (other,
+ 98);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_kyc_references (pg,
+ &other,
+ &reference_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ }
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "legitimization-processes-empty",
+ &check_empty },
+ { "legitimization-processes-start",
+ &check_start },
+ { "legitimization-processes-update",
+ &check_update },
+ { "legitimization-processes-kyc-failure",
+ &check_kyc_failure },
+ { "legitimization-processes-aml-program-failure",
+ &check_aml_program_failure },
+ { "legitimization-processes-references",
+ &check_references },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-legitimization-processes",
+ "Tests for the exchangedb `legitimization_processes' table",
+ tests);
+ TDB_account_free (&account);
+ return ret;
+}
+
+
+/* end of test_legitimization_processes.c */
diff --git a/src/exchangedb/test_misc.c b/src/exchangedb/test_misc.c
@@ -0,0 +1,656 @@
+/*
+ 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/test_misc.c
+ * @brief tests for the exchangedb functions that have no table of their own
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_start(), #TALER_EXCHANGEDB_start_read_only(),
+ * #TALER_EXCHANGEDB_start_read_committed(),
+ * #TALER_EXCHANGEDB_start_deferred_wire_out(), #TALER_EXCHANGEDB_commit(),
+ * #TALER_EXCHANGEDB_rollback(), #TALER_EXCHANGEDB_preflight(),
+ * #TALER_EXCHANGEDB_compute_shard(),
+ * #TALER_EXCHANGEDB_calculate_transaction_list_totals(),
+ * #TALER_EXCHANGEDB_free_coin_transaction_list(),
+ * #TALER_EXCHANGEDB_free_reserve_history(),
+ * #TALER_EXCHANGEDB_event_listen(),
+ * #TALER_EXCHANGEDB_event_listen_cancel(),
+ * #TALER_EXCHANGEDB_event_notify(), #TALER_EXCHANGEDB_enable_rules(),
+ * #TALER_EXCHANGEDB_disable_rules(),
+ * #TALER_EXCHANGEDB_inject_auditor_triggers(),
+ * #TALER_EXCHANGEDB_drop_tables() and #TALER_EXCHANGEDB_create_tables().
+ *
+ * These are the connection-level functions: transactions, notifications,
+ * schema management and the two list destructors. The schema checks come
+ * last on purpose -- they take the tables away and put them back, so
+ * nothing may run after them.
+ */
+#include "test_common.h"
+#include "taler/taler_dbevents.h"
+#include "exchange-database/commit.h"
+#include "exchange-database/compute_shard.h"
+#include "exchange-database/create_tables.h"
+#include "exchange-database/disable_rules.h"
+#include "exchange-database/drop_tables.h"
+#include "exchange-database/enable_rules.h"
+#include "exchange-database/event_listen.h"
+#include "exchange-database/event_listen_cancel.h"
+#include "exchange-database/event_notify.h"
+#include "exchange-database/free_coin_transaction_list.h"
+#include "exchange-database/free_reserve_history.h"
+#include "exchange-database/get_coin_transactions.h"
+#include "exchange-database/get_reserve_history.h"
+#include "exchange-database/inject_auditor_triggers.h"
+#include "exchange-database/preflight.h"
+#include "exchange-database/rollback.h"
+#include "exchange-database/start.h"
+#include "exchange-database/start_deferred_wire_out.h"
+#include "exchange-database/start_read_committed.h"
+#include "exchange-database/start_read_only.h"
+
+
+/**
+ * Account the checks fund their reserves from.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Denomination the checks use.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * The four ways to start a transaction.
+ *
+ * @param pg the database context
+ * @param name name of the transaction
+ * @return #GNUNET_OK on success
+ */
+static enum GNUNET_GenericReturnValue
+start_serializable (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const char *name)
+{
+ return TALER_EXCHANGEDB_start (pg,
+ name);
+}
+
+
+/**
+ * Start a read-only transaction.
+ *
+ * @param pg the database context
+ * @param name name of the transaction
+ * @return #GNUNET_OK on success
+ */
+static enum GNUNET_GenericReturnValue
+start_read_only (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const char *name)
+{
+ return TALER_EXCHANGEDB_start_read_only (pg,
+ name);
+}
+
+
+/**
+ * Start a read-committed transaction.
+ *
+ * @param pg the database context
+ * @param name name of the transaction
+ * @return #GNUNET_OK on success
+ */
+static enum GNUNET_GenericReturnValue
+start_read_committed (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const char *name)
+{
+ return TALER_EXCHANGEDB_start_read_committed (pg,
+ name);
+}
+
+
+/**
+ * Start a transaction with deferred wire-out constraints.
+ *
+ * @param pg the database context
+ * @param name name of the transaction
+ * @return #GNUNET_OK on success
+ */
+static enum GNUNET_GenericReturnValue
+start_deferred (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const char *name)
+{
+ (void) name;
+ return TALER_EXCHANGEDB_start_deferred_wire_out (pg);
+}
+
+
+/**
+ * All four transaction starters behave the same way as far as the
+ * connection's bookkeeping is concerned.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_transactions (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ static enum GNUNET_GenericReturnValue (*const starters[])(
+ struct TALER_EXCHANGEDB_PostgresContext *,
+ const char *) = {
+ &start_serializable,
+ &start_read_only,
+ &start_read_committed,
+ &start_deferred
+ };
+
+ FAILIF (NULL != pg->transaction_name);
+ for (unsigned int i = 0; i < 4; i++)
+ {
+ /* commit ends the transaction ... */
+ FAILIF (GNUNET_OK !=
+ starters[i](pg,
+ "misc-commit"));
+ FAILIF (NULL == pg->transaction_name);
+ FAILIF (0 > TALER_EXCHANGEDB_commit (pg));
+ FAILIF (NULL != pg->transaction_name);
+
+ /* ... and so does a rollback */
+ FAILIF (GNUNET_OK !=
+ starters[i](pg,
+ "misc-rollback"));
+ FAILIF (NULL == pg->transaction_name);
+ TALER_EXCHANGEDB_rollback (pg);
+ FAILIF (NULL != pg->transaction_name);
+ }
+
+ /* rolling back when nothing is open is a no-op */
+ TALER_EXCHANGEDB_rollback (pg);
+ FAILIF (NULL != pg->transaction_name);
+ return 0;
+}
+
+
+/**
+ * A transaction really isolates: what it wrote is gone after a rollback
+ * and there after a commit.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_isolation (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "misc-isolation"));
+ TDB_account (pg,
+ 10,
+ &account);
+ FAILIF (1 != TDB_count (pg,
+ "FROM wire_targets"));
+ TALER_EXCHANGEDB_rollback (pg);
+ FAILIF (0 != TDB_count (pg,
+ "FROM wire_targets"));
+
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "misc-isolation"));
+ TDB_account (pg,
+ 10,
+ &account);
+ FAILIF (0 > TALER_EXCHANGEDB_commit (pg));
+ FAILIF (1 != TDB_count (pg,
+ "FROM wire_targets"));
+ return 0;
+}
+
+
+/**
+ * Preflight is happy on an idle connection and cleans up after a caller
+ * that forgot to end its transaction.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_preflight (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_preflight (pg));
+
+ /* a forgotten transaction is rolled back, and preflight says so */
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "misc-leak"));
+ FAILIF (GNUNET_NO !=
+ TALER_EXCHANGEDB_preflight (pg));
+ FAILIF (NULL != pg->transaction_name);
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_preflight (pg));
+
+ /* which is also what start() does for us */
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "misc-leak"));
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "misc-after-leak"));
+ TALER_EXCHANGEDB_rollback (pg);
+ return 0;
+}
+
+
+/**
+ * The shard of a merchant key is deterministic and stays in range.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_compute_shard (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_MerchantPublicKeyP m1;
+ struct TALER_MerchantPublicKeyP m2;
+ uint64_t s1;
+ uint64_t s2;
+
+ (void) pg;
+ TDB_FILL (m1,
+ 1);
+ TDB_FILL (m2,
+ 2);
+ s1 = TALER_EXCHANGEDB_compute_shard (&m1);
+ s2 = TALER_EXCHANGEDB_compute_shard (&m2);
+ FAILIF (s1 > INT32_MAX);
+ FAILIF (s2 > INT32_MAX);
+ FAILIF (s1 == s2);
+ /* the same key always maps to the same shard, otherwise the shard
+ locks of the aggregator would not partition the work */
+ FAILIF (s1 != TALER_EXCHANGEDB_compute_shard (&m1));
+ FAILIF (s2 != TALER_EXCHANGEDB_compute_shard (&m2));
+ return 0;
+}
+
+
+/**
+ * The coin transaction list adds up and can be released.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_coin_list (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_TransactionList *tl = NULL;
+ struct TALER_CoinPublicInfo coin;
+ struct TDB_Deposit dep;
+ struct TALER_Amount balance;
+ struct TALER_Amount total;
+ struct TALER_Amount zero = TDB_amount ("0");
+ struct TALER_Amount one = TDB_amount ("1");
+ struct TALER_Amount two = TDB_amount ("2");
+ struct TALER_DenominationHashP h_denom_pub;
+ uint64_t etag = 0;
+
+ /* an empty list is worth the offset it started from */
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_calculate_transaction_list_totals (NULL,
+ &one,
+ &total));
+ FAILIF (0 != TALER_amount_cmp (&total,
+ &one));
+ /* and freeing it is a no-op */
+ TALER_EXCHANGEDB_free_coin_transaction_list (NULL);
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ NULL);
+ TDB_deposit (pg,
+ &account,
+ &coin,
+ 30,
+ "1",
+ "0.1",
+ GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS),
+ GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_HOURS),
+ &dep);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_coin_transactions (pg,
+ true,
+ &coin.coin_pub,
+ 0,
+ 0,
+ &etag,
+ &balance,
+ &h_denom_pub,
+ &tl),
+ TDB_coin_free (&coin));
+ TDB_coin_free (&coin);
+ FAILIF (NULL == tl);
+ FAILIF_C (GNUNET_OK !=
+ TALER_EXCHANGEDB_calculate_transaction_list_totals (tl,
+ &zero,
+ &total),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ FAILIF_C (0 != TALER_amount_cmp (&total,
+ &one),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ /* the offset is added on top */
+ FAILIF_C (GNUNET_OK !=
+ TALER_EXCHANGEDB_calculate_transaction_list_totals (tl,
+ &one,
+ &total),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ FAILIF_C (0 != TALER_amount_cmp (&total,
+ &two),
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl));
+ TALER_EXCHANGEDB_free_coin_transaction_list (tl);
+ return 0;
+}
+
+
+/**
+ * The reserve history can be released.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_reserve_history (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_ReserveHistory *rh = NULL;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_Amount balance;
+ uint64_t etag = 0;
+ unsigned int cnt = 0;
+
+ /* freeing nothing is a no-op */
+ TALER_EXCHANGEDB_free_reserve_history (NULL);
+
+ TDB_reserve_in (pg,
+ &account,
+ 11,
+ "10",
+ &reserve_pub);
+ (void) TDB_withdraw (pg,
+ &denom,
+ &reserve_pub,
+ 11,
+ "5");
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_history (pg,
+ &reserve_pub,
+ 0,
+ 0,
+ &etag,
+ &balance,
+ &rh));
+ FAILIF (NULL == rh);
+ for (const struct TALER_EXCHANGEDB_ReserveHistory *pos = rh;
+ NULL != pos;
+ pos = pos->next)
+ cnt++;
+ FAILIF_C (2 != cnt,
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ TALER_EXCHANGEDB_free_reserve_history (rh);
+ return 0;
+}
+
+
+/**
+ * State of the event check.
+ */
+struct EventState
+{
+ /**
+ * How often did the callback fire with a payload?
+ */
+ unsigned int fired;
+
+ /**
+ * Payload of the last notification.
+ */
+ char payload[16];
+
+ /**
+ * Number of bytes in @e payload.
+ */
+ size_t payload_len;
+};
+
+
+/**
+ * Payload of the notification.
+ */
+#define PAYLOAD "hello"
+
+
+/**
+ * Called when the notification arrives (or the listener times out).
+ *
+ * @param cls a `struct EventState *`
+ * @param extra the payload, NULL on timeout
+ * @param extra_size number of bytes in @a extra
+ */
+static void
+event_cb (void *cls,
+ const void *extra,
+ size_t extra_size)
+{
+ struct EventState *st = cls;
+
+ if (NULL == extra)
+ return; /* timeout event, not what we are after */
+ st->fired++;
+ st->payload_len = GNUNET_MIN (extra_size,
+ sizeof (st->payload));
+ memcpy (st->payload,
+ extra,
+ st->payload_len);
+}
+
+
+/**
+ * A notification sent on the connection reaches a listener registered on
+ * it, and stops reaching it once the listener is cancelled.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_events (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct GNUNET_DB_EventHeaderP es = {
+ .size = htons (sizeof (es)),
+ .type = htons (TALER_DBEVENT_EXCHANGE_KYC_COMPLETED)
+ };
+ struct GNUNET_DB_EventHandler *eh;
+ struct EventState st = { 0 };
+
+ eh = TALER_EXCHANGEDB_event_listen (pg,
+ GNUNET_TIME_UNIT_MINUTES,
+ &es,
+ &event_cb,
+ &st);
+ FAILIF (NULL == eh);
+ TALER_EXCHANGEDB_event_notify (pg,
+ &es,
+ PAYLOAD,
+ strlen (PAYLOAD));
+ /* the scheduler task that would pick this up does not get to run while
+ the checks are executing, so poll for it ourselves -- which is also
+ what the exchange does after every notifying transaction */
+ GNUNET_PQ_event_do_poll (pg->conn);
+ FAILIF_C (1 != st.fired,
+ TALER_EXCHANGEDB_event_listen_cancel (pg, eh));
+ FAILIF_C (strlen (PAYLOAD) != st.payload_len,
+ TALER_EXCHANGEDB_event_listen_cancel (pg, eh));
+ FAILIF_C (0 != memcmp (st.payload,
+ PAYLOAD,
+ st.payload_len),
+ TALER_EXCHANGEDB_event_listen_cancel (pg, eh));
+
+ /* an unrelated event does not wake us */
+ {
+ struct GNUNET_DB_EventHeaderP other = {
+ .size = htons (sizeof (other)),
+ .type = htons (TALER_DBEVENT_EXCHANGE_NEW_KYC_ATTRIBUTES)
+ };
+
+ TALER_EXCHANGEDB_event_notify (pg,
+ &other,
+ PAYLOAD,
+ strlen (PAYLOAD));
+ GNUNET_PQ_event_do_poll (pg->conn);
+ FAILIF_C (1 != st.fired,
+ TALER_EXCHANGEDB_event_listen_cancel (pg, eh));
+ }
+
+ /* and after cancelling, neither does the one we listened for */
+ TALER_EXCHANGEDB_event_listen_cancel (pg,
+ eh);
+ TALER_EXCHANGEDB_event_notify (pg,
+ &es,
+ PAYLOAD,
+ strlen (PAYLOAD));
+ GNUNET_PQ_event_do_poll (pg->conn);
+ FAILIF (1 != st.fired);
+ return 0;
+}
+
+
+/**
+ * Customization rules that were never deployed cannot be dropped.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_rules (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_disable_rules (pg,
+ "no-such-customization"));
+ /* and a schema without SQL files on disk cannot be deployed; the
+ search_path must be back to `exchange' afterwards either way */
+ FAILIF (GNUNET_SYSERR ==
+ TALER_EXCHANGEDB_enable_rules (pg,
+ "no-such-customization"));
+ /* the search_path is back to `exchange', so an unqualified query
+ still finds the exchange's own tables */
+ FAILIF (1 != TDB_count (pg,
+ "FROM wire_targets"));
+ return 0;
+}
+
+
+/**
+ * The auditor triggers can be injected into an existing schema.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_auditor_triggers (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_inject_auditor_triggers (pg));
+ return 0;
+}
+
+
+/**
+ * Dropping and re-creating the schema leaves an empty but usable
+ * database. Nothing may run after this check.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_schema (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ FAILIF (0 == TDB_count (pg,
+ "FROM wire_targets"));
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_drop_tables (pg));
+ /* dropping twice fails: drop.sql says `DROP SCHEMA exchange CASCADE'
+ without IF EXISTS, so there is nothing left to drop the second time */
+ FAILIF (GNUNET_OK ==
+ TALER_EXCHANGEDB_drop_tables (pg));
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_create_tables (pg,
+ false,
+ 0));
+ FAILIF (0 != TDB_count (pg,
+ "FROM wire_targets"));
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "misc-transactions",
+ &check_transactions },
+ { "misc-isolation",
+ &check_isolation },
+ { "misc-preflight",
+ &check_preflight },
+ { "misc-compute-shard",
+ &check_compute_shard },
+ { "misc-coin-list",
+ &check_coin_list },
+ { "misc-reserve-history",
+ &check_reserve_history },
+ { "misc-events",
+ &check_events },
+ { "misc-rules",
+ &check_rules },
+ { "misc-auditor-triggers",
+ &check_auditor_triggers },
+ { "misc-schema",
+ &check_schema },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-misc",
+ "Tests for the exchangedb functions without a table",
+ tests);
+ TDB_denom_free (&denom);
+ TDB_account_free (&account);
+ return ret;
+}
+
+
+/* end of test_misc.c */
diff --git a/src/exchangedb/test_partners.c b/src/exchangedb/test_partners.c
@@ -0,0 +1,196 @@
+/*
+ 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/test_partners.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `partners`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_partner(). It is the only exported
+ * function that writes the table; the rows are read back by the purse
+ * machinery, which is covered in test_purse_merges.c and
+ * test_purse_deposits.c.
+ *
+ * `partners` has no foreign keys. The insert is ON CONFLICT DO NOTHING,
+ * so re-inserting the same (key, validity period) is a no-op rather than a
+ * failure.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_partner.h"
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Insert a partner exchange.
+ *
+ * @param pg the database context
+ * @param seed seed for the partner's key and signature
+ * @param from start of the validity period
+ * @param until end of the validity period
+ * @param url base URL of the partner
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+add_partner (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ uint64_t from,
+ uint64_t until,
+ const char *url)
+{
+ struct TALER_MasterPublicKeyP master_pub;
+ struct TALER_MasterSignatureP master_sig;
+ struct TALER_Amount wad_fee = TDB_amount ("0.05");
+
+ TDB_FILL (master_pub,
+ seed);
+ TDB_FILL (master_sig,
+ seed);
+ return TALER_EXCHANGEDB_insert_partner (pg,
+ &master_pub,
+ ts (from),
+ ts (until),
+ GNUNET_TIME_UNIT_HOURS,
+ &wad_fee,
+ url,
+ &master_sig);
+}
+
+
+/**
+ * Inserting a partner stores exactly the row we asked for.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_MasterPublicKeyP master_pub;
+ struct TALER_Amount wad_fee = TDB_amount ("0.05");
+ char *hex;
+
+ FAILIF (0 != TDB_count (pg,
+ "FROM partners"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_partner (pg,
+ 10,
+ 1600000000,
+ 1700000000,
+ "https://partner.example/"));
+ TDB_FILL (master_pub,
+ 10);
+ hex = TDB_hex (&master_pub,
+ sizeof (master_pub));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM partners"
+ " WHERE partner_master_pub=decode('%s','hex')"
+ " AND partner_base_url='https://partner.example/'"
+ " AND wad_fee=ROW(%llu,%u)::taler_amount"
+ " AND start_date=%llu"
+ " AND end_date=%llu",
+ hex,
+ (unsigned long long) wad_fee.value,
+ (unsigned int) wad_fee.fraction,
+ (unsigned long long) 1600000000000000LLU,
+ (unsigned long long) 1700000000000000LLU),
+ GNUNET_free (hex));
+ GNUNET_free (hex);
+ return 0;
+}
+
+
+/**
+ * Re-inserting the identical row is a no-op, while a different validity
+ * period for the same key is a new row.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_conflict (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ /* identical to what check_insert() stored */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ add_partner (pg,
+ 10,
+ 1600000000,
+ 1700000000,
+ "https://partner.example/"));
+ FAILIF (1 != TDB_count (pg,
+ "FROM partners"));
+ /* a later period for the same partner is a separate row */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_partner (pg,
+ 10,
+ 1700000000,
+ 1800000000,
+ "https://partner.example/"));
+ FAILIF (2 != TDB_count (pg,
+ "FROM partners"));
+ /* and so is a different partner */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_partner (pg,
+ 11,
+ 1600000000,
+ 1700000000,
+ "https://other.example/"));
+ FAILIF (3 != TDB_count (pg,
+ "FROM partners"));
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "partners-insert",
+ &check_insert },
+ { "partners-conflict",
+ &check_conflict },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-partners",
+ "Tests for the exchangedb `partners' table",
+ tests);
+}
+
+
+/* end of test_partners.c */
diff --git a/src/exchangedb/test_prewire.c b/src/exchangedb/test_prewire.c
@@ -0,0 +1,323 @@
+/*
+ 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/test_prewire.c
+ * @brief tests for the exchangedb functions whose primary table is `prewire`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_prewire(),
+ * #TALER_EXCHANGEDB_iterate_prewires(),
+ * #TALER_EXCHANGEDB_update_to_prewire_finished() and
+ * #TALER_EXCHANGEDB_update_to_prewire_failed().
+ *
+ * `prewire` has no foreign keys. The table is a work queue: the iterator
+ * only reports rows that are neither finished nor failed, which is what the
+ * checks below are about.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_prewire.h"
+#include "exchange-database/iterate_prewires.h"
+#include "exchange-database/update_to_prewire_finished.h"
+#include "exchange-database/update_to_prewire_failed.h"
+
+
+/**
+ * Closure for #prewire_cb().
+ */
+struct PrewireContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Row of the first entry seen.
+ */
+ uint64_t first_row;
+
+ /**
+ * Payload of the first entry seen, owned by this struct.
+ */
+ char *first_buf;
+
+ /**
+ * Number of bytes in @e first_buf.
+ */
+ size_t first_buf_size;
+
+ /**
+ * Wire method of the first entry seen, owned by this struct.
+ */
+ char *first_method;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_prewires().
+ *
+ * @param cls a `struct PrewireContext *`
+ * @param rowid row of the entry
+ * @param wire_method wire method of the entry
+ * @param buf payload of the entry
+ * @param buf_size number of bytes in @a buf
+ */
+static void
+prewire_cb (void *cls,
+ uint64_t rowid,
+ const char *wire_method,
+ const char *buf,
+ size_t buf_size)
+{
+ struct PrewireContext *ctx = cls;
+
+ if (0 == ctx->total++)
+ {
+ ctx->first_row = rowid;
+ ctx->first_buf = GNUNET_memdup (buf,
+ buf_size);
+ ctx->first_buf_size = buf_size;
+ ctx->first_method = GNUNET_strdup (wire_method);
+ }
+}
+
+
+/**
+ * Release what #prewire_cb() allocated.
+ *
+ * @param[in,out] ctx context to clean up
+ */
+static void
+ctx_free (struct PrewireContext *ctx)
+{
+ GNUNET_free (ctx->first_buf);
+ GNUNET_free (ctx->first_method);
+}
+
+
+/**
+ * An empty queue yields nothing, and marking rows that do not exist does
+ * nothing.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct PrewireContext ctx = { 0 };
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_prewires (pg,
+ 0,
+ 10,
+ &prewire_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_to_prewire_finished (pg,
+ 1));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_to_prewire_failed (pg,
+ 1));
+ return 0;
+}
+
+
+/**
+ * Inserted rows come back in order, with their payload intact.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_and_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ static const char buf1[] = "first-transfer";
+ static const char buf2[] = "second-transfer";
+ struct PrewireContext ctx;
+ uint64_t row1;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_prewire (pg,
+ "x-taler-bank",
+ buf1,
+ sizeof (buf1)));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_prewire (pg,
+ "iban",
+ buf2,
+ sizeof (buf2)));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_prewires (pg,
+ 0,
+ 10,
+ &prewire_cb,
+ &ctx));
+ FAILIF_C (2 != ctx.total,
+ ctx_free (&ctx));
+ FAILIF_C (sizeof (buf1) != ctx.first_buf_size,
+ ctx_free (&ctx));
+ FAILIF_C (0 != memcmp (ctx.first_buf,
+ buf1,
+ sizeof (buf1)),
+ ctx_free (&ctx));
+ FAILIF_C (0 != strcmp (ctx.first_method,
+ "x-taler-bank"),
+ ctx_free (&ctx));
+ row1 = ctx.first_row;
+ ctx_free (&ctx);
+
+ /* the limit is honoured */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_prewires (pg,
+ 0,
+ 1,
+ &prewire_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.total,
+ ctx_free (&ctx));
+ ctx_free (&ctx);
+
+ /* ...and so is the starting row */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_prewires (pg,
+ row1 + 1,
+ 10,
+ &prewire_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.total,
+ ctx_free (&ctx));
+ FAILIF_C (0 != strcmp (ctx.first_method,
+ "iban"),
+ ctx_free (&ctx));
+ ctx_free (&ctx);
+ return 0;
+}
+
+
+/**
+ * Finished and failed rows drop out of the queue.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_finished_and_failed (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct PrewireContext ctx;
+ uint64_t row1;
+ uint64_t row2;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_prewires (pg,
+ 0,
+ 10,
+ &prewire_cb,
+ &ctx));
+ FAILIF_C (2 != ctx.total,
+ ctx_free (&ctx));
+ row1 = ctx.first_row;
+ ctx_free (&ctx);
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_to_prewire_finished (pg,
+ row1));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_prewires (pg,
+ 0,
+ 10,
+ &prewire_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.total,
+ ctx_free (&ctx));
+ row2 = ctx.first_row;
+ ctx_free (&ctx);
+ FAILIF (row2 == row1);
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_to_prewire_failed (pg,
+ row2));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_prewires (pg,
+ 0,
+ 10,
+ &prewire_cb,
+ &ctx));
+ FAILIF_C (0 != ctx.total,
+ ctx_free (&ctx));
+ ctx_free (&ctx);
+
+ /* both rows are still on file, one finished and one failed */
+ FAILIF (2 != TDB_count (pg,
+ "FROM prewire"));
+ FAILIF (1 != TDB_count (pg,
+ "FROM prewire WHERE finished"));
+ FAILIF (1 != TDB_count (pg,
+ "FROM prewire WHERE failed"));
+ /* marking again is still reported as a row touched */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_to_prewire_finished (pg,
+ row1));
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "prewire-empty",
+ &check_empty },
+ { "prewire-insert-and-iterate",
+ &check_insert_and_iterate },
+ { "prewire-finished-and-failed",
+ &check_finished_and_failed },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-prewire",
+ "Tests for the exchangedb `prewire' table",
+ tests);
+}
+
+
+/* end of test_prewire.c */
diff --git a/src/exchangedb/test_profit_drains.c b/src/exchangedb/test_profit_drains.c
@@ -0,0 +1,330 @@
+/*
+ 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/test_profit_drains.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `profit_drains`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_profit_drain(),
+ * #TALER_EXCHANGEDB_get_profit_drain(),
+ * #TALER_EXCHANGEDB_get_pending_profit_drain() and
+ * #TALER_EXCHANGEDB_update_to_profit_drain_finished().
+ *
+ * `profit_drains` has no foreign keys. The pending lookup picks the
+ * oldest not-yet-executed request, so the checks insert two out of order
+ * and drain them one by one.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_profit_drain.h"
+#include "exchange-database/get_profit_drain.h"
+#include "exchange-database/get_pending_profit_drain.h"
+#include "exchange-database/update_to_profit_drain_finished.h"
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Insert a drain request.
+ *
+ * @param pg the database context
+ * @param seed seed for the wire transfer identifier and signature
+ * @param when when was the request made
+ * @param amount amount to drain, e.g. "10"
+ * @param[out] wtid set to the wire transfer identifier used
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+add_drain (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ uint64_t when,
+ const char *amount,
+ struct TALER_WireTransferIdentifierRawP *wtid)
+{
+ struct TALER_FullPayto payto = {
+ .full_payto = (char *) "payto://x-taler-bank/localhost/profits"
+ };
+ struct TALER_Amount a = TDB_amount (amount);
+ struct TALER_MasterSignatureP master_sig;
+
+ TDB_fill (wtid,
+ sizeof (*wtid),
+ seed);
+ TDB_FILL (master_sig,
+ seed);
+ return TALER_EXCHANGEDB_insert_profit_drain (pg,
+ wtid,
+ "exchange-account-1",
+ payto,
+ ts (when),
+ &a,
+ &master_sig);
+}
+
+
+/**
+ * Nothing is pending and nothing is found while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct TALER_FullPayto payto_uri;
+ struct GNUNET_TIME_Timestamp request_timestamp;
+ struct TALER_Amount amount;
+ struct TALER_MasterSignatureP master_sig;
+ char *account_section = NULL;
+ uint64_t serial;
+
+ TDB_FILL (wtid,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_profit_drain (pg,
+ &wtid,
+ &serial,
+ &account_section,
+ &payto_uri,
+ &request_timestamp,
+ &amount,
+ &master_sig));
+ FAILIF (NULL != account_section);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_pending_profit_drain (pg,
+ &serial,
+ &wtid,
+ &account_section,
+ &payto_uri,
+ &request_timestamp,
+ &amount,
+ &master_sig));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_to_profit_drain_finished (pg,
+ 1));
+ return 0;
+}
+
+
+/**
+ * A drain request is stored, found by its wire transfer identifier and
+ * reported as pending.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct TALER_WireTransferIdentifierRawP unknown;
+ struct TALER_WireTransferIdentifierRawP got_wtid;
+ struct TALER_FullPayto payto_uri = { NULL };
+ struct GNUNET_TIME_Timestamp request_timestamp;
+ struct TALER_Amount amount;
+ struct TALER_Amount expect = TDB_amount ("10");
+ struct TALER_MasterSignatureP master_sig;
+ struct TALER_MasterSignatureP expect_sig;
+ char *account_section = NULL;
+ uint64_t serial = 0;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_drain (pg,
+ 10,
+ 1600000000,
+ "10",
+ &wtid));
+ TDB_FILL (expect_sig,
+ 10);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_profit_drain (pg,
+ &wtid,
+ &serial,
+ &account_section,
+ &payto_uri,
+ &request_timestamp,
+ &amount,
+ &master_sig));
+ FAILIF (0 == serial);
+ FAILIF (0 != strcmp (account_section,
+ "exchange-account-1"));
+ GNUNET_free (account_section);
+ FAILIF (0 != strcmp (payto_uri.full_payto,
+ "payto://x-taler-bank/localhost/profits"));
+ GNUNET_free (payto_uri.full_payto);
+ FAILIF (0 != TALER_amount_cmp (&amount,
+ &expect));
+ FAILIF (GNUNET_TIME_timestamp_cmp (request_timestamp,
+ !=,
+ ts (1600000000)));
+ FAILIF (0 != GNUNET_memcmp (&master_sig,
+ &expect_sig));
+
+ /* a wire transfer identifier we never used is not found */
+ TDB_FILL (unknown,
+ 99);
+ account_section = NULL;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_profit_drain (pg,
+ &unknown,
+ &serial,
+ &account_section,
+ &payto_uri,
+ &request_timestamp,
+ &amount,
+ &master_sig));
+
+ account_section = NULL;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_pending_profit_drain (pg,
+ &serial,
+ &got_wtid,
+ &account_section,
+ &payto_uri,
+ &request_timestamp,
+ &amount,
+ &master_sig));
+ GNUNET_free (account_section);
+ GNUNET_free (payto_uri.full_payto);
+ FAILIF (0 != GNUNET_memcmp (&got_wtid,
+ &wtid));
+ return 0;
+}
+
+
+/**
+ * The oldest unexecuted request is the pending one, and marking it
+ * executed hands the next one over.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_pending_order (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_WireTransferIdentifierRawP older;
+ struct TALER_WireTransferIdentifierRawP got_wtid;
+ struct TALER_FullPayto payto_uri = { NULL };
+ struct GNUNET_TIME_Timestamp request_timestamp;
+ struct TALER_Amount amount;
+ struct TALER_MasterSignatureP master_sig;
+ char *account_section = NULL;
+ uint64_t serial = 0;
+
+ /* inserted second, but with an earlier trigger date */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_drain (pg,
+ 11,
+ 1500000000,
+ "3",
+ &older));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_pending_profit_drain (pg,
+ &serial,
+ &got_wtid,
+ &account_section,
+ &payto_uri,
+ &request_timestamp,
+ &amount,
+ &master_sig));
+ GNUNET_free (account_section);
+ GNUNET_free (payto_uri.full_payto);
+ FAILIF (0 != GNUNET_memcmp (&got_wtid,
+ &older));
+
+ /* draining it hands over the remaining one */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_to_profit_drain_finished (pg,
+ serial));
+ account_section = NULL;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_pending_profit_drain (pg,
+ &serial,
+ &got_wtid,
+ &account_section,
+ &payto_uri,
+ &request_timestamp,
+ &amount,
+ &master_sig));
+ GNUNET_free (account_section);
+ GNUNET_free (payto_uri.full_payto);
+ FAILIF (0 == GNUNET_memcmp (&got_wtid,
+ &older));
+
+ /* and once both are executed, nothing is pending */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_to_profit_drain_finished (pg,
+ serial));
+ account_section = NULL;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_pending_profit_drain (pg,
+ &serial,
+ &got_wtid,
+ &account_section,
+ &payto_uri,
+ &request_timestamp,
+ &amount,
+ &master_sig));
+ /* the executed rows are still on file */
+ FAILIF (2 != TDB_count (pg,
+ "FROM profit_drains WHERE executed"));
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "profit-drains-empty",
+ &check_empty },
+ { "profit-drains-insert-and-lookup",
+ &check_insert_and_lookup },
+ { "profit-drains-pending-order",
+ &check_pending_order },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-profit-drains",
+ "Tests for the exchangedb `profit_drains' table",
+ tests);
+}
+
+
+/* end of test_profit_drains.c */
diff --git a/src/exchangedb/test_purse_decision.c b/src/exchangedb/test_purse_decision.c
@@ -0,0 +1,441 @@
+/*
+ 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/test_purse_decision.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `purse_decision`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_expire_purse(),
+ * #TALER_EXCHANGEDB_iterate_purse_decisions_above_serial_id() and
+ * #TALER_EXCHANGEDB_iterate_all_purse_decisions_above_serial_id().
+ *
+ * A purse is decided either because it was merged or because it expired.
+ * do_expire_purse() handles the second case: it decides the oldest expired
+ * purse in the window it is given, marks the decision as a refund and
+ * gives the coins their money back -- which is what the checks look at.
+ */
+#include "test_common.h"
+#include "exchange-database/do_expire_purse.h"
+#include "exchange-database/do_purse_deposit.h"
+#include "exchange-database/iterate_all_purse_decisions_above_serial_id.h"
+#include "exchange-database/iterate_purse_decisions_above_serial_id.h"
+
+
+/**
+ * Denomination the checks deposit into purses.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #decision_cb() and #all_decision_cb().
+ */
+struct DecisionContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Purse we are looking for, NULL to match nothing.
+ */
+ const struct TALER_PurseContractPublicKeyP *purse_pub;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Was it reported as refunded?
+ */
+ bool refunded;
+
+ /**
+ * Was a reserve reported for it?
+ */
+ bool have_reserve;
+};
+
+
+/**
+ * Callback for
+ * #TALER_EXCHANGEDB_iterate_purse_decisions_above_serial_id().
+ *
+ * @param cls a `struct DecisionContext *`
+ * @param rowid row of the decision
+ * @param purse_pub the purse
+ * @param reserve_pub reserve it went to, NULL if refunded
+ * @param purse_value target value of the purse
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+decision_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_PurseContractPublicKeyP *purse_pub,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ const struct TALER_Amount *purse_value)
+{
+ struct DecisionContext *ctx = cls;
+
+ (void) rowid;
+ (void) purse_value;
+ ctx->total++;
+ if ( (NULL != ctx->purse_pub) &&
+ (0 == GNUNET_memcmp (purse_pub,
+ ctx->purse_pub)) )
+ {
+ ctx->matched++;
+ ctx->have_reserve = (NULL != reserve_pub);
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Callback for
+ * #TALER_EXCHANGEDB_iterate_all_purse_decisions_above_serial_id().
+ *
+ * @param cls a `struct DecisionContext *`
+ * @param rowid row of the decision
+ * @param purse_pub the purse
+ * @param refunded whether the decision was to refund
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+all_decision_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_PurseContractPublicKeyP *purse_pub,
+ bool refunded)
+{
+ struct DecisionContext *ctx = cls;
+
+ (void) rowid;
+ ctx->total++;
+ if ( (NULL != ctx->purse_pub) &&
+ (0 == GNUNET_memcmp (purse_pub,
+ ctx->purse_pub)) )
+ {
+ ctx->matched++;
+ ctx->refunded = refunded;
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * With no purse to expire, nothing is decided and nothing is reported.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct DecisionContext ctx = { 0 };
+ struct GNUNET_TIME_Absolute expiration;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_do_expire_purse (pg,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ GNUNET_TIME_absolute_get (),
+ &expiration));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_purse_decisions_above_serial_id (
+ pg,
+ 0,
+ false,
+ &decision_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_all_purse_decisions_above_serial_id (
+ pg,
+ 0,
+ &all_decision_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * A purse that has not expired yet is left alone.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_not_expired (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct GNUNET_TIME_Absolute expiration;
+
+ /* expires in 2023 */
+ TDB_purse (pg,
+ 10,
+ "5",
+ ts (1700000000),
+ &purse);
+ /* ...but we only look at purses that expired before 2020 */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_do_expire_purse (pg,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ ts (1600000000).abs_time,
+ &expiration));
+ FAILIF (0 != TDB_count (pg,
+ "FROM purse_decision"));
+ return 0;
+}
+
+
+/**
+ * Expiring a purse decides it as a refund and gives the coins their
+ * money back.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_expire (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_CoinPublicInfo coin;
+ struct GNUNET_TIME_Absolute expiration;
+ struct DecisionContext ctx;
+ struct TALER_CoinSpendSignatureP coin_sig;
+ struct TALER_Amount amount = TDB_amount ("1");
+ bool balance_ok;
+ bool too_late;
+ bool conflict;
+ char *hex;
+
+ /* a purse that expired in 2020, with a coin paid into it */
+ TDB_purse (pg,
+ 11,
+ "5",
+ ts (1600000000),
+ &purse);
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ NULL);
+ TDB_FILL (coin_sig,
+ 20);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_purse_deposit (pg,
+ &purse.purse_pub,
+ &coin.coin_pub,
+ &amount,
+ &coin_sig,
+ &amount,
+ &balance_ok,
+ &too_late,
+ &conflict),
+ TDB_coin_free (&coin));
+ FAILIF_C (! balance_ok,
+ TDB_coin_free (&coin));
+ hex = TDB_hex (&coin.coin_pub,
+ sizeof (coin.coin_pub));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM known_coins"
+ " WHERE coin_pub=decode('%s','hex')"
+ " AND remaining=ROW(4,0)::taler_amount",
+ hex),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_expire_purse (pg,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ GNUNET_TIME_absolute_get (),
+ &expiration),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ FAILIF_C (expiration.abs_value_us !=
+ ts (1600000000).abs_time.abs_value_us,
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM purse_decision"),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ /* the coin got its money back */
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM known_coins"
+ " WHERE coin_pub=decode('%s','hex')"
+ " AND remaining=ROW(5,0)::taler_amount",
+ hex),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ GNUNET_free (hex);
+ TDB_coin_free (&coin);
+
+ /* the decision is a refund, so the by-refund iterator sees it... */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.purse_pub = &purse.purse_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_purse_decisions_above_serial_id (
+ pg,
+ 0,
+ true,
+ &decision_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ /* ...a refunded purse has no reserve to credit */
+ FAILIF (ctx.have_reserve);
+
+ /* ...and the non-refunded view does not */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.purse_pub = &purse.purse_pub;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_purse_decisions_above_serial_id (
+ pg,
+ 0,
+ false,
+ &decision_cb,
+ &ctx));
+ FAILIF (0 != ctx.matched);
+
+ /* the all-decisions view reports it with its refund flag */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.purse_pub = &purse.purse_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_all_purse_decisions_above_serial_id (
+ pg,
+ 0,
+ &all_decision_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (! ctx.refunded);
+
+ /* a second sweep finds nothing left to expire */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_do_expire_purse (pg,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ ts (1650000000).abs_time,
+ &expiration));
+ FAILIF (1 != TDB_count (pg,
+ "FROM purse_decision"));
+ return 0;
+}
+
+
+/**
+ * The iterators' serial bounds and abort returns behave as documented.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct DecisionContext ctx;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_all_purse_decisions_above_serial_id (
+ pg,
+ 1000,
+ &all_decision_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_all_purse_decisions_above_serial_id (
+ pg,
+ 0,
+ &all_decision_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "purse-decision-empty",
+ &check_empty },
+ { "purse-decision-not-expired",
+ &check_not_expired },
+ { "purse-decision-expire",
+ &check_expire },
+ { "purse-decision-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-purse-decision",
+ "Tests for the exchangedb `purse_decision' table",
+ tests);
+ TDB_denom_free (&denom);
+ return ret;
+}
+
+
+/* end of test_purse_decision.c */
diff --git a/src/exchangedb/test_purse_deletion.c b/src/exchangedb/test_purse_deletion.c
@@ -0,0 +1,410 @@
+/*
+ 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/test_purse_deletion.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `purse_deletion`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_purse_delete() and
+ * #TALER_EXCHANGEDB_iterate_all_purse_deletions_above_serial_id().
+ *
+ * Deleting a purse is only possible while it is undecided: the two
+ * negative answers of do_purse_delete() are "no such purse" and "already
+ * decided", and the checks below produce both.
+ */
+#include "test_common.h"
+#include "exchange-database/do_expire_purse.h"
+#include "exchange-database/do_purse_delete.h"
+#include "exchange-database/do_purse_deposit.h"
+#include "exchange-database/get_purse.h"
+#include "exchange-database/iterate_all_purse_deletions_above_serial_id.h"
+
+
+/**
+ * Denomination the checks deposit into purses.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #deletion_cb().
+ */
+struct DeletionContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Purse we are looking for, NULL to match nothing.
+ */
+ const struct TALER_PurseContractPublicKeyP *purse_pub;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Signature reported for it.
+ */
+ struct TALER_PurseContractSignatureP purse_sig;
+};
+
+
+/**
+ * Callback for
+ * #TALER_EXCHANGEDB_iterate_all_purse_deletions_above_serial_id().
+ *
+ * @param cls a `struct DeletionContext *`
+ * @param rowid row of the deletion
+ * @param purse_pub the purse that was deleted
+ * @param purse_sig signature affirming the deletion
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+deletion_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_PurseContractPublicKeyP *purse_pub,
+ const struct TALER_PurseContractSignatureP *purse_sig)
+{
+ struct DeletionContext *ctx = cls;
+
+ (void) rowid;
+ ctx->total++;
+ if ( (NULL != ctx->purse_pub) &&
+ (0 == GNUNET_memcmp (purse_pub,
+ ctx->purse_pub)) )
+ {
+ ctx->matched++;
+ ctx->purse_sig = *purse_sig;
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * A purse that does not exist cannot be deleted.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_unknown_purse (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_PurseContractPublicKeyP purse_pub;
+ struct TALER_PurseContractSignatureP purse_sig;
+ struct DeletionContext ctx = { 0 };
+ bool decided = true;
+ bool found = true;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_FILL (purse_pub,
+ 1);
+ TDB_FILL (purse_sig,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_purse_delete (pg,
+ &purse_pub,
+ &purse_sig,
+ &decided,
+ &found));
+ FAILIF (found);
+ FAILIF (0 != TDB_count (pg,
+ "FROM purse_deletion"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_all_purse_deletions_above_serial_id (
+ pg,
+ 0,
+ &deletion_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * Deleting an undecided purse records the deletion, refunds the coins in
+ * it and shows up in the purse's status.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_delete (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_PurseContractSignatureP purse_sig;
+ struct TALER_CoinSpendSignatureP coin_sig;
+ struct TALER_Amount amount = TDB_amount ("1");
+ struct GNUNET_TIME_Timestamp purse_creation;
+ struct GNUNET_TIME_Timestamp purse_expiration;
+ struct GNUNET_TIME_Timestamp merge_timestamp;
+ struct TALER_Amount target;
+ struct TALER_Amount deposited;
+ struct TALER_PrivateContractHashP h_contract_terms;
+ struct DeletionContext ctx;
+ bool balance_ok;
+ bool too_late;
+ bool conflict;
+ bool decided = true;
+ bool found = false;
+ bool purse_deleted = false;
+ bool purse_refunded = true;
+ char *hex;
+
+ TDB_purse (pg,
+ 10,
+ "5",
+ ts (1700000000),
+ &purse);
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ NULL);
+ TDB_FILL (coin_sig,
+ 20);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_purse_deposit (pg,
+ &purse.purse_pub,
+ &coin.coin_pub,
+ &amount,
+ &coin_sig,
+ &amount,
+ &balance_ok,
+ &too_late,
+ &conflict),
+ TDB_coin_free (&coin));
+ FAILIF_C (! balance_ok,
+ TDB_coin_free (&coin));
+
+ TDB_FILL (purse_sig,
+ 10);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_purse_delete (pg,
+ &purse.purse_pub,
+ &purse_sig,
+ &decided,
+ &found),
+ TDB_coin_free (&coin));
+ FAILIF_C (! found,
+ TDB_coin_free (&coin));
+ FAILIF_C (decided,
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM purse_deletion"),
+ TDB_coin_free (&coin));
+ /* the coin got its money back */
+ hex = TDB_hex (&coin.coin_pub,
+ sizeof (coin.coin_pub));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM known_coins"
+ " WHERE coin_pub=decode('%s','hex')"
+ " AND remaining=ROW(5,0)::taler_amount",
+ hex),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ GNUNET_free (hex);
+ TDB_coin_free (&coin);
+
+ /* the purse reports itself as deleted */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_purse (pg,
+ &purse.purse_pub,
+ &purse_creation,
+ &purse_expiration,
+ &target,
+ &deposited,
+ &h_contract_terms,
+ &merge_timestamp,
+ &purse_deleted,
+ &purse_refunded));
+ FAILIF (! purse_deleted);
+
+ /* the deletion is reported with the signature it was made with */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.purse_pub = &purse.purse_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_all_purse_deletions_above_serial_id (
+ pg,
+ 0,
+ &deletion_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (0 != GNUNET_memcmp (&ctx.purse_sig,
+ &purse_sig));
+
+ /* deleting it again finds it already decided */
+ decided = false;
+ found = false;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_purse_delete (pg,
+ &purse.purse_pub,
+ &purse_sig,
+ &decided,
+ &found));
+ FAILIF (! found);
+ FAILIF (! decided);
+ FAILIF (1 != TDB_count (pg,
+ "FROM purse_deletion"));
+ return 0;
+}
+
+
+/**
+ * A purse that expired first can no longer be deleted.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_already_decided (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_PurseContractSignatureP purse_sig;
+ struct GNUNET_TIME_Absolute expiration;
+ bool decided = false;
+ bool found = false;
+
+ TDB_purse (pg,
+ 11,
+ "5",
+ ts (1600000000),
+ &purse);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_expire_purse (pg,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ GNUNET_TIME_absolute_get (),
+ &expiration));
+ TDB_FILL (purse_sig,
+ 11);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_purse_delete (pg,
+ &purse.purse_pub,
+ &purse_sig,
+ &decided,
+ &found));
+ FAILIF (! found);
+ FAILIF (! decided);
+ FAILIF (1 != TDB_count (pg,
+ "FROM purse_deletion"));
+ return 0;
+}
+
+
+/**
+ * The iterator's serial bound and abort return behave as documented.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct DeletionContext ctx;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_all_purse_deletions_above_serial_id (
+ pg,
+ 1000,
+ &deletion_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_all_purse_deletions_above_serial_id (
+ pg,
+ 0,
+ &deletion_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "purse-deletion-unknown-purse",
+ &check_unknown_purse },
+ { "purse-deletion-delete",
+ &check_delete },
+ { "purse-deletion-already-decided",
+ &check_already_decided },
+ { "purse-deletion-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-purse-deletion",
+ "Tests for the exchangedb `purse_deletion' table",
+ tests);
+ TDB_denom_free (&denom);
+ return ret;
+}
+
+
+/* end of test_purse_deletion.c */
diff --git a/src/exchangedb/test_purse_deposits.c b/src/exchangedb/test_purse_deposits.c
@@ -0,0 +1,697 @@
+/*
+ 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/test_purse_deposits.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `purse_deposits`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_purse_deposit(),
+ * #TALER_EXCHANGEDB_get_purse_deposit(),
+ * #TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id() and
+ * #TALER_EXCHANGEDB_iterate_purse_deposits_by_purse().
+ *
+ * `purse_deposits` references `purse_requests` and `known_coins`, which
+ * TDB_purse() and TDB_coin() create. do_purse_deposit() declines in three
+ * ways: the coin has too little left, the purse has already been decided
+ * (too late), and the same coin was already paid into the purse with a
+ * different amount (conflict).
+ */
+#include "test_common.h"
+#include "exchange-database/do_expire_purse.h"
+#include "exchange-database/do_purse_deposit.h"
+#include "exchange-database/get_purse_deposit.h"
+#include "exchange-database/iterate_purse_deposits_above_serial_id.h"
+#include "exchange-database/iterate_purse_deposits_by_purse.h"
+#include "exchange-database/rollback.h"
+#include "exchange-database/start.h"
+
+
+/**
+ * Denomination the checks deposit.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #deposit_cb() and #refund_cb().
+ */
+struct DepositContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Coin we are looking for, NULL to match nothing.
+ */
+ const struct TALER_CoinSpendPublicKeyP *coin_pub;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Amount reported for it.
+ */
+ struct TALER_Amount amount;
+
+ /**
+ * Was a reserve reported for its purse?
+ */
+ bool have_reserve;
+};
+
+
+/**
+ * Callback for
+ * #TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id().
+ *
+ * @param cls a `struct DepositContext *`
+ * @param rowid row of the deposit
+ * @param deposit details of the deposit
+ * @param reserve_pub reserve the purse was merged into, NULL if none
+ * @param flags flags of the purse
+ * @param auditor_balance balance of the purse
+ * @param purse_total target amount of the purse
+ * @param denom_pub denomination of the coin
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+deposit_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_EXCHANGEDB_PurseDeposit *deposit,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ enum TALER_WalletAccountMergeFlags flags,
+ const struct TALER_Amount *auditor_balance,
+ const struct TALER_Amount *purse_total,
+ const struct TALER_DenominationPublicKey *denom_pub)
+{
+ struct DepositContext *ctx = cls;
+
+ (void) rowid;
+ (void) flags;
+ (void) auditor_balance;
+ (void) purse_total;
+ (void) denom_pub;
+ ctx->total++;
+ if ( (NULL != ctx->coin_pub) &&
+ (0 == GNUNET_memcmp (&deposit->coin_pub,
+ ctx->coin_pub)) )
+ {
+ ctx->matched++;
+ ctx->amount = deposit->amount;
+ ctx->have_reserve = (NULL != reserve_pub);
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_purse_deposits_by_purse().
+ *
+ * @param cls a `struct DepositContext *`
+ * @param rowid row of the deposit
+ * @param amount_with_fee how much was paid in
+ * @param coin_pub which coin paid
+ * @param denom_pub denomination of that coin
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+refund_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_Amount *amount_with_fee,
+ const struct TALER_CoinSpendPublicKeyP *coin_pub,
+ const struct TALER_DenominationPublicKey *denom_pub)
+{
+ struct DepositContext *ctx = cls;
+
+ (void) rowid;
+ (void) denom_pub;
+ ctx->total++;
+ if ( (NULL != ctx->coin_pub) &&
+ (0 == GNUNET_memcmp (coin_pub,
+ ctx->coin_pub)) )
+ {
+ ctx->matched++;
+ ctx->amount = *amount_with_fee;
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Outcome of a purse deposit.
+ */
+struct DepositStatus
+{
+ /**
+ * Did the coin have enough left?
+ */
+ bool balance_ok;
+
+ /**
+ * Was the purse already decided?
+ */
+ bool too_late;
+
+ /**
+ * Was the coin already paid into this purse differently?
+ */
+ bool conflict;
+};
+
+
+/**
+ * Pay a coin into a purse.
+ *
+ * @param pg the database context
+ * @param purse purse to credit
+ * @param coin coin to debit
+ * @param seed seed for the coin signature
+ * @param amount how much to take from the coin, e.g. "1"
+ * @param fee purse deposit fee included in @a amount, e.g. "0"
+ * @param[out] st set to the outcome
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+run_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Purse *purse,
+ const struct TALER_CoinPublicInfo *coin,
+ uint32_t seed,
+ const char *amount,
+ const char *fee,
+ struct DepositStatus *st)
+{
+ struct TALER_CoinSpendSignatureP coin_sig;
+ struct TALER_Amount a = TDB_amount (amount);
+ struct TALER_Amount f = TDB_amount (fee);
+ struct TALER_Amount net;
+
+ TDB_fill (&coin_sig,
+ sizeof (coin_sig),
+ seed);
+ GNUNET_assert (0 <=
+ TALER_amount_subtract (&net,
+ &a,
+ &f));
+ memset (st,
+ 0,
+ sizeof (*st));
+ return TALER_EXCHANGEDB_do_purse_deposit (pg,
+ &purse->purse_pub,
+ &coin->coin_pub,
+ &a,
+ &coin_sig,
+ &net,
+ &st->balance_ok,
+ &st->too_late,
+ &st->conflict);
+}
+
+
+/**
+ * Nothing is reported while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_PurseContractPublicKeyP purse_pub;
+ struct TALER_CoinSpendPublicKeyP coin_pub;
+ struct TALER_Amount amount;
+ struct TALER_DenominationHashP h_denom_pub;
+ struct TALER_AgeCommitmentHashP phac;
+ struct TALER_CoinSpendSignatureP coin_sig;
+ struct DepositContext ctx = { 0 };
+ char *partner_url = NULL;
+ bool no_age_commitment;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_FILL (purse_pub,
+ 1);
+ TDB_FILL (coin_pub,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_purse_deposit (pg,
+ &purse_pub,
+ &coin_pub,
+ &amount,
+ &h_denom_pub,
+ &phac,
+ &no_age_commitment,
+ &coin_sig,
+ &partner_url));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id (
+ pg,
+ 0,
+ &deposit_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_purse_deposits_by_purse (pg,
+ &purse_pub,
+ &refund_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * Paying a coin into a purse debits the coin and credits the purse.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_CoinPublicInfo coin;
+ struct DepositStatus st;
+ struct TALER_Amount amount;
+ struct TALER_Amount expect = TDB_amount ("1");
+ struct TALER_DenominationHashP h_denom_pub;
+ struct TALER_AgeCommitmentHashP phac;
+ struct TALER_CoinSpendSignatureP coin_sig;
+ struct TALER_CoinSpendSignatureP expect_sig;
+ char *partner_url = NULL;
+ bool no_age_commitment = false;
+
+ TDB_purse (pg,
+ 10,
+ "5",
+ ts (1700000000),
+ &purse);
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ NULL);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_deposit (pg,
+ &purse,
+ &coin,
+ 20,
+ "1",
+ "0",
+ &st),
+ TDB_coin_free (&coin));
+ FAILIF_C (! st.balance_ok,
+ TDB_coin_free (&coin));
+ FAILIF_C (st.too_late,
+ TDB_coin_free (&coin));
+ FAILIF_C (st.conflict,
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM purse_deposits"),
+ TDB_coin_free (&coin));
+
+ /* the coin was debited... */
+ {
+ char *hex = TDB_hex (&coin.coin_pub,
+ sizeof (coin.coin_pub));
+
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM known_coins"
+ " WHERE coin_pub=decode('%s','hex')"
+ " AND remaining=ROW(4,0)::taler_amount",
+ hex),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ GNUNET_free (hex);
+ }
+ /* ...and the purse credited */
+ {
+ char *hex = TDB_hex (&purse.purse_pub,
+ sizeof (purse.purse_pub));
+
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM purse_requests"
+ " WHERE purse_pub=decode('%s','hex')"
+ " AND balance=ROW(1,0)::taler_amount",
+ hex),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ GNUNET_free (hex);
+ }
+
+ TDB_FILL (expect_sig,
+ 20);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_purse_deposit (pg,
+ &purse.purse_pub,
+ &coin.coin_pub,
+ &amount,
+ &h_denom_pub,
+ &phac,
+ &no_age_commitment,
+ &coin_sig,
+ &partner_url),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != TALER_amount_cmp (&amount,
+ &expect),
+ GNUNET_free (partner_url); TDB_coin_free (&coin));
+ FAILIF_C (0 != GNUNET_memcmp (&h_denom_pub,
+ &denom.h_denom_pub),
+ GNUNET_free (partner_url); TDB_coin_free (&coin));
+ FAILIF_C (0 != GNUNET_memcmp (&coin_sig,
+ &expect_sig),
+ GNUNET_free (partner_url); TDB_coin_free (&coin));
+ FAILIF_C (! no_age_commitment,
+ GNUNET_free (partner_url); TDB_coin_free (&coin));
+ /* the purse is ours, so there is no partner exchange */
+ FAILIF_C (NULL != partner_url,
+ GNUNET_free (partner_url); TDB_coin_free (&coin));
+
+ /* re-depositing the same coin with the same amount is idempotent */
+ FAILIF_C (0 >
+ run_deposit (pg,
+ &purse,
+ &coin,
+ 20,
+ "1",
+ "0",
+ &st),
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM purse_deposits"),
+ TDB_coin_free (&coin));
+
+ /* ...but a second attempt with a different coin signature is a conflict:
+ the idempotency check compares the signature, not the amount */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_deposit (pg,
+ &purse,
+ &coin,
+ 29,
+ "1",
+ "0",
+ &st),
+ TDB_coin_free (&coin));
+ FAILIF_C (! st.conflict,
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM purse_deposits"),
+ TDB_coin_free (&coin));
+ TDB_coin_free (&coin);
+ return 0;
+}
+
+
+/**
+ * A coin without enough left cannot be paid into a purse.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insufficient_balance (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_CoinPublicInfo coin;
+ struct DepositStatus st;
+
+ TDB_purse (pg,
+ 10,
+ "5",
+ ts (1700000000),
+ &purse);
+ TDB_coin (pg,
+ &denom,
+ 21,
+ &coin,
+ NULL);
+ /* The `purse_deposits` row goes in before the coin balance is checked,
+ so the caller has to roll back -- which is what the /purses/.../deposit
+ handler does. */
+ FAILIF_C (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "test-purse-deposit-insufficient"),
+ TDB_coin_free (&coin));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_deposit (pg,
+ &purse,
+ &coin,
+ 21,
+ "6",
+ "0",
+ &st),
+ TALER_EXCHANGEDB_rollback (pg); TDB_coin_free (&coin));
+ TALER_EXCHANGEDB_rollback (pg);
+ TDB_coin_free (&coin);
+ FAILIF (st.balance_ok);
+ FAILIF (st.conflict);
+ FAILIF (1 != TDB_count (pg,
+ "FROM purse_deposits"));
+ return 0;
+}
+
+
+/**
+ * Once a merged purse has expired and been decided, the deposit that
+ * would complete it is refused as too late.
+ *
+ * Note what this shows about the shape of the function: the "too late"
+ * answer only comes out of the attempt to record the decision, which is
+ * only reached for a purse that has been merged *and* is completed by this
+ * deposit. A deposit into an expired purse that neither completes it nor
+ * has been merged is accepted -- see EDBT-9 in bugs.txt.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_too_late (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct DepositStatus st;
+ struct GNUNET_TIME_Absolute expiration;
+
+ /* a purse that expired in 2020, merged into a reserve */
+ TDB_purse (pg,
+ 11,
+ "5",
+ ts (1600000000),
+ &purse);
+ TDB_reserve (pg,
+ 11,
+ "0",
+ &reserve_pub);
+ {
+ char *phex = TDB_hex (&purse.purse_pub,
+ sizeof (purse.purse_pub));
+ char *rhex = TDB_hex (&reserve_pub,
+ sizeof (reserve_pub));
+
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "INSERT INTO purse_merges"
+ " (reserve_pub,purse_pub,merge_sig,merge_timestamp)"
+ " VALUES (decode('%s','hex'),decode('%s','hex')"
+ " ,decode(repeat('11',64),'hex'),1000000);",
+ rhex,
+ phex),
+ GNUNET_free (phex); GNUNET_free (rhex));
+ GNUNET_free (phex);
+ GNUNET_free (rhex);
+ }
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_do_expire_purse (pg,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ GNUNET_TIME_absolute_get (),
+ &expiration));
+ TDB_coin (pg,
+ &denom,
+ 22,
+ &coin,
+ NULL);
+ /* Pay in the full target amount: the "too late" answer only comes out
+ when the purse would be decided by this deposit, which is when the
+ function tries to insert the `purse_decision` row that already exists.
+ As with the balance check, the row is written first, so roll back. */
+ FAILIF_C (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "test-purse-deposit-too-late"),
+ TDB_coin_free (&coin));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_deposit (pg,
+ &purse,
+ &coin,
+ 22,
+ "5",
+ "0",
+ &st),
+ TALER_EXCHANGEDB_rollback (pg); TDB_coin_free (&coin));
+ TALER_EXCHANGEDB_rollback (pg);
+ TDB_coin_free (&coin);
+ FAILIF (! st.too_late);
+ FAILIF (1 != TDB_count (pg,
+ "FROM purse_deposits"));
+ return 0;
+}
+
+
+/**
+ * The iterators see the deposit; the by-purse one only for its own purse.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinSpendPublicKeyP coin_pub;
+ struct TALER_PurseContractPublicKeyP purse_pub;
+ struct DepositContext ctx;
+ struct TALER_Amount expect = TDB_amount ("1");
+
+ TDB_FILL (coin_pub,
+ 20);
+ TDB_FILL (purse_pub,
+ 10);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.coin_pub = &coin_pub;
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id (
+ pg,
+ 0,
+ &deposit_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (0 != TALER_amount_cmp (&ctx.amount,
+ &expect));
+ /* the purse was never merged, so there is no reserve */
+ FAILIF (ctx.have_reserve);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id (
+ pg,
+ 1000,
+ &deposit_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.coin_pub = &coin_pub;
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_purse_deposits_by_purse (pg,
+ &purse_pub,
+ &refund_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (0 != TALER_amount_cmp (&ctx.amount,
+ &expect));
+
+ /* a purse nobody paid into has no coins */
+ {
+ struct TALER_PurseContractPublicKeyP other;
+
+ TDB_FILL (other,
+ 11);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_purse_deposits_by_purse (pg,
+ &other,
+ &refund_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ }
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "purse-deposits-empty",
+ &check_empty },
+ { "purse-deposits-deposit",
+ &check_deposit },
+ { "purse-deposits-insufficient-balance",
+ &check_insufficient_balance },
+ { "purse-deposits-too-late",
+ &check_too_late },
+ { "purse-deposits-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-purse-deposits",
+ "Tests for the exchangedb `purse_deposits' table",
+ tests);
+ TDB_denom_free (&denom);
+ return ret;
+}
+
+
+/* end of test_purse_deposits.c */
diff --git a/src/exchangedb/test_purse_merges.c b/src/exchangedb/test_purse_merges.c
@@ -0,0 +1,759 @@
+/*
+ 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/test_purse_merges.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `purse_merges`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_purse_merge(),
+ * #TALER_EXCHANGEDB_get_purse_merge(),
+ * #TALER_EXCHANGEDB_iterate_purse_merges_above_serial_id(),
+ * #TALER_EXCHANGEDB_iterate_wallet_merges().
+ *
+ * A merge is the exchange acting on the merge capability key: it moves a
+ * paid-up purse into a reserve. do_purse_merge() therefore declines when
+ * the purse is not paid up and when the named partner exchange is unknown,
+ * and reports a conflict when the purse was already merged elsewhere.
+ */
+#include "test_common.h"
+#include "exchange-database/do_purse_deposit.h"
+#include "exchange-database/do_purse_merge.h"
+#include "exchange-database/get_purse_merge.h"
+#include "exchange-database/get_reserve.h"
+#include "exchange-database/iterate_purse_merges_above_serial_id.h"
+#include "exchange-database/iterate_wallet_merges.h"
+
+
+/**
+ * Denomination the checks pay purses with.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * Wallet account the reserve of the checks belongs to.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #merge_cb().
+ */
+struct MergeContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Purse we are looking for, NULL to match nothing.
+ */
+ const struct TALER_PurseContractPublicKeyP *purse_pub;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Reserve reported for it.
+ */
+ struct TALER_ReservePublicKeyP reserve_pub;
+
+ /**
+ * Amount reported for it.
+ */
+ struct TALER_Amount amount;
+
+ /**
+ * Was a partner exchange reported for it?
+ */
+ bool have_partner;
+};
+
+
+/**
+ * Callback for
+ * #TALER_EXCHANGEDB_iterate_purse_merges_above_serial_id().
+ *
+ * @param cls a `struct MergeContext *`
+ * @param rowid row of the merge
+ * @param partner_base_url partner exchange, NULL for us
+ * @param amount target amount of the purse
+ * @param balance balance of the purse
+ * @param flags flags of the purse
+ * @param merge_pub merge capability key
+ * @param reserve_pub reserve the purse went into
+ * @param merge_sig signature affirming the merge
+ * @param purse_pub the purse
+ * @param merge_timestamp when it was merged
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+merge_cb (void *cls,
+ uint64_t rowid,
+ const char *partner_base_url,
+ const struct TALER_Amount *amount,
+ const struct TALER_Amount *balance,
+ enum TALER_WalletAccountMergeFlags flags,
+ const struct TALER_PurseMergePublicKeyP *merge_pub,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ const struct TALER_PurseMergeSignatureP *merge_sig,
+ const struct TALER_PurseContractPublicKeyP *purse_pub,
+ struct GNUNET_TIME_Timestamp merge_timestamp)
+{
+ struct MergeContext *ctx = cls;
+
+ (void) rowid;
+ (void) balance;
+ (void) flags;
+ (void) merge_pub;
+ (void) merge_sig;
+ (void) merge_timestamp;
+ ctx->total++;
+ if ( (NULL != ctx->purse_pub) &&
+ (0 == GNUNET_memcmp (purse_pub,
+ ctx->purse_pub)) )
+ {
+ ctx->matched++;
+ ctx->reserve_pub = *reserve_pub;
+ ctx->amount = *amount;
+ ctx->have_partner = (NULL != partner_base_url);
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Closure for #transfer_cb() and #amount_cb().
+ */
+struct SumContext
+{
+ /**
+ * How many entries did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Sum of the whole-unit parts of the amounts seen.
+ */
+ uint64_t value_sum;
+
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_wallet_merges().
+ *
+ * @param cls a `struct SumContext *`
+ * @param row_id row of the merge
+ * @param payto_uri wallet account involved
+ * @param execution_time when the merge happened
+ * @param amount how much was merged
+ */
+static void
+transfer_cb (void *cls,
+ uint64_t row_id,
+ const char *payto_uri,
+ struct GNUNET_TIME_Absolute execution_time,
+ const struct TALER_Amount *amount)
+{
+ struct SumContext *ctx = cls;
+
+ (void) row_id;
+ (void) payto_uri;
+ (void) execution_time;
+ ctx->total++;
+ ctx->value_sum += amount->value;
+}
+
+
+/**
+ * Outcome of a merge.
+ */
+struct MergeStatus
+{
+ /**
+ * Was the named partner exchange unknown?
+ */
+ bool no_partner;
+
+ /**
+ * Was the purse not paid up?
+ */
+ bool no_balance;
+
+ /**
+ * Was the purse already merged elsewhere?
+ */
+ bool in_conflict;
+};
+
+
+/**
+ * Merge a purse into a reserve.
+ *
+ * @param pg the database context
+ * @param purse purse to merge
+ * @param reserve_pub reserve to credit
+ * @param h_payto normalized account of the reserve's wallet
+ * @param seed seed for the merge and reserve signatures
+ * @param partner_url partner exchange, NULL for us
+ * @param[out] st set to the outcome
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+run_merge (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Purse *purse,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ uint32_t seed,
+ const char *partner_url,
+ struct MergeStatus *st)
+{
+ struct TALER_PurseMergeSignatureP merge_sig;
+ struct TALER_ReserveSignatureP reserve_sig;
+
+ TDB_fill (&merge_sig,
+ sizeof (merge_sig),
+ seed);
+ TDB_fill (&reserve_sig,
+ sizeof (reserve_sig),
+ seed);
+ memset (st,
+ 0,
+ sizeof (*st));
+ return TALER_EXCHANGEDB_do_purse_merge (pg,
+ &purse->purse_pub,
+ &merge_sig,
+ ts (1600000000),
+ &reserve_sig,
+ partner_url,
+ reserve_pub,
+ &st->no_partner,
+ &st->no_balance,
+ &st->in_conflict);
+}
+
+
+/**
+ * Pay a purse up to its target amount.
+ *
+ * @param pg the database context
+ * @param purse purse to fill
+ * @param seed seed for the coin
+ * @param amount how much to pay in, e.g. "5"
+ */
+static void
+fill_purse (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Purse *purse,
+ uint32_t seed,
+ const char *amount)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_CoinSpendSignatureP coin_sig;
+ struct TALER_Amount a = TDB_amount (amount);
+ bool balance_ok;
+ bool too_late;
+ bool conflict;
+
+ TDB_coin (pg,
+ &denom,
+ seed,
+ &coin,
+ NULL);
+ TDB_fill (&coin_sig,
+ sizeof (coin_sig),
+ seed);
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_do_purse_deposit (pg,
+ &purse->purse_pub,
+ &coin.coin_pub,
+ &a,
+ &coin_sig,
+ &a,
+ &balance_ok,
+ &too_late,
+ &conflict));
+ GNUNET_assert (balance_ok);
+ TDB_coin_free (&coin);
+}
+
+
+/**
+ * Nothing is reported while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_PurseContractPublicKeyP purse_pub;
+ struct TALER_PurseMergeSignatureP merge_sig;
+ struct GNUNET_TIME_Timestamp merge_timestamp;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct MergeContext ctx = { 0 };
+ struct SumContext sctx = { 0 };
+ struct TALER_Amount zero = TDB_amount ("0");
+ char *partner_url = NULL;
+ bool refunded;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_FILL (purse_pub,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_purse_merge (pg,
+ &purse_pub,
+ &merge_sig,
+ &merge_timestamp,
+ &partner_url,
+ &reserve_pub,
+ &refunded));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_purse_merges_above_serial_id (
+ pg,
+ 0,
+ &merge_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_wallet_merges (pg,
+ &zero,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &sctx));
+ FAILIF (0 != ctx.total);
+ FAILIF (0 != sctx.total);
+ return 0;
+}
+
+
+/**
+ * A purse that has not been paid up cannot be merged.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_no_balance (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct MergeStatus st;
+
+ TDB_purse (pg,
+ 10,
+ "5",
+ ts (1700000000),
+ &purse);
+ TDB_reserve (pg,
+ 10,
+ "0",
+ &reserve_pub);
+ /* only EUR:1 of the EUR:5 has been paid in */
+ fill_purse (pg,
+ &purse,
+ 20,
+ "1");
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_merge (pg,
+ &purse,
+ &reserve_pub,
+ 10,
+ NULL,
+ &st));
+ FAILIF (! st.no_balance);
+ FAILIF (st.no_partner);
+ FAILIF (st.in_conflict);
+ FAILIF (0 != TDB_count (pg,
+ "FROM purse_merges"));
+ return 0;
+}
+
+
+/**
+ * A partner exchange the exchange does not know cannot receive a merge.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_no_partner (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct MergeStatus st;
+
+ TDB_purse (pg,
+ 10,
+ "5",
+ ts (1700000000),
+ &purse);
+ TDB_fill (&reserve_pub,
+ sizeof (reserve_pub),
+ 10);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_merge (pg,
+ &purse,
+ &reserve_pub,
+ 10,
+ "https://partner.example/",
+ &st));
+ FAILIF (! st.no_partner);
+ FAILIF (0 != TDB_count (pg,
+ "FROM purse_merges"));
+ return 0;
+}
+
+
+/**
+ * A paid-up purse is merged into the reserve, which is credited.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_merge (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_ReservePublicKeyP got_reserve;
+ struct TALER_EXCHANGEDB_Reserve reserve;
+ struct MergeStatus st;
+ struct MergeContext ctx;
+ struct TALER_PurseMergeSignatureP merge_sig;
+ struct TALER_PurseMergeSignatureP expect_sig;
+ struct GNUNET_TIME_Timestamp merge_timestamp;
+ struct TALER_Amount expect_balance = TDB_amount ("5");
+ char *partner_url = NULL;
+ bool refunded = true;
+
+ TDB_purse (pg,
+ 10,
+ "5",
+ ts (1700000000),
+ &purse);
+ TDB_fill (&reserve_pub,
+ sizeof (reserve_pub),
+ 10);
+ /* pay in the remaining EUR:4 */
+ fill_purse (pg,
+ &purse,
+ 21,
+ "4");
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_merge (pg,
+ &purse,
+ &reserve_pub,
+ 10,
+ NULL,
+ &st));
+ FAILIF (st.no_balance);
+ FAILIF (st.no_partner);
+ FAILIF (st.in_conflict);
+ FAILIF (1 != TDB_count (pg,
+ "FROM purse_merges"));
+
+ /* the reserve got the money */
+ memset (&reserve,
+ 0,
+ sizeof (reserve));
+ reserve.pub = reserve_pub;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve (pg,
+ &reserve));
+ FAILIF (0 != TALER_amount_cmp (&reserve.balance,
+ &expect_balance));
+
+ TDB_FILL (expect_sig,
+ 10);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_purse_merge (pg,
+ &purse.purse_pub,
+ &merge_sig,
+ &merge_timestamp,
+ &partner_url,
+ &got_reserve,
+ &refunded));
+ FAILIF (0 != GNUNET_memcmp (&merge_sig,
+ &expect_sig));
+ FAILIF (0 != GNUNET_memcmp (&got_reserve,
+ &reserve_pub));
+ FAILIF (GNUNET_TIME_timestamp_cmp (merge_timestamp,
+ !=,
+ ts (1600000000)));
+ FAILIF (refunded);
+ /* the purse stayed with us, so there is no partner exchange */
+ FAILIF (NULL != partner_url);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.purse_pub = &purse.purse_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_purse_merges_above_serial_id (
+ pg,
+ 0,
+ &merge_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (0 != GNUNET_memcmp (&ctx.reserve_pub,
+ &reserve_pub));
+ FAILIF (0 != TALER_amount_cmp (&ctx.amount,
+ &expect_balance));
+ FAILIF (ctx.have_partner);
+
+ /* merging the same purse into a different reserve is a conflict */
+ {
+ struct TALER_ReservePublicKeyP other;
+
+ TDB_reserve (pg,
+ 12,
+ "0",
+ &other);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_merge (pg,
+ &purse,
+ &other,
+ 12,
+ NULL,
+ &st));
+ FAILIF (! st.in_conflict);
+ FAILIF (1 != TDB_count (pg,
+ "FROM purse_merges"));
+ }
+ return 0;
+}
+
+
+/**
+ * The wallet's view of its merges reports the purse once the reserve is
+ * tied to a wallet account.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_wallet_merges (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_Amount zero = TDB_amount ("0");
+ struct TALER_Amount huge = TDB_amount ("1000");
+ struct SumContext ctx;
+ char *rhex;
+ char *nhex;
+
+ /* Until the reserve is known to belong to a wallet account, the view
+ has nothing to join against. */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_wallet_merges (pg,
+ &zero,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ TDB_fill (&reserve_pub,
+ sizeof (reserve_pub),
+ 10);
+ rhex = TDB_hex (&reserve_pub,
+ sizeof (reserve_pub));
+ nhex = TDB_hex (&account.h_normalized,
+ sizeof (account.h_normalized));
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE kyc_targets"
+ " SET target_pub=decode('%s','hex')"
+ " ,is_wallet=TRUE"
+ " WHERE h_normalized_payto=decode('%s','hex');",
+ rhex,
+ nhex),
+ GNUNET_free (rhex); GNUNET_free (nhex));
+ GNUNET_free (rhex);
+ GNUNET_free (nhex);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_wallet_merges (pg,
+ &zero,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (5 != ctx.value_sum);
+
+ /* filtering by that very account keeps it */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_wallet_merges (pg,
+ &zero,
+ 0,
+ 10,
+ &account.h_normalized,
+ &transfer_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+
+ /* a threshold nobody meets drops it */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_wallet_merges (pg,
+ &huge,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* and the negative limit walks the table the other way */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_wallet_merges (pg,
+ &zero,
+ 1000,
+ -10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The iterators' bounds and abort return behave as documented.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct MergeContext ctx;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_purse_merges_above_serial_id (
+ pg,
+ 1000,
+ &merge_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_purse_merges_above_serial_id (
+ pg,
+ 0,
+ &merge_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "purse-merges-empty",
+ &check_empty },
+ { "purse-merges-no-balance",
+ &check_no_balance },
+ { "purse-merges-no-partner",
+ &check_no_partner },
+ { "purse-merges-merge",
+ &check_merge },
+ { "purse-merges-wallet-merges",
+ &check_wallet_merges },
+ { "purse-merges-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-purse-merges",
+ "Tests for the exchangedb `purse_merges' table",
+ tests);
+ TDB_account_free (&account);
+ TDB_denom_free (&denom);
+ return ret;
+}
+
+
+/* end of test_purse_merges.c */
diff --git a/src/exchangedb/test_purse_requests.c b/src/exchangedb/test_purse_requests.c
@@ -0,0 +1,477 @@
+/*
+ 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/test_purse_requests.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `purse_requests`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_purse_request(),
+ * #TALER_EXCHANGEDB_get_purse_request(),
+ * #TALER_EXCHANGEDB_get_purse_by_merge_pub(),
+ * #TALER_EXCHANGEDB_get_purse() and
+ * #TALER_EXCHANGEDB_iterate_purse_requests_above_serial_id().
+ *
+ * `purse_requests` has no foreign keys -- a purse is created before
+ * anything is paid into it. The interesting case of the insert is the
+ * conflict: re-creating a purse with the same key but different meta data
+ * is reported through @e in_conflict rather than as an error.
+ */
+#include "test_common.h"
+#include "exchange-database/get_purse.h"
+#include "exchange-database/get_purse_by_merge_pub.h"
+#include "exchange-database/get_purse_request.h"
+#include "exchange-database/insert_purse_request.h"
+#include "exchange-database/iterate_purse_requests_above_serial_id.h"
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #purse_cb().
+ */
+struct PurseContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Purse we are looking for, NULL to match nothing.
+ */
+ const struct TALER_PurseContractPublicKeyP *purse_pub;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Target amount reported for it.
+ */
+ struct TALER_Amount amount;
+
+ /**
+ * Age limit reported for it.
+ */
+ uint32_t age_limit;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_purse_requests_above_serial_id().
+ *
+ * @param cls a `struct PurseContext *`
+ * @param rowid row of the purse
+ * @param purse_pub the purse
+ * @param merge_pub its merge capability key
+ * @param purse_creation when it was created
+ * @param purse_expiration when it expires
+ * @param h_contract_terms contract it is for
+ * @param age_limit age limit on deposits into it
+ * @param target_amount how much it is meant to hold
+ * @param purse_sig signature over its meta data
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+purse_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_PurseContractPublicKeyP *purse_pub,
+ const struct TALER_PurseMergePublicKeyP *merge_pub,
+ struct GNUNET_TIME_Timestamp purse_creation,
+ struct GNUNET_TIME_Timestamp purse_expiration,
+ const struct TALER_PrivateContractHashP *h_contract_terms,
+ uint32_t age_limit,
+ const struct TALER_Amount *target_amount,
+ const struct TALER_PurseContractSignatureP *purse_sig)
+{
+ struct PurseContext *ctx = cls;
+
+ (void) rowid;
+ (void) merge_pub;
+ (void) purse_creation;
+ (void) purse_expiration;
+ (void) h_contract_terms;
+ (void) purse_sig;
+ ctx->total++;
+ if ( (NULL != ctx->purse_pub) &&
+ (0 == GNUNET_memcmp (purse_pub,
+ ctx->purse_pub)) )
+ {
+ ctx->matched++;
+ ctx->amount = *target_amount;
+ ctx->age_limit = age_limit;
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Nothing is known while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_PurseContractPublicKeyP purse_pub;
+ struct TALER_PurseMergePublicKeyP merge_pub;
+ struct GNUNET_TIME_Timestamp purse_expiration;
+ struct GNUNET_TIME_Timestamp purse_creation;
+ struct GNUNET_TIME_Timestamp merge_timestamp;
+ struct TALER_PrivateContractHashP h_contract_terms;
+ struct TALER_Amount target_amount;
+ struct TALER_Amount balance;
+ struct TALER_PurseContractSignatureP purse_sig;
+ struct PurseContext ctx = { 0 };
+ uint32_t age_limit;
+ bool purse_deleted;
+ bool purse_refunded;
+
+ TDB_FILL (purse_pub,
+ 1);
+ TDB_FILL (merge_pub,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_purse_request (pg,
+ &purse_pub,
+ &merge_pub,
+ &purse_expiration,
+ &h_contract_terms,
+ &age_limit,
+ &target_amount,
+ &balance,
+ &purse_sig));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_purse_by_merge_pub (pg,
+ &merge_pub,
+ &purse_pub,
+ &purse_expiration,
+ &h_contract_terms,
+ &age_limit,
+ &target_amount,
+ &balance,
+ &purse_sig));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_purse (pg,
+ &purse_pub,
+ &purse_creation,
+ &purse_expiration,
+ &target_amount,
+ &balance,
+ &h_contract_terms,
+ &merge_timestamp,
+ &purse_deleted,
+ &purse_refunded));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_purse_requests_above_serial_id (
+ pg,
+ 0,
+ &purse_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * A created purse is found by its own key, by its merge key and by the
+ * combined status lookup.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_create_and_lookup (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_PurseContractPublicKeyP got_pub;
+ struct TALER_PurseMergePublicKeyP got_merge;
+ struct GNUNET_TIME_Timestamp purse_expiration;
+ struct GNUNET_TIME_Timestamp purse_creation;
+ struct GNUNET_TIME_Timestamp merge_timestamp;
+ struct TALER_PrivateContractHashP h_contract_terms;
+ struct TALER_Amount target_amount;
+ struct TALER_Amount balance;
+ struct TALER_Amount zero = TDB_amount ("0");
+ struct TALER_PurseContractSignatureP purse_sig;
+ uint32_t age_limit;
+ bool purse_deleted = true;
+ bool purse_refunded = true;
+
+ TDB_purse (pg,
+ 10,
+ "5",
+ ts (1700000000),
+ &purse);
+ FAILIF (1 != TDB_count (pg,
+ "FROM purse_requests"));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_purse_request (pg,
+ &purse.purse_pub,
+ &got_merge,
+ &purse_expiration,
+ &h_contract_terms,
+ &age_limit,
+ &target_amount,
+ &balance,
+ &purse_sig));
+ FAILIF (0 != GNUNET_memcmp (&got_merge,
+ &purse.merge_pub));
+ FAILIF (0 != GNUNET_memcmp (&h_contract_terms,
+ &purse.h_contract_terms));
+ FAILIF (0 != GNUNET_memcmp (&purse_sig,
+ &purse.purse_sig));
+ FAILIF (GNUNET_TIME_timestamp_cmp (purse_expiration,
+ !=,
+ purse.purse_expiration));
+ FAILIF (0 != TALER_amount_cmp (&target_amount,
+ &purse.amount));
+ /* nothing has been paid in yet */
+ FAILIF (0 != TALER_amount_cmp (&balance,
+ &zero));
+ FAILIF (0 != age_limit);
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_purse_by_merge_pub (pg,
+ &purse.merge_pub,
+ &got_pub,
+ &purse_expiration,
+ &h_contract_terms,
+ &age_limit,
+ &target_amount,
+ &balance,
+ &purse_sig));
+ FAILIF (0 != GNUNET_memcmp (&got_pub,
+ &purse.purse_pub));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_purse (pg,
+ &purse.purse_pub,
+ &purse_creation,
+ &purse_expiration,
+ &target_amount,
+ &balance,
+ &h_contract_terms,
+ &merge_timestamp,
+ &purse_deleted,
+ &purse_refunded));
+ FAILIF (purse_deleted);
+ FAILIF (purse_refunded);
+ FAILIF (0 != TALER_amount_cmp (&balance,
+ &zero));
+ /* the purse has not been merged */
+ FAILIF (! GNUNET_TIME_absolute_is_never (merge_timestamp.abs_time));
+
+ /* a merge key nobody used finds nothing */
+ {
+ struct TALER_PurseMergePublicKeyP other;
+
+ TDB_FILL (other,
+ 99);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_purse_by_merge_pub (pg,
+ &other,
+ &got_pub,
+ &purse_expiration,
+ &h_contract_terms,
+ &age_limit,
+ &target_amount,
+ &balance,
+ &purse_sig));
+ }
+ return 0;
+}
+
+
+/**
+ * Re-creating a purse with the same key is idempotent if the meta data
+ * matches and a conflict if it does not.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_conflict (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse purse;
+ struct TALER_Amount purse_fee = TDB_amount ("0");
+ struct TALER_Amount other_amount = TDB_amount ("9");
+ bool in_conflict = false;
+
+ /* TDB_purse() with the same seed replays the identical request */
+ TDB_purse (pg,
+ 10,
+ "5",
+ ts (1700000000),
+ &purse);
+ FAILIF (1 != TDB_count (pg,
+ "FROM purse_requests"));
+ /* An identical replay is reported as "nothing to do". Note that the
+ function leaves @e in_conflict untouched on that path, so only the
+ status says so. */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_insert_purse_request (
+ pg,
+ &purse.purse_pub,
+ &purse.merge_pub,
+ purse.purse_expiration,
+ &purse.h_contract_terms,
+ 0,
+ TALER_WAMF_MODE_CREATE_FROM_PURSE_QUOTA,
+ &purse_fee,
+ &purse.amount,
+ &purse.purse_sig,
+ &in_conflict));
+ FAILIF (in_conflict);
+
+ /* the same purse key with a different target amount is a conflict */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_purse_request (
+ pg,
+ &purse.purse_pub,
+ &purse.merge_pub,
+ purse.purse_expiration,
+ &purse.h_contract_terms,
+ 0,
+ TALER_WAMF_MODE_CREATE_FROM_PURSE_QUOTA,
+ &purse_fee,
+ &other_amount,
+ &purse.purse_sig,
+ &in_conflict));
+ FAILIF (! in_conflict);
+ FAILIF (1 != TDB_count (pg,
+ "FROM purse_requests"));
+ return 0;
+}
+
+
+/**
+ * The iterator walks the table by serial and honours an aborting
+ * callback.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Purse p2;
+ struct PurseContext ctx;
+
+ TDB_purse (pg,
+ 11,
+ "3",
+ ts (1700003600),
+ &p2);
+ FAILIF (2 != TDB_count (pg,
+ "FROM purse_requests"));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.purse_pub = &p2.purse_pub;
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_purse_requests_above_serial_id (
+ pg,
+ 0,
+ &purse_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (0 != TALER_amount_cmp (&ctx.amount,
+ &p2.amount));
+ FAILIF (0 != ctx.age_limit);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_purse_requests_above_serial_id (
+ pg,
+ 1000,
+ &purse_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_purse_requests_above_serial_id (
+ pg,
+ 0,
+ &purse_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "purse-requests-empty",
+ &check_empty },
+ { "purse-requests-create-and-lookup",
+ &check_create_and_lookup },
+ { "purse-requests-conflict",
+ &check_conflict },
+ { "purse-requests-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-purse-requests",
+ "Tests for the exchangedb `purse_requests' table",
+ tests);
+}
+
+
+/* end of test_purse_requests.c */
diff --git a/src/exchangedb/test_recoup.c b/src/exchangedb/test_recoup.c
@@ -0,0 +1,531 @@
+/*
+ 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/test_recoup.c
+ * @brief tests for the exchangedb functions whose primary table is `recoup`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_recoup() and
+ * #TALER_EXCHANGEDB_iterate_recoups_above_serial_id().
+ *
+ * `recoup` references `known_coins` and `withdraw`, so a full chain is
+ * built for each check: a funded reserve, a withdraw from it and a coin.
+ * do_recoup() moves the coin's whole remaining balance back to the reserve,
+ * which is why the checks look at both sides afterwards.
+ */
+#include "test_common.h"
+#include "exchange-database/do_recoup.h"
+#include "exchange-database/get_reserve.h"
+#include "exchange-database/iterate_recoups_above_serial_id.h"
+
+
+/**
+ * Account the checks fund their reserves from.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Denomination the checks use.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * Closure for #recoup_cb().
+ */
+struct RecoupContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Coin we are looking for, NULL to match nothing.
+ */
+ const struct TALER_CoinSpendPublicKeyP *coin_pub;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Amount reported for it.
+ */
+ struct TALER_Amount amount;
+
+ /**
+ * Reserve reported for it.
+ */
+ struct TALER_ReservePublicKeyP reserve_pub;
+
+ /**
+ * Blinding secret reported for it.
+ */
+ union GNUNET_CRYPTO_BlindingSecretP coin_blind;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_recoups_above_serial_id().
+ *
+ * @param cls a `struct RecoupContext *`
+ * @param rowid row of the recoup
+ * @param timestamp when the recoup was requested
+ * @param amount how much went back to the reserve
+ * @param reserve_pub the reserve that was credited
+ * @param coin the coin that was recouped
+ * @param denom_pub denomination of @a coin
+ * @param coin_sig signature over the request
+ * @param coin_blind blinding secret of the coin
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+recoup_cb (void *cls,
+ uint64_t rowid,
+ struct GNUNET_TIME_Timestamp timestamp,
+ const struct TALER_Amount *amount,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ const struct TALER_CoinPublicInfo *coin,
+ const struct TALER_DenominationPublicKey *denom_pub,
+ const struct TALER_CoinSpendSignatureP *coin_sig,
+ const union GNUNET_CRYPTO_BlindingSecretP *coin_blind)
+{
+ struct RecoupContext *ctx = cls;
+
+ (void) rowid;
+ (void) timestamp;
+ (void) denom_pub;
+ (void) coin_sig;
+ ctx->total++;
+ if ( (NULL != ctx->coin_pub) &&
+ (0 == GNUNET_memcmp (&coin->coin_pub,
+ ctx->coin_pub)) )
+ {
+ ctx->matched++;
+ ctx->amount = *amount;
+ ctx->reserve_pub = *reserve_pub;
+ ctx->coin_blind = *coin_blind;
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Outcome of a recoup request.
+ */
+struct RecoupStatus
+{
+ /**
+ * Was the recoup accepted?
+ */
+ bool recoup_ok;
+
+ /**
+ * Did something go wrong inside the database?
+ */
+ bool internal_failure;
+
+ /**
+ * When the recoup happened.
+ */
+ struct GNUNET_TIME_Timestamp recoup_timestamp;
+};
+
+
+/**
+ * Recoup a coin into a reserve.
+ *
+ * @param pg the database context
+ * @param reserve_pub reserve to credit
+ * @param withdraw_id withdraw that justifies the recoup
+ * @param coin_pub coin to recoup
+ * @param known_coin_id row of @a coin_pub
+ * @param seed seed for the blinding secret and coin signature
+ * @param[out] st set to the outcome
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+run_recoup (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ uint64_t withdraw_id,
+ const struct TALER_CoinSpendPublicKeyP *coin_pub,
+ uint64_t known_coin_id,
+ uint32_t seed,
+ struct RecoupStatus *st)
+{
+ union GNUNET_CRYPTO_BlindingSecretP coin_bks;
+ struct TALER_CoinSpendSignatureP coin_sig;
+
+ TDB_fill (&coin_bks,
+ sizeof (coin_bks),
+ seed);
+ TDB_fill (&coin_sig,
+ sizeof (coin_sig),
+ seed);
+ memset (st,
+ 0,
+ sizeof (*st));
+ st->recoup_timestamp = GNUNET_TIME_timestamp_get ();
+ return TALER_EXCHANGEDB_do_recoup (pg,
+ reserve_pub,
+ withdraw_id,
+ &coin_bks,
+ coin_pub,
+ known_coin_id,
+ &coin_sig,
+ &st->recoup_timestamp,
+ &st->recoup_ok,
+ &st->internal_failure);
+}
+
+
+/**
+ * Recouping a coin the exchange does not know is an internal failure.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_unknown_coin (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_CoinSpendPublicKeyP coin_pub;
+ struct RecoupContext ctx = { 0 };
+ struct RecoupStatus st;
+ uint64_t withdraw_id;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_reserve_in (pg,
+ &account,
+ 10,
+ "10",
+ &reserve_pub);
+ withdraw_id = TDB_withdraw (pg,
+ &denom,
+ &reserve_pub,
+ 10,
+ "5");
+ TDB_FILL (coin_pub,
+ 99);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &reserve_pub,
+ withdraw_id,
+ &coin_pub,
+ 1,
+ 99,
+ &st));
+ FAILIF (! st.internal_failure);
+ FAILIF (st.recoup_ok);
+ FAILIF (0 != TDB_count (pg,
+ "FROM recoup"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_recoups_above_serial_id (pg,
+ 0,
+ &recoup_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * A coin that is already empty and was never recouped is refused.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty_coin (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_CoinPublicInfo coin;
+ struct RecoupStatus st;
+ uint64_t known_coin_id;
+ uint64_t withdraw_id;
+ char *hex;
+
+ TDB_reserve_in (pg,
+ &account,
+ 11,
+ "10",
+ &reserve_pub);
+ withdraw_id = TDB_withdraw (pg,
+ &denom,
+ &reserve_pub,
+ 11,
+ "5");
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ &known_coin_id);
+ hex = TDB_hex (&coin.coin_pub,
+ sizeof (coin.coin_pub));
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE known_coins"
+ " SET remaining=ROW(0,0)::taler_amount"
+ " WHERE coin_pub=decode('%s','hex');",
+ hex),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ GNUNET_free (hex);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &reserve_pub,
+ withdraw_id,
+ &coin.coin_pub,
+ known_coin_id,
+ 20,
+ &st),
+ TDB_coin_free (&coin));
+ TDB_coin_free (&coin);
+ FAILIF (st.internal_failure);
+ /* nothing to give back, and no earlier recoup to report */
+ FAILIF (st.recoup_ok);
+ FAILIF (0 != TDB_count (pg,
+ "FROM recoup"));
+ return 0;
+}
+
+
+/**
+ * A funded coin is drained and the reserve is credited.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_recoup (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_EXCHANGEDB_Reserve reserve;
+ struct RecoupStatus st;
+ struct RecoupContext ctx;
+ struct TALER_Amount expect_coin = TDB_amount ("5");
+ struct TALER_Amount expect_reserve = TDB_amount ("10");
+ union GNUNET_CRYPTO_BlindingSecretP expect_bks;
+ uint64_t known_coin_id;
+ uint64_t withdraw_id;
+ char *hex;
+
+ TDB_reserve_in (pg,
+ &account,
+ 12,
+ "10",
+ &reserve_pub);
+ withdraw_id = TDB_withdraw (pg,
+ &denom,
+ &reserve_pub,
+ 12,
+ "5");
+ TDB_coin (pg,
+ &denom,
+ 21,
+ &coin,
+ &known_coin_id);
+ /* EUR:10 came in and EUR:5 was withdrawn, so EUR:5 is left */
+ memset (&reserve,
+ 0,
+ sizeof (reserve));
+ reserve.pub = reserve_pub;
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve (pg,
+ &reserve),
+ TDB_coin_free (&coin));
+
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &reserve_pub,
+ withdraw_id,
+ &coin.coin_pub,
+ known_coin_id,
+ 21,
+ &st),
+ TDB_coin_free (&coin));
+ FAILIF_C (st.internal_failure,
+ TDB_coin_free (&coin));
+ FAILIF_C (! st.recoup_ok,
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM recoup"),
+ TDB_coin_free (&coin));
+
+ /* the coin is empty... */
+ hex = TDB_hex (&coin.coin_pub,
+ sizeof (coin.coin_pub));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM known_coins"
+ " WHERE coin_pub=decode('%s','hex')"
+ " AND remaining=ROW(0,0)::taler_amount",
+ hex),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ GNUNET_free (hex);
+ /* ...and the reserve got the money */
+ memset (&reserve,
+ 0,
+ sizeof (reserve));
+ reserve.pub = reserve_pub;
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve (pg,
+ &reserve),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != TALER_amount_cmp (&reserve.balance,
+ &expect_reserve),
+ TDB_coin_free (&coin));
+
+ /* the recoup is reported with everything it was made with */
+ TDB_FILL (expect_bks,
+ 21);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.coin_pub = &coin.coin_pub;
+ FAILIF_C (0 >=
+ TALER_EXCHANGEDB_iterate_recoups_above_serial_id (pg,
+ 0,
+ &recoup_cb,
+ &ctx),
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != ctx.matched,
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != TALER_amount_cmp (&ctx.amount,
+ &expect_coin),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != GNUNET_memcmp (&ctx.reserve_pub,
+ &reserve_pub),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != GNUNET_memcmp (&ctx.coin_blind,
+ &expect_bks),
+ TDB_coin_free (&coin));
+
+ /* recouping the same coin again finds the earlier recoup */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &reserve_pub,
+ withdraw_id,
+ &coin.coin_pub,
+ known_coin_id,
+ 21,
+ &st),
+ TDB_coin_free (&coin));
+ FAILIF_C (! st.recoup_ok,
+ TDB_coin_free (&coin));
+ FAILIF_C (st.internal_failure,
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM recoup"),
+ TDB_coin_free (&coin));
+ TDB_coin_free (&coin);
+ return 0;
+}
+
+
+/**
+ * The iterator's serial bound and abort return behave as documented.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct RecoupContext ctx;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_recoups_above_serial_id (pg,
+ 0,
+ &recoup_cb,
+ &ctx));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_recoups_above_serial_id (pg,
+ 1000,
+ &recoup_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_recoups_above_serial_id (pg,
+ 0,
+ &recoup_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "recoup-unknown-coin",
+ &check_unknown_coin },
+ { "recoup-empty-coin",
+ &check_empty_coin },
+ { "recoup-recoup",
+ &check_recoup },
+ { "recoup-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-recoup",
+ "Tests for the exchangedb `recoup' table",
+ tests);
+ TDB_account_free (&account);
+ TDB_denom_free (&denom);
+ return ret;
+}
+
+
+/* end of test_recoup.c */
diff --git a/src/exchangedb/test_recoup_refresh.c b/src/exchangedb/test_recoup_refresh.c
@@ -0,0 +1,527 @@
+/*
+ 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/test_recoup_refresh.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `recoup_refresh`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_recoup_refresh() and, as far as it can be,
+ * #TALER_EXCHANGEDB_iterate_recoup_refreshes_above_serial_id().
+ *
+ * `recoup_refresh` references `known_coins` and `refresh`, so each check
+ * builds a melt first. do_recoup_refresh() moves the fresh coin's whole
+ * remaining balance back to the old coin.
+ *
+ * The iterator cannot be checked beyond "does not invent an answer": its
+ * statement joins `refresh_revealed_coins` and `refresh_commitments`, which
+ * the schema no longer has (EDB-16 in bugs.txt, in-tree FIXME-#9828).
+ */
+#include "test_common.h"
+#include "exchange-database/do_recoup_refresh.h"
+#include "exchange-database/do_refresh.h"
+#include "exchange-database/iterate_recoup_refreshes_above_serial_id.h"
+
+
+/**
+ * Account the checks fund their reserves from.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Denomination the checks use.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * Melt @a coin and return the row of the melt.
+ *
+ * @param pg the database context
+ * @param coin coin to melt
+ * @param seed seed for the commitment and signatures
+ * @param amount how much to melt, e.g. "1"
+ * @return row of the melt in `refresh`
+ */
+static uint64_t
+melt (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_CoinPublicInfo *coin,
+ uint32_t seed,
+ const char *amount)
+{
+ struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf;
+ struct TALER_BlindedDenominationSignature denom_sig;
+ struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get ();
+ struct TALER_Amount coin_balance;
+ uint64_t denom_serial = denom.serial;
+ uint64_t refresh_id;
+ uint32_t noreveal_index;
+ bool found;
+ bool zombie_required = false;
+ bool nonce_reuse;
+ bool balance_ok;
+
+ memset (&rf,
+ 0,
+ sizeof (rf));
+ rf.coin.coin_pub = coin->coin_pub;
+ rf.coin.denom_pub_hash = coin->denom_pub_hash;
+ rf.coin.no_age_commitment = coin->no_age_commitment;
+ TDB_fill (&rf.coin_sig,
+ sizeof (rf.coin_sig),
+ seed);
+ TDB_fill (&rf.rc,
+ sizeof (rf.rc),
+ seed);
+ TDB_fill (&rf.refresh_seed,
+ sizeof (rf.refresh_seed),
+ seed);
+ TDB_fill (&rf.planchets_h,
+ sizeof (rf.planchets_h),
+ seed);
+ TDB_fill (&rf.selected_h,
+ sizeof (rf.selected_h),
+ seed + 1);
+ rf.amount_with_fee = TDB_amount (amount);
+ rf.num_coins = 1;
+ rf.denom_serials = &denom_serial;
+ TDB_blinded_denom_sig (seed,
+ &denom_sig);
+ rf.denom_sigs = &denom_sig;
+ rf.noreveal_index = 0;
+ rf.is_v27_refresh = true;
+ rf.no_blinding_seed = true;
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_do_refresh (pg,
+ &rf,
+ &now,
+ &found,
+ &noreveal_index,
+ &zombie_required,
+ &nonce_reuse,
+ &balance_ok,
+ &coin_balance));
+ GNUNET_assert (balance_ok);
+ TALER_blinded_denom_sig_free (&denom_sig);
+ {
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (&rf.rc),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("refresh_id",
+ &refresh_id),
+ GNUNET_PQ_result_spec_end
+ };
+
+ GNUNET_assert (GNUNET_OK ==
+ GNUNET_PQ_prepare_anon (pg->conn,
+ "SELECT refresh_id"
+ " FROM refresh"
+ " WHERE rc=$1;"));
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs));
+ }
+ return refresh_id;
+}
+
+
+/**
+ * Callback for
+ * #TALER_EXCHANGEDB_iterate_recoup_refreshes_above_serial_id().
+ *
+ * @param cls a pointer to an `unsigned int` counter
+ * @param rowid row of the recoup
+ * @param timestamp when it happened
+ * @param amount how much went back
+ * @param old_coin_pub the old coin
+ * @param old_denom_pub_hash denomination of the old coin
+ * @param coin the fresh coin
+ * @param denom_pub denomination of @a coin
+ * @param coin_sig signature over the request
+ * @param coin_blind blinding secret of the fresh coin
+ * @return #GNUNET_OK
+ */
+static enum GNUNET_GenericReturnValue
+recoup_refresh_cb (void *cls,
+ uint64_t rowid,
+ struct GNUNET_TIME_Timestamp timestamp,
+ const struct TALER_Amount *amount,
+ const struct TALER_CoinSpendPublicKeyP *old_coin_pub,
+ const struct TALER_DenominationHashP *old_denom_pub_hash,
+ const struct TALER_CoinPublicInfo *coin,
+ const struct TALER_DenominationPublicKey *denom_pub,
+ const struct TALER_CoinSpendSignatureP *coin_sig,
+ const union GNUNET_CRYPTO_BlindingSecretP *coin_blind)
+{
+ unsigned int *total = cls;
+
+ (void) rowid;
+ (void) timestamp;
+ (void) amount;
+ (void) old_coin_pub;
+ (void) old_denom_pub_hash;
+ (void) coin;
+ (void) denom_pub;
+ (void) coin_sig;
+ (void) coin_blind;
+ (*total)++;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Outcome of a recoup-refresh request.
+ */
+struct RecoupStatus
+{
+ /**
+ * Was the recoup accepted?
+ */
+ bool recoup_ok;
+
+ /**
+ * Did something go wrong inside the database?
+ */
+ bool internal_failure;
+
+ /**
+ * When the recoup happened.
+ */
+ struct GNUNET_TIME_Timestamp recoup_timestamp;
+};
+
+
+/**
+ * Recoup a fresh coin back onto the coin it was refreshed from.
+ *
+ * @param pg the database context
+ * @param old_coin_pub coin to credit
+ * @param refresh_id melt that justifies the recoup
+ * @param coin_pub fresh coin to drain
+ * @param known_coin_id row of @a coin_pub
+ * @param seed seed for the blinding secret and coin signature
+ * @param[out] st set to the outcome
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+run_recoup (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_CoinSpendPublicKeyP *old_coin_pub,
+ uint64_t refresh_id,
+ const struct TALER_CoinSpendPublicKeyP *coin_pub,
+ uint64_t known_coin_id,
+ uint32_t seed,
+ struct RecoupStatus *st)
+{
+ union GNUNET_CRYPTO_BlindingSecretP coin_bks;
+ struct TALER_CoinSpendSignatureP coin_sig;
+
+ TDB_fill (&coin_bks,
+ sizeof (coin_bks),
+ seed);
+ TDB_fill (&coin_sig,
+ sizeof (coin_sig),
+ seed);
+ memset (st,
+ 0,
+ sizeof (*st));
+ st->recoup_timestamp = GNUNET_TIME_timestamp_get ();
+ return TALER_EXCHANGEDB_do_recoup_refresh (pg,
+ old_coin_pub,
+ refresh_id,
+ &coin_bks,
+ coin_pub,
+ known_coin_id,
+ &coin_sig,
+ &st->recoup_timestamp,
+ &st->recoup_ok,
+ &st->internal_failure);
+}
+
+
+/**
+ * Recouping a coin the exchange does not know is an internal failure.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_unknown_coin (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_CoinPublicInfo old_coin;
+ struct TALER_CoinSpendPublicKeyP coin_pub;
+ struct RecoupStatus st;
+ uint64_t refresh_id;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_reserve_in (pg,
+ &account,
+ 10,
+ "10",
+ &reserve_pub);
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &old_coin,
+ NULL);
+ refresh_id = melt (pg,
+ &old_coin,
+ 1,
+ "1");
+ TDB_FILL (coin_pub,
+ 99);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &old_coin.coin_pub,
+ refresh_id,
+ &coin_pub,
+ 1,
+ 99,
+ &st),
+ TDB_coin_free (&old_coin));
+ TDB_coin_free (&old_coin);
+ FAILIF (! st.internal_failure);
+ FAILIF (st.recoup_ok);
+ FAILIF (0 != TDB_count (pg,
+ "FROM recoup_refresh"));
+ return 0;
+}
+
+
+/**
+ * A fresh coin with nothing left on it and no earlier recoup is refused.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty_coin (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo old_coin;
+ struct TALER_CoinPublicInfo fresh;
+ struct RecoupStatus st;
+ uint64_t known_coin_id;
+ uint64_t refresh_id;
+ char *hex;
+
+ TDB_coin (pg,
+ &denom,
+ 21,
+ &old_coin,
+ NULL);
+ refresh_id = melt (pg,
+ &old_coin,
+ 2,
+ "1");
+ TDB_coin (pg,
+ &denom,
+ 22,
+ &fresh,
+ &known_coin_id);
+ hex = TDB_hex (&fresh.coin_pub,
+ sizeof (fresh.coin_pub));
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE known_coins"
+ " SET remaining=ROW(0,0)::taler_amount"
+ " WHERE coin_pub=decode('%s','hex');",
+ hex),
+ GNUNET_free (hex);
+ TDB_coin_free (&fresh); TDB_coin_free (&old_coin));
+ GNUNET_free (hex);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &old_coin.coin_pub,
+ refresh_id,
+ &fresh.coin_pub,
+ known_coin_id,
+ 22,
+ &st),
+ TDB_coin_free (&fresh); TDB_coin_free (&old_coin));
+ TDB_coin_free (&fresh);
+ TDB_coin_free (&old_coin);
+ FAILIF (st.internal_failure);
+ FAILIF (st.recoup_ok);
+ FAILIF (0 != TDB_count (pg,
+ "FROM recoup_refresh"));
+ return 0;
+}
+
+
+/**
+ * A funded fresh coin is drained back onto the old coin.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_recoup_refresh (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo old_coin;
+ struct TALER_CoinPublicInfo fresh;
+ struct RecoupStatus st;
+ uint64_t known_coin_id;
+ uint64_t refresh_id;
+ char *hex;
+
+ TDB_coin (pg,
+ &denom,
+ 23,
+ &old_coin,
+ NULL);
+ refresh_id = melt (pg,
+ &old_coin,
+ 3,
+ "1");
+ TDB_coin (pg,
+ &denom,
+ 24,
+ &fresh,
+ &known_coin_id);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &old_coin.coin_pub,
+ refresh_id,
+ &fresh.coin_pub,
+ known_coin_id,
+ 24,
+ &st),
+ TDB_coin_free (&fresh); TDB_coin_free (&old_coin));
+ FAILIF_C (st.internal_failure,
+ TDB_coin_free (&fresh); TDB_coin_free (&old_coin));
+ FAILIF_C (! st.recoup_ok,
+ TDB_coin_free (&fresh); TDB_coin_free (&old_coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM recoup_refresh"),
+ TDB_coin_free (&fresh); TDB_coin_free (&old_coin));
+
+ /* the fresh coin is empty */
+ hex = TDB_hex (&fresh.coin_pub,
+ sizeof (fresh.coin_pub));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM known_coins"
+ " WHERE coin_pub=decode('%s','hex')"
+ " AND remaining=ROW(0,0)::taler_amount",
+ hex),
+ GNUNET_free (hex);
+ TDB_coin_free (&fresh); TDB_coin_free (&old_coin));
+ GNUNET_free (hex);
+ /* the old coin got the EUR:5 back on top of the EUR:4 it had left */
+ hex = TDB_hex (&old_coin.coin_pub,
+ sizeof (old_coin.coin_pub));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM known_coins"
+ " WHERE coin_pub=decode('%s','hex')"
+ " AND remaining=ROW(9,0)::taler_amount",
+ hex),
+ GNUNET_free (hex);
+ TDB_coin_free (&fresh); TDB_coin_free (&old_coin));
+ GNUNET_free (hex);
+
+ /* recouping the same coin again finds the earlier recoup */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &old_coin.coin_pub,
+ refresh_id,
+ &fresh.coin_pub,
+ known_coin_id,
+ 24,
+ &st),
+ TDB_coin_free (&fresh); TDB_coin_free (&old_coin));
+ FAILIF_C (! st.recoup_ok,
+ TDB_coin_free (&fresh); TDB_coin_free (&old_coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM recoup_refresh"),
+ TDB_coin_free (&fresh); TDB_coin_free (&old_coin));
+ TDB_coin_free (&fresh);
+ TDB_coin_free (&old_coin);
+ return 0;
+}
+
+
+/**
+ * The iterator must not report a recoup-refresh that is not there.
+ *
+ * This is all it can be checked for today: its statement joins
+ * `refresh_revealed_coins` and `refresh_commitments`, tables the schema no
+ * longer has, so it cannot return a row at all (EDB-16). The assertion
+ * below holds both now and once that is repaired.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ unsigned int total = 0;
+
+ FAILIF (0 <
+ TALER_EXCHANGEDB_iterate_recoup_refreshes_above_serial_id (
+ pg,
+ 1000,
+ &recoup_refresh_cb,
+ &total));
+ FAILIF (0 != total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "recoup-refresh-unknown-coin",
+ &check_unknown_coin },
+ { "recoup-refresh-empty-coin",
+ &check_empty_coin },
+ { "recoup-refresh-recoup",
+ &check_recoup_refresh },
+ { "recoup-refresh-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-recoup-refresh",
+ "Tests for the exchangedb `recoup_refresh' table",
+ tests);
+ TDB_account_free (&account);
+ TDB_denom_free (&denom);
+ return ret;
+}
+
+
+/* end of test_recoup_refresh.c */
diff --git a/src/exchangedb/test_refresh.c b/src/exchangedb/test_refresh.c
@@ -0,0 +1,781 @@
+/*
+ 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/test_refresh.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `refresh`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_refresh(), #TALER_EXCHANGEDB_get_refresh(),
+ * #TALER_EXCHANGEDB_update_to_refresh_revealed(),
+ * #TALER_EXCHANGEDB_iterate_refreshes_above_serial_id() and, as far as it
+ * can be, #TALER_EXCHANGEDB_get_old_coin_by_h_blind().
+ *
+ * `refresh` references `known_coins`, which TDB_coin() creates. The
+ * do_refresh() answers checked here are: unknown coin, insufficient coin
+ * balance, the zombie requirement, blinding-seed reuse and an idempotent
+ * replay.
+ *
+ * get_old_coin_by_h_blind() cannot be checked beyond "does not invent an
+ * answer": its statement selects on a `h_blind_evs` column that the current
+ * `refresh` table does not have, so it cannot succeed at all -- see EDBT-8
+ * in bugs.txt.
+ */
+#include "test_common.h"
+#include "exchange-database/do_refresh.h"
+#include "exchange-database/get_known_coin.h"
+#include "exchange-database/get_old_coin_by_h_blind.h"
+#include "exchange-database/get_refresh.h"
+#include "exchange-database/iterate_refreshes_above_serial_id.h"
+#include "exchange-database/update_to_refresh_revealed.h"
+
+
+/**
+ * Denomination the checks melt.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * Fill in a refresh (melt) request for one fresh coin.
+ *
+ * @param seed seed for the commitment, signatures and seeds
+ * @param amount amount to melt, e.g. "1"
+ * @param coin coin being melted
+ * @param with_seed true to pass a blinding seed and a CS R value
+ * @param[out] rf set to the request; release with free_refresh()
+ */
+static void
+make_refresh (uint32_t seed,
+ const char *amount,
+ const struct TALER_CoinPublicInfo *coin,
+ bool with_seed,
+ struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS *rf)
+{
+ memset (rf,
+ 0,
+ sizeof (*rf));
+ rf->coin.coin_pub = coin->coin_pub;
+ rf->coin.denom_pub_hash = coin->denom_pub_hash;
+ rf->coin.no_age_commitment = coin->no_age_commitment;
+ TDB_fill (&rf->coin_sig,
+ sizeof (rf->coin_sig),
+ seed);
+ TDB_fill (&rf->rc,
+ sizeof (rf->rc),
+ seed);
+ TDB_fill (&rf->refresh_seed,
+ sizeof (rf->refresh_seed),
+ seed);
+ TDB_fill (&rf->planchets_h,
+ sizeof (rf->planchets_h),
+ seed);
+ TDB_fill (&rf->selected_h,
+ sizeof (rf->selected_h),
+ seed + 1);
+ rf->amount_with_fee = TDB_amount (amount);
+ rf->num_coins = 1;
+ rf->denom_serials = GNUNET_new (uint64_t);
+ rf->denom_serials[0] = denom.serial;
+ rf->denom_sigs = GNUNET_new (struct TALER_BlindedDenominationSignature);
+ TDB_blinded_denom_sig (seed,
+ &rf->denom_sigs[0]);
+ rf->noreveal_index = 1;
+ /* v27 refresh: the client provides no transfer public keys */
+ rf->is_v27_refresh = true;
+ rf->no_blinding_seed = ! with_seed;
+ if (with_seed)
+ {
+ TDB_fill (&rf->blinding_seed,
+ sizeof (rf->blinding_seed),
+ seed);
+ rf->num_cs_r_values = 1;
+ rf->cs_r_values = GNUNET_new (struct GNUNET_CRYPTO_CSPublicRPairP);
+ TDB_fill (rf->cs_r_values,
+ sizeof (*rf->cs_r_values),
+ seed);
+ rf->cs_r_choices = 0;
+ }
+}
+
+
+/**
+ * Release what make_refresh() allocated.
+ *
+ * @param[in,out] rf request to clean up
+ */
+static void
+free_refresh (struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS *rf)
+{
+ for (size_t i = 0; i<rf->num_coins; i++)
+ TALER_blinded_denom_sig_free (&rf->denom_sigs[i]);
+ GNUNET_free (rf->denom_sigs);
+ GNUNET_free (rf->denom_serials);
+ GNUNET_free (rf->cs_r_values);
+ GNUNET_free (rf->transfer_pubs);
+ GNUNET_free (rf->denom_pub_hashes);
+}
+
+
+/**
+ * Outcome flags of a refresh request.
+ */
+struct RefreshStatus
+{
+ /**
+ * Was there already a row for this commitment?
+ */
+ bool found;
+
+ /**
+ * Did the melt have to be of a zombie coin?
+ */
+ bool zombie_required;
+
+ /**
+ * Was the blinding seed used before?
+ */
+ bool nonce_reuse;
+
+ /**
+ * Was the coin balance sufficient?
+ */
+ bool balance_ok;
+
+ /**
+ * Balance the coin had.
+ */
+ struct TALER_Amount coin_balance;
+
+ /**
+ * Index the exchange chose not to reveal.
+ */
+ uint32_t noreveal_index;
+};
+
+
+/**
+ * Perform a refresh request.
+ *
+ * @param pg the database context
+ * @param rf the request
+ * @param zombie_required whether the coin must be a zombie
+ * @param[out] st set to the outcome
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+run_refresh (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS *rf,
+ bool zombie_required,
+ struct RefreshStatus *st)
+{
+ struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get ();
+
+ memset (st,
+ 0,
+ sizeof (*st));
+ st->zombie_required = zombie_required;
+ return TALER_EXCHANGEDB_do_refresh (pg,
+ rf,
+ &now,
+ &st->found,
+ &st->noreveal_index,
+ &st->zombie_required,
+ &st->nonce_reuse,
+ &st->balance_ok,
+ &st->coin_balance);
+}
+
+
+/**
+ * Melting a coin that is not known does nothing.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_unknown_coin (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf;
+ struct RefreshStatus st;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ memset (&coin,
+ 0,
+ sizeof (coin));
+ TDB_FILL (coin.coin_pub,
+ 1);
+ coin.denom_pub_hash = denom.h_denom_pub;
+ coin.no_age_commitment = true;
+ make_refresh (1,
+ "1",
+ &coin,
+ false,
+ &rf);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ run_refresh (pg,
+ &rf,
+ false,
+ &st),
+ free_refresh (&rf));
+ free_refresh (&rf);
+ FAILIF (0 != TDB_count (pg,
+ "FROM refresh"));
+ return 0;
+}
+
+
+/**
+ * Melting more than the coin is worth does nothing.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insufficient_balance (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf;
+ struct RefreshStatus st;
+ struct TALER_Amount expect = TDB_amount ("5");
+
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ NULL);
+ make_refresh (2,
+ "6",
+ &coin,
+ false,
+ &rf);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_refresh (pg,
+ &rf,
+ false,
+ &st),
+ free_refresh (&rf); TDB_coin_free (&coin));
+ free_refresh (&rf);
+ FAILIF_C (st.balance_ok,
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != TALER_amount_cmp (&st.coin_balance,
+ &expect),
+ TDB_coin_free (&coin));
+ /* the row was written before the balance was checked, so the caller has
+ to roll back -- here the check simply notes that it is there */
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM refresh"),
+ TDB_coin_free (&coin));
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "DELETE FROM refresh;"),
+ TDB_coin_free (&coin));
+ TDB_coin_free (&coin);
+ return 0;
+}
+
+
+/**
+ * A melt within the coin's balance is recorded and debits the coin.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_refresh (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_CoinPublicInfo got_coin;
+ struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf;
+ struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS got;
+ struct RefreshStatus st;
+ struct TALER_Amount expect_amount = TDB_amount ("1");
+
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ NULL);
+ make_refresh (3,
+ "1",
+ &coin,
+ true,
+ &rf);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_refresh (pg,
+ &rf,
+ false,
+ &st),
+ free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (! st.balance_ok,
+ free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (st.found,
+ free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (st.nonce_reuse,
+ free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM refresh"),
+ free_refresh (&rf); TDB_coin_free (&coin));
+
+ /* the melt is on file with everything it was created with */
+ memset (&got,
+ 0,
+ sizeof (got));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_refresh (pg,
+ &rf.rc,
+ &got),
+ free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (0 != TALER_amount_cmp (&got.amount_with_fee,
+ &expect_amount),
+ free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (0 != GNUNET_memcmp (&got.coin.coin_pub,
+ &coin.coin_pub),
+ free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (0 != GNUNET_memcmp (&got.coin_sig,
+ &rf.coin_sig),
+ free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (0 != GNUNET_memcmp (&got.refresh_seed,
+ &rf.refresh_seed),
+ free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (1 != got.num_coins,
+ free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (denom.serial != got.denom_serials[0],
+ free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (rf.noreveal_index != got.noreveal_index,
+ free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (got.revealed,
+ free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (got.no_blinding_seed,
+ free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (! got.is_v27_refresh,
+ free_refresh (&got); free_refresh (&rf); TDB_coin_free (&coin));
+ free_refresh (&got);
+
+ /* the coin was debited */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_known_coin (pg,
+ &coin.coin_pub,
+ &got_coin),
+ free_refresh (&rf); TDB_coin_free (&coin));
+ TALER_denom_sig_free (&got_coin.denom_sig);
+ {
+ char *hex = TDB_hex (&coin.coin_pub,
+ sizeof (coin.coin_pub));
+
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM known_coins"
+ " WHERE coin_pub=decode('%s','hex')"
+ " AND remaining=ROW(4,0)::taler_amount",
+ hex),
+ GNUNET_free (hex);
+ free_refresh (&rf); TDB_coin_free (&coin));
+ GNUNET_free (hex);
+ }
+
+ /* a replay is idempotent and reports the index we chose */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_refresh (pg,
+ &rf,
+ false,
+ &st),
+ free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (! st.found,
+ free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (rf.noreveal_index != st.noreveal_index,
+ free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM refresh"),
+ free_refresh (&rf); TDB_coin_free (&coin));
+ free_refresh (&rf);
+ TDB_coin_free (&coin);
+ return 0;
+}
+
+
+/**
+ * Reusing a blinding seed is refused.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_nonce_reuse (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf;
+ struct RefreshStatus st;
+
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ NULL);
+ /* a fresh commitment, but the blinding seed of check_refresh() */
+ make_refresh (4,
+ "1",
+ &coin,
+ true,
+ &rf);
+ TDB_fill (&rf.blinding_seed,
+ sizeof (rf.blinding_seed),
+ 3);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_refresh (pg,
+ &rf,
+ false,
+ &st),
+ free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (! st.nonce_reuse,
+ free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (st.found,
+ free_refresh (&rf); TDB_coin_free (&coin));
+ free_refresh (&rf);
+ TDB_coin_free (&coin);
+ FAILIF (1 != TDB_count (pg,
+ "FROM refresh"));
+ return 0;
+}
+
+
+/**
+ * A melt that insists on a zombie coin is refused when the coin never was
+ * one.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_zombie_required (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf;
+ struct RefreshStatus st;
+
+ TDB_coin (pg,
+ &denom,
+ 21,
+ &coin,
+ NULL);
+ make_refresh (5,
+ "1",
+ &coin,
+ false,
+ &rf);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_refresh (pg,
+ &rf,
+ true,
+ &st),
+ free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (! st.zombie_required,
+ free_refresh (&rf); TDB_coin_free (&coin));
+ FAILIF_C (st.balance_ok,
+ free_refresh (&rf); TDB_coin_free (&coin));
+ free_refresh (&rf);
+ TDB_coin_free (&coin);
+ /* the row went in before the zombie check, so the caller must roll back */
+ FAILIF (2 != TDB_count (pg,
+ "FROM refresh"));
+ FAILIF (GNUNET_OK !=
+ TDB_exec (pg,
+ "DELETE FROM refresh WHERE NOT revealed"
+ " AND rc <> (SELECT rc FROM refresh"
+ " ORDER BY refresh_id ASC LIMIT 1);"));
+ return 0;
+}
+
+
+/**
+ * A melt can be marked as revealed, and marking one that does not exist
+ * does nothing.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_revealed (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_RefreshCommitmentP rc;
+ struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS got;
+
+ TDB_FILL (rc,
+ 99);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_to_refresh_revealed (pg,
+ &rc));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_refresh (pg,
+ &rc,
+ &got));
+
+ TDB_FILL (rc,
+ 3);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_to_refresh_revealed (pg,
+ &rc));
+ memset (&got,
+ 0,
+ sizeof (got));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_refresh (pg,
+ &rc,
+ &got));
+ FAILIF_C (! got.revealed,
+ free_refresh (&got));
+ free_refresh (&got);
+ /* marking it again is still reported as a row touched */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_to_refresh_revealed (pg,
+ &rc));
+ return 0;
+}
+
+
+/**
+ * Closure for #refreshes_cb().
+ */
+struct RefreshesContext
+{
+ /**
+ * How many melts did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many melts; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Row of the last melt.
+ */
+ uint64_t rowid;
+
+ /**
+ * Amount of the last melt.
+ */
+ struct TALER_Amount amount;
+
+ /**
+ * Number of new denominations of the last melt.
+ */
+ size_t num_nds;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_refreshes_above_serial_id().
+ *
+ * @param cls a `struct RefreshesContext *`
+ * @param rowid row of the melt
+ * @param old_denom_pub denomination of the melted coin
+ * @param coin_pub the melted coin
+ * @param coin_sig signature authorising the melt
+ * @param h_age_commitment age commitment of the coin, NULL if none
+ * @param amount_with_fee how much was melted
+ * @param num_nds length of @a new_denom_serials
+ * @param new_denom_serials denominations of the fresh coins
+ * @param rc commitment of the melt
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+refreshes_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_DenominationPublicKey *old_denom_pub,
+ const struct TALER_CoinSpendPublicKeyP *coin_pub,
+ const struct TALER_CoinSpendSignatureP *coin_sig,
+ const struct TALER_AgeCommitmentHashP *h_age_commitment,
+ const struct TALER_Amount *amount_with_fee,
+ size_t num_nds,
+ uint64_t new_denom_serials[static num_nds],
+ const struct TALER_RefreshCommitmentP *rc)
+{
+ struct RefreshesContext *ctx = cls;
+
+ (void) old_denom_pub;
+ (void) coin_pub;
+ (void) coin_sig;
+ (void) h_age_commitment;
+ (void) new_denom_serials;
+ (void) rc;
+ ctx->total++;
+ ctx->rowid = rowid;
+ ctx->amount = *amount_with_fee;
+ ctx->num_nds = num_nds;
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * The auditor's view walks the melts by serial and stops when asked to.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_EXCHANGEDB_Refresh_vDOLDPLUS rf;
+ struct RefreshStatus st;
+ struct RefreshesContext ctx;
+ uint64_t rows;
+
+ /* one more melt, so that "stop after the first" is distinguishable
+ from "saw everything" */
+ TDB_coin (pg,
+ &denom,
+ 42,
+ &coin,
+ NULL);
+ make_refresh (42,
+ "1",
+ &coin,
+ false,
+ &rf);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_refresh (pg,
+ &rf,
+ false,
+ &st),
+ free_refresh (&rf); TDB_coin_free (&coin));
+ free_refresh (&rf);
+ TDB_coin_free (&coin);
+ FAILIF (! st.balance_ok);
+ rows = TDB_count (pg,
+ "FROM refresh");
+ FAILIF (2 != rows);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_refreshes_above_serial_id (
+ pg,
+ 0,
+ &refreshes_cb,
+ &ctx));
+ FAILIF (rows != ctx.total);
+ /* every melt here asked for exactly one fresh coin */
+ FAILIF (1 != ctx.num_nds);
+
+ /* everything above the last row is nothing */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_refreshes_above_serial_id (
+ pg,
+ UINT32_MAX,
+ &refreshes_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* a callback that says stop is not called again */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_refreshes_above_serial_id (
+ pg,
+ 0,
+ &refreshes_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * A blinded coin hash that was never seen must not be attributed to any
+ * old coin.
+ *
+ * This is all get_old_coin_by_h_blind() can be checked for today: its
+ * statement selects on a `h_blind_evs` column that `refresh` does not
+ * have, so it cannot return a row at all (EDBT-8). The assertion below
+ * holds both now and once that is repaired.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_old_coin_by_h_blind (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_BlindedCoinHashP h_blind_ev;
+ struct TALER_CoinSpendPublicKeyP old_coin_pub;
+ uint64_t rrc_serial;
+
+ TDB_FILL (h_blind_ev,
+ 123);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_get_old_coin_by_h_blind (pg,
+ &h_blind_ev,
+ &old_coin_pub,
+ &rrc_serial));
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "refresh-unknown-coin",
+ &check_unknown_coin },
+ { "refresh-insufficient-balance",
+ &check_insufficient_balance },
+ { "refresh-refresh",
+ &check_refresh },
+ { "refresh-nonce-reuse",
+ &check_nonce_reuse },
+ { "refresh-zombie-required",
+ &check_zombie_required },
+ { "refresh-revealed",
+ &check_revealed },
+ { "refresh-iterate",
+ &check_iterate },
+ { "refresh-old-coin-by-h-blind",
+ &check_old_coin_by_h_blind },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-refresh",
+ "Tests for the exchangedb `refresh' table",
+ tests);
+ TDB_denom_free (&denom);
+ return ret;
+}
+
+
+/* end of test_refresh.c */
diff --git a/src/exchangedb/test_refunds.c b/src/exchangedb/test_refunds.c
@@ -0,0 +1,707 @@
+/*
+ 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/test_refunds.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `refunds`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_refund(), #TALER_EXCHANGEDB_insert_refund(),
+ * #TALER_EXCHANGEDB_iterate_refunds_above_serial_id() and
+ * #TALER_EXCHANGEDB_iterate_refunds_by_coin().
+ *
+ * `refunds` references `coin_deposits`, so every check deposits a coin
+ * first with TDB_deposit(). do_refund() has three ways of declining:
+ * no such deposit, the merchant was already paid, and a reused refund
+ * transaction id.
+ */
+#include "test_common.h"
+#include "exchange-database/do_refund.h"
+#include "exchange-database/insert_refund.h"
+#include "exchange-database/iterate_refunds_above_serial_id.h"
+#include "exchange-database/iterate_refunds_by_coin.h"
+#include "exchange-database/rollback.h"
+#include "exchange-database/start.h"
+
+
+/**
+ * Account the merchants of the checks are paid at.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Denomination the checks deposit.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #refund_cb().
+ */
+struct RefundContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Coin we are looking for, NULL to match nothing.
+ */
+ const struct TALER_CoinSpendPublicKeyP *coin_pub;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Amount reported for it.
+ */
+ struct TALER_Amount amount;
+
+ /**
+ * Refund transaction id reported for it.
+ */
+ uint64_t rtransaction_id;
+
+ /**
+ * Was the deposit fully refunded?
+ */
+ bool full_refund;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_refunds_above_serial_id().
+ *
+ * @param cls a `struct RefundContext *`
+ * @param rowid row of the refund
+ * @param denom_pub denomination of the coin
+ * @param coin_pub the refunded coin
+ * @param merchant_pub merchant that refunded
+ * @param merchant_sig signature over the refund
+ * @param h_contract_terms contract the refund is for
+ * @param rtransaction_id merchant's refund transaction id
+ * @param full_refund whether the deposit is now fully refunded
+ * @param amount_with_fee how much was refunded
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+refund_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_DenominationPublicKey *denom_pub,
+ const struct TALER_CoinSpendPublicKeyP *coin_pub,
+ const struct TALER_MerchantPublicKeyP *merchant_pub,
+ const struct TALER_MerchantSignatureP *merchant_sig,
+ const struct TALER_PrivateContractHashP *h_contract_terms,
+ uint64_t rtransaction_id,
+ bool full_refund,
+ const struct TALER_Amount *amount_with_fee)
+{
+ struct RefundContext *ctx = cls;
+
+ (void) rowid;
+ (void) denom_pub;
+ (void) merchant_pub;
+ (void) merchant_sig;
+ (void) h_contract_terms;
+ ctx->total++;
+ if ( (NULL != ctx->coin_pub) &&
+ (0 == GNUNET_memcmp (coin_pub,
+ ctx->coin_pub)) )
+ {
+ ctx->matched++;
+ ctx->amount = *amount_with_fee;
+ ctx->rtransaction_id = rtransaction_id;
+ ctx->full_refund = full_refund;
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Closure for #coin_refund_cb().
+ */
+struct CoinRefundContext
+{
+ /**
+ * How many refunds did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Sum of the whole-unit parts of the amounts seen.
+ */
+ uint64_t value_sum;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_refunds_by_coin().
+ *
+ * @param cls a `struct CoinRefundContext *`
+ * @param amount_with_fee how much was refunded
+ * @return #GNUNET_OK
+ */
+static enum GNUNET_GenericReturnValue
+coin_refund_cb (void *cls,
+ const struct TALER_Amount *amount_with_fee)
+{
+ struct CoinRefundContext *ctx = cls;
+
+ ctx->total++;
+ ctx->value_sum += amount_with_fee->value;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Outcome of a refund request.
+ */
+struct RefundStatus
+{
+ /**
+ * Was there no such deposit?
+ */
+ bool not_found;
+
+ /**
+ * Was the refund accepted?
+ */
+ bool refund_ok;
+
+ /**
+ * Was the merchant already paid?
+ */
+ bool gone;
+
+ /**
+ * Was the refund transaction id reused?
+ */
+ bool conflict;
+};
+
+
+/**
+ * Refund part of a deposit.
+ *
+ * @param pg the database context
+ * @param coin coin to refund
+ * @param dep deposit to refund
+ * @param seed seed for the merchant signature
+ * @param rtransaction_id merchant's refund transaction id
+ * @param amount how much to refund, e.g. "0.5"
+ * @param[out] st set to the outcome
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+run_refund (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_CoinPublicInfo *coin,
+ const struct TDB_Deposit *dep,
+ uint32_t seed,
+ uint64_t rtransaction_id,
+ const char *amount,
+ struct RefundStatus *st)
+{
+ struct TALER_EXCHANGEDB_Refund refund;
+ struct TALER_Amount deposit_fee = TDB_amount ("0.1");
+
+ memset (&refund,
+ 0,
+ sizeof (refund));
+ refund.coin = *coin;
+ refund.details.merchant_pub = dep->merchant_pub;
+ TDB_fill (&refund.details.merchant_sig,
+ sizeof (refund.details.merchant_sig),
+ seed);
+ refund.details.h_contract_terms = dep->h_contract_terms;
+ refund.details.rtransaction_id = rtransaction_id;
+ refund.details.refund_amount = TDB_amount (amount);
+ refund.details.refund_fee = TDB_amount ("0");
+ memset (st,
+ 0,
+ sizeof (*st));
+ return TALER_EXCHANGEDB_do_refund (pg,
+ &refund,
+ &deposit_fee,
+ 0,
+ &st->not_found,
+ &st->refund_ok,
+ &st->gone,
+ &st->conflict);
+}
+
+
+/**
+ * Nothing is reported while the table is empty, and a refund of a deposit
+ * that does not exist is refused.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TDB_Deposit dep;
+ struct RefundContext ctx = { 0 };
+ struct CoinRefundContext cctx = { 0 };
+ struct RefundStatus st;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ NULL);
+ memset (&dep,
+ 0,
+ sizeof (dep));
+ TDB_FILL (dep.merchant_pub,
+ 98);
+ TDB_FILL (dep.h_contract_terms,
+ 98);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_refund (pg,
+ &coin,
+ &dep,
+ 98,
+ 1,
+ "1",
+ &st),
+ TDB_coin_free (&coin));
+ TDB_coin_free (&coin);
+ FAILIF (! st.not_found);
+ FAILIF (st.refund_ok);
+ FAILIF (0 != TDB_count (pg,
+ "FROM refunds"));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
+ 0,
+ &refund_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_refunds_by_coin (pg,
+ &coin.coin_pub,
+ &dep.merchant_pub,
+ &dep.h_contract_terms,
+ &coin_refund_cb,
+ &cctx));
+ FAILIF (0 != ctx.total);
+ FAILIF (0 != cctx.total);
+ return 0;
+}
+
+
+/**
+ * A partial refund is recorded and reported.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_refund (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TDB_Deposit dep;
+ struct RefundStatus st;
+ struct RefundContext ctx;
+ struct CoinRefundContext cctx;
+ struct TALER_Amount expect = TDB_amount ("0.5");
+
+ TDB_coin (pg,
+ &denom,
+ 20,
+ &coin,
+ NULL);
+ TDB_deposit (pg,
+ &account,
+ &coin,
+ 20,
+ "1",
+ "0.1",
+ ts (1600000000),
+ ts (1600000000),
+ &dep);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_refund (pg,
+ &coin,
+ &dep,
+ 20,
+ 1,
+ "0.5",
+ &st),
+ TDB_coin_free (&coin));
+ FAILIF_C (st.not_found,
+ TDB_coin_free (&coin));
+ FAILIF_C (! st.refund_ok,
+ TDB_coin_free (&coin));
+ FAILIF_C (st.gone,
+ TDB_coin_free (&coin));
+ FAILIF_C (st.conflict,
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM refunds"),
+ TDB_coin_free (&coin));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.coin_pub = &coin.coin_pub;
+ FAILIF_C (1 !=
+ TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
+ 0,
+ &refund_cb,
+ &ctx),
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != ctx.matched,
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != TALER_amount_cmp (&ctx.amount,
+ &expect),
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != ctx.rtransaction_id,
+ TDB_coin_free (&coin));
+ /* only half of the deposit was refunded */
+ FAILIF_C (ctx.full_refund,
+ TDB_coin_free (&coin));
+
+ memset (&cctx,
+ 0,
+ sizeof (cctx));
+ FAILIF_C (1 !=
+ TALER_EXCHANGEDB_iterate_refunds_by_coin (pg,
+ &coin.coin_pub,
+ &dep.merchant_pub,
+ &dep.h_contract_terms,
+ &coin_refund_cb,
+ &cctx),
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != cctx.total,
+ TDB_coin_free (&coin));
+
+ /* a contract nobody refunded has no refunds */
+ {
+ struct TALER_PrivateContractHashP other;
+
+ TDB_FILL (other,
+ 97);
+ memset (&cctx,
+ 0,
+ sizeof (cctx));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_refunds_by_coin (pg,
+ &coin.coin_pub,
+ &dep.merchant_pub,
+ &other,
+ &coin_refund_cb,
+ &cctx),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 != cctx.total,
+ TDB_coin_free (&coin));
+ }
+
+ /* reusing the refund transaction id is a conflict */
+ FAILIF_C (0 >
+ run_refund (pg,
+ &coin,
+ &dep,
+ 21,
+ 1,
+ "0.25",
+ &st),
+ TDB_coin_free (&coin));
+ FAILIF_C (! st.conflict,
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM refunds"),
+ TDB_coin_free (&coin));
+
+ /* a second refund with a fresh id completes the deposit */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_refund (pg,
+ &coin,
+ &dep,
+ 22,
+ 2,
+ "0.5",
+ &st),
+ TDB_coin_free (&coin));
+ FAILIF_C (! st.refund_ok,
+ TDB_coin_free (&coin));
+ memset (&cctx,
+ 0,
+ sizeof (cctx));
+ FAILIF_C (2 !=
+ TALER_EXCHANGEDB_iterate_refunds_by_coin (pg,
+ &coin.coin_pub,
+ &dep.merchant_pub,
+ &dep.h_contract_terms,
+ &coin_refund_cb,
+ &cctx),
+ TDB_coin_free (&coin));
+ FAILIF_C (2 != cctx.total,
+ TDB_coin_free (&coin));
+ TDB_coin_free (&coin);
+ return 0;
+}
+
+
+/**
+ * Refunding a deposit the exchange has already paid out is refused.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_gone (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TDB_Deposit dep;
+ struct RefundStatus st;
+
+ TDB_coin (pg,
+ &denom,
+ 23,
+ &coin,
+ NULL);
+ TDB_deposit (pg,
+ &account,
+ &coin,
+ 23,
+ "1",
+ "0.1",
+ ts (1600000000),
+ ts (1600000000),
+ &dep);
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE batch_deposits"
+ " SET done=TRUE"
+ " WHERE batch_deposit_serial_id=%llu;",
+ (unsigned long long) dep.serial),
+ TDB_coin_free (&coin));
+ /* The row goes in before the "already wired out" check, so this is how
+ the /refund handler uses the function: inside a transaction it can
+ roll back. */
+ FAILIF_C (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "test-refund-gone"),
+ TDB_coin_free (&coin));
+ FAILIF_C (0 >
+ run_refund (pg,
+ &coin,
+ &dep,
+ 23,
+ 1,
+ "0.5",
+ &st),
+ TALER_EXCHANGEDB_rollback (pg); TDB_coin_free (&coin));
+ TALER_EXCHANGEDB_rollback (pg);
+ TDB_coin_free (&coin);
+ FAILIF (! st.gone);
+ FAILIF (st.refund_ok);
+ FAILIF (2 != TDB_count (pg,
+ "FROM refunds"));
+ return 0;
+}
+
+
+/**
+ * insert_refund() writes the row without any of the checks do_refund()
+ * makes, and finds the deposit through (coin, merchant, contract).
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_refund (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo coin;
+ struct TDB_Deposit dep;
+ struct TALER_EXCHANGEDB_Refund refund;
+ struct CoinRefundContext cctx;
+
+ TDB_coin (pg,
+ &denom,
+ 24,
+ &coin,
+ NULL);
+ TDB_deposit (pg,
+ &account,
+ &coin,
+ 24,
+ "2",
+ "0.1",
+ ts (1600000000),
+ ts (1600000000),
+ &dep);
+ memset (&refund,
+ 0,
+ sizeof (refund));
+ refund.coin = coin;
+ refund.details.merchant_pub = dep.merchant_pub;
+ TDB_FILL (refund.details.merchant_sig,
+ 24);
+ refund.details.h_contract_terms = dep.h_contract_terms;
+ refund.details.rtransaction_id = 7;
+ refund.details.refund_amount = TDB_amount ("1");
+ refund.details.refund_fee = TDB_amount ("0");
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_refund (pg,
+ &refund),
+ TDB_coin_free (&coin));
+ memset (&cctx,
+ 0,
+ sizeof (cctx));
+ FAILIF_C (1 !=
+ TALER_EXCHANGEDB_iterate_refunds_by_coin (pg,
+ &coin.coin_pub,
+ &dep.merchant_pub,
+ &dep.h_contract_terms,
+ &coin_refund_cb,
+ &cctx),
+ TDB_coin_free (&coin));
+ FAILIF_C (1 != cctx.value_sum,
+ TDB_coin_free (&coin));
+
+ /* a contract that has no deposit selects no row to insert against */
+ TDB_FILL (refund.details.h_contract_terms,
+ 96);
+ refund.details.rtransaction_id = 8;
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_insert_refund (pg,
+ &refund),
+ TDB_coin_free (&coin));
+ TDB_coin_free (&coin);
+ FAILIF (3 != TDB_count (pg,
+ "FROM refunds"));
+ return 0;
+}
+
+
+/**
+ * The serial iterator's bound and abort return behave as documented.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct RefundContext ctx;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
+ 0,
+ &refund_cb,
+ &ctx));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
+ 1000,
+ &refund_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_refunds_above_serial_id (pg,
+ 0,
+ &refund_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "refunds-empty",
+ &check_empty },
+ { "refunds-refund",
+ &check_refund },
+ { "refunds-gone",
+ &check_gone },
+ { "refunds-insert-refund",
+ &check_insert_refund },
+ { "refunds-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-refunds",
+ "Tests for the exchangedb `refunds' table",
+ tests);
+ TDB_account_free (&account);
+ TDB_denom_free (&denom);
+ return ret;
+}
+
+
+/* end of test_refunds.c */
diff --git a/src/exchangedb/test_replication.c b/src/exchangedb/test_replication.c
@@ -0,0 +1,480 @@
+/*
+ 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/test_replication.c
+ * @brief tests for the exchangedb replication functions
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_get_serial_by_table(),
+ * #TALER_EXCHANGEDB_iterate_records_by_table() and
+ * #TALER_EXCHANGEDB_insert_records_by_table().
+ *
+ * These three have no table of their own: each is one big switch over
+ * #TALER_EXCHANGEDB_ReplicatedTable, and taler-auditor-sync drives them in
+ * a loop over every value of that enum. A table that is missing from one
+ * of the switches, or whose SQL no longer matches the schema, breaks
+ * replication for that table only -- and nothing else in the test suite
+ * would notice. So the checks here walk the whole enum, and then do one
+ * full round trip (read a record out, delete it, put it back) to show that
+ * the reader and the writer agree.
+ */
+#include "test_common.h"
+#include "exchange-database/get_serial_by_table.h"
+#include "exchange-database/insert_records_by_table.h"
+#include "exchange-database/iterate_records_by_table.h"
+
+
+/**
+ * The two accounts we replicate.
+ */
+static struct TDB_Account account[2];
+
+
+/**
+ * Denomination the checks use.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * Value we gave @e denom.
+ */
+#define DENOM_VALUE "5"
+
+
+/**
+ * Closure for #record_cb().
+ */
+struct RecordContext
+{
+ /**
+ * How many records did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Table we expect the records to be from.
+ */
+ enum TALER_EXCHANGEDB_ReplicatedTable table;
+
+ /**
+ * Set to true if a record from another table arrived.
+ */
+ bool wrong_table;
+
+ /**
+ * Serial of the last record.
+ */
+ uint64_t serial;
+
+ /**
+ * Payto URI we are looking for, NULL to look for none.
+ */
+ const char *payto;
+
+ /**
+ * Serial the record with @e payto had, 0 if it did not show up.
+ */
+ uint64_t payto_serial;
+
+ /**
+ * Value of the last denomination record.
+ */
+ struct TALER_Amount coin;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_records_by_table().
+ *
+ * @param cls a `struct RecordContext *`
+ * @param td the record
+ * @return #GNUNET_OK to continue iterating
+ */
+static int
+record_cb (void *cls,
+ const struct TALER_EXCHANGEDB_TableData *td)
+{
+ struct RecordContext *ctx = cls;
+
+ ctx->total++;
+ ctx->serial = td->serial;
+ if (td->table != ctx->table)
+ ctx->wrong_table = true;
+ switch (td->table)
+ {
+ case TALER_EXCHANGEDB_RT_DENOMINATIONS:
+ ctx->coin = td->details.denominations.coin;
+ break;
+ case TALER_EXCHANGEDB_RT_WIRE_TARGETS:
+ if ( (NULL != ctx->payto) &&
+ (0 == strcmp (ctx->payto,
+ td->details.wire_targets.full_payto_uri.full_payto)) )
+ ctx->payto_serial = td->serial;
+ break;
+ default:
+ break;
+ }
+ return GNUNET_OK;
+}
+
+
+/**
+ * Every replicated table must have a serial statement, and on an empty
+ * database none of them has a row.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_all_serials (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ for (unsigned int t = 0;
+ t <= TALER_EXCHANGEDB_RT_KYCAUTHS_IN;
+ t++)
+ {
+ uint64_t serial = UINT64_MAX;
+ enum GNUNET_DB_QueryStatus qs;
+
+ qs = TALER_EXCHANGEDB_get_serial_by_table (
+ pg,
+ (enum TALER_EXCHANGEDB_ReplicatedTable) t,
+ &serial);
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != qs)
+ fprintf (stderr,
+ "get_serial_by_table(%u) returned %d\n",
+ t,
+ (int) qs);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != qs);
+ }
+ return 0;
+}
+
+
+/**
+ * Every replicated table must be readable, and on an empty database none
+ * of them yields a record.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_all_iterable (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ for (unsigned int t = 0;
+ t <= TALER_EXCHANGEDB_RT_KYCAUTHS_IN;
+ t++)
+ {
+ struct RecordContext ctx = {
+ .table = (enum TALER_EXCHANGEDB_ReplicatedTable) t
+ };
+ enum GNUNET_DB_QueryStatus qs;
+
+ qs = TALER_EXCHANGEDB_iterate_records_by_table (
+ pg,
+ (enum TALER_EXCHANGEDB_ReplicatedTable) t,
+ 0,
+ &record_cb,
+ &ctx);
+ if (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != qs)
+ fprintf (stderr,
+ "iterate_records_by_table(%u) returned %d\n",
+ t,
+ (int) qs);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != qs);
+ FAILIF (0 != ctx.total);
+ }
+ return 0;
+}
+
+
+/**
+ * A table outside the enum is refused by all three functions.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_unknown_table (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ enum TALER_EXCHANGEDB_ReplicatedTable bogus
+ = (enum TALER_EXCHANGEDB_ReplicatedTable) (TALER_EXCHANGEDB_RT_KYCAUTHS_IN
+ + 1);
+ struct TALER_EXCHANGEDB_TableData td = {
+ .table = bogus
+ };
+ struct RecordContext ctx = {
+ .table = bogus
+ };
+ uint64_t serial;
+
+ FAILIF (GNUNET_DB_STATUS_HARD_ERROR !=
+ TALER_EXCHANGEDB_get_serial_by_table (pg,
+ bogus,
+ &serial));
+ FAILIF (GNUNET_DB_STATUS_HARD_ERROR !=
+ TALER_EXCHANGEDB_iterate_records_by_table (pg,
+ bogus,
+ 0,
+ &record_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ FAILIF (GNUNET_DB_STATUS_HARD_ERROR !=
+ TALER_EXCHANGEDB_insert_records_by_table (pg,
+ &td));
+ return 0;
+}
+
+
+/**
+ * With rows in place, the serial of a table is the row ID of its last
+ * record.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_serial (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ uint64_t serial = 0;
+
+ for (unsigned int i = 0; i < 2; i++)
+ TDB_account (pg,
+ 10 + i,
+ &account[i]);
+ TDB_denom (pg,
+ 10,
+ DENOM_VALUE,
+ "0.1",
+ &denom);
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_serial_by_table (
+ pg,
+ TALER_EXCHANGEDB_RT_WIRE_TARGETS,
+ &serial));
+ FAILIF (2 != serial);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_serial_by_table (
+ pg,
+ TALER_EXCHANGEDB_RT_KYC_TARGETS,
+ &serial));
+ FAILIF (2 != serial);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_serial_by_table (
+ pg,
+ TALER_EXCHANGEDB_RT_DENOMINATIONS,
+ &serial));
+ FAILIF (serial != denom.serial);
+
+ /* a table nobody wrote to still has no serial */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_serial_by_table (
+ pg,
+ TALER_EXCHANGEDB_RT_WIRE_OUT,
+ &serial));
+ return 0;
+}
+
+
+/**
+ * Reading records returns them in serial order and skips everything up
+ * to and including the requested serial.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct RecordContext ctx;
+ struct TALER_Amount expect = TDB_amount (DENOM_VALUE);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.table = TALER_EXCHANGEDB_RT_DENOMINATIONS;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_iterate_records_by_table (
+ pg,
+ TALER_EXCHANGEDB_RT_DENOMINATIONS,
+ 0,
+ &record_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ FAILIF (ctx.wrong_table);
+ FAILIF (ctx.serial != denom.serial);
+ FAILIF (0 != TALER_amount_cmp (&ctx.coin,
+ &expect));
+
+ /* nothing above the last serial */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.table = TALER_EXCHANGEDB_RT_DENOMINATIONS;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_records_by_table (
+ pg,
+ TALER_EXCHANGEDB_RT_DENOMINATIONS,
+ denom.serial,
+ &record_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* both accounts, and the second one only above the first serial */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.table = TALER_EXCHANGEDB_RT_WIRE_TARGETS;
+ ctx.payto = account[1].payto.full_payto;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_records_by_table (
+ pg,
+ TALER_EXCHANGEDB_RT_WIRE_TARGETS,
+ 0,
+ &record_cb,
+ &ctx));
+ FAILIF (2 != ctx.total);
+ FAILIF (0 == ctx.payto_serial);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.table = TALER_EXCHANGEDB_RT_WIRE_TARGETS;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_records_by_table (
+ pg,
+ TALER_EXCHANGEDB_RT_WIRE_TARGETS,
+ 1,
+ &record_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ FAILIF (2 != ctx.serial);
+ return 0;
+}
+
+
+/**
+ * A record that is read out, dropped and written back lands in the same
+ * row it came from.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_round_trip (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_TableData td = {
+ .table = TALER_EXCHANGEDB_RT_WIRE_TARGETS
+ };
+ struct RecordContext ctx;
+ char *hex;
+
+ /* find the row of the second account */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.table = TALER_EXCHANGEDB_RT_WIRE_TARGETS;
+ ctx.payto = account[1].payto.full_payto;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_records_by_table (
+ pg,
+ TALER_EXCHANGEDB_RT_WIRE_TARGETS,
+ 0,
+ &record_cb,
+ &ctx));
+ FAILIF (0 == ctx.payto_serial);
+
+ /* the account is not referenced by anything, so we can drop it and
+ replicate it back in */
+ hex = TDB_hex (&account[1].h_full,
+ sizeof (account[1].h_full));
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "DELETE FROM wire_targets"
+ " WHERE wire_target_h_payto=decode('%s','hex');",
+ hex),
+ GNUNET_free (hex));
+ GNUNET_free (hex);
+ FAILIF (1 != TDB_count (pg,
+ "FROM wire_targets"));
+
+ td.serial = ctx.payto_serial;
+ td.details.wire_targets.full_payto_uri = account[1].payto;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_records_by_table (pg,
+ &td));
+ FAILIF (2 != TDB_count (pg,
+ "FROM wire_targets"));
+
+ /* and it is back under its original serial, with its payto intact */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.table = TALER_EXCHANGEDB_RT_WIRE_TARGETS;
+ ctx.payto = account[1].payto.full_payto;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_records_by_table (
+ pg,
+ TALER_EXCHANGEDB_RT_WIRE_TARGETS,
+ 0,
+ &record_cb,
+ &ctx));
+ FAILIF (2 != ctx.total);
+ FAILIF (ctx.payto_serial != td.serial);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "replication-all-serials",
+ &check_all_serials },
+ { "replication-all-iterable",
+ &check_all_iterable },
+ { "replication-unknown-table",
+ &check_unknown_table },
+ { "replication-serial",
+ &check_serial },
+ { "replication-iterate",
+ &check_iterate },
+ { "replication-round-trip",
+ &check_round_trip },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-replication",
+ "Tests for the exchangedb replication functions",
+ tests);
+ TDB_denom_free (&denom);
+ for (unsigned int i = 0; i < 2; i++)
+ TDB_account_free (&account[i]);
+ return ret;
+}
+
+
+/* end of test_replication.c */
diff --git a/src/exchangedb/test_reserve_history.c b/src/exchangedb/test_reserve_history.c
@@ -0,0 +1,388 @@
+/*
+ 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/test_reserve_history.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `reserve_history`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_get_reserve_history().
+ *
+ * `reserve_history` is an index: AFTER INSERT triggers on the tables that
+ * change a reserve add one row each, naming the table and the row. The
+ * function walks that index newest-first and looks each entry up in the
+ * table it names. So the checks build a reserve with several kinds of
+ * operation on it and read the history back, and they check the ETag
+ * short-circuit -- which must not leave a transaction open.
+ */
+#include "test_common.h"
+#include "exchange-database/get_reserve_history.h"
+#include "exchange-database/insert_close_request.h"
+#include "exchange-database/start.h"
+#include "exchange-database/rollback.h"
+
+
+/**
+ * Account the checks use.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Count the entries of @a rh, and how many of them have type @a type.
+ *
+ * @param rh history to walk
+ * @param type operation to count
+ * @param[out] matched set to the number of entries of that type
+ * @return total number of entries
+ */
+static unsigned int
+count_history (const struct TALER_EXCHANGEDB_ReserveHistory *rh,
+ enum TALER_EXCHANGEDB_ReserveOperation type,
+ unsigned int *matched)
+{
+ unsigned int total = 0;
+
+ *matched = 0;
+ for (const struct TALER_EXCHANGEDB_ReserveHistory *p = rh;
+ NULL != p;
+ p = p->next)
+ {
+ total++;
+ if (type == p->type)
+ (*matched)++;
+ }
+ return total;
+}
+
+
+/**
+ * An unknown reserve has no history at all.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_unknown_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_EXCHANGEDB_ReserveHistory *rh = NULL;
+ struct TALER_Amount balance;
+ uint64_t etag_out = 42;
+
+ TDB_FILL (reserve_pub,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_reserve_history (pg,
+ &reserve_pub,
+ 0,
+ 0,
+ &etag_out,
+ &balance,
+ &rh));
+ FAILIF (NULL != rh);
+ return 0;
+}
+
+
+/**
+ * A reserve that exists but was never touched has no history either: the
+ * `reserves` row alone does not create an index entry.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_untouched_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_EXCHANGEDB_ReserveHistory *rh = NULL;
+ struct TALER_Amount balance;
+ uint64_t etag_out = 42;
+
+ TDB_reserve (pg,
+ 2,
+ "5",
+ &reserve_pub);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_reserve_history (pg,
+ &reserve_pub,
+ 0,
+ 0,
+ &etag_out,
+ &balance,
+ &rh));
+ FAILIF (NULL != rh);
+ return 0;
+}
+
+
+/**
+ * A bank transfer into the reserve shows up as one history entry.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_bank_transfer (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_EXCHANGEDB_ReserveHistory *rh = NULL;
+ struct TALER_Amount balance;
+ struct TALER_Amount expect = TDB_amount ("10");
+ unsigned int matched;
+ uint64_t etag_out = 0;
+
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_reserve_in (pg,
+ &account,
+ 10,
+ "10",
+ &reserve_pub);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_history (pg,
+ &reserve_pub,
+ 0,
+ 0,
+ &etag_out,
+ &balance,
+ &rh));
+ FAILIF_C (NULL == rh,
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ FAILIF_C (0 == etag_out,
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ FAILIF_C (0 != TALER_amount_cmp (&balance,
+ &expect),
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ FAILIF_C (1 != count_history (rh,
+ TALER_EXCHANGEDB_RO_BANK_TO_EXCHANGE,
+ &matched),
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ FAILIF_C (1 != matched,
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ FAILIF_C (0 != TALER_amount_cmp (&rh->details.bank->amount,
+ &expect),
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ FAILIF_C (0 != strcmp (rh->details.bank->sender_account_details.full_payto,
+ account.payto.full_payto),
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ TALER_EXCHANGEDB_free_reserve_history (rh);
+ return 0;
+}
+
+
+/**
+ * A close request adds a second entry, and the offset filter hides the
+ * older one.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_two_entries (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_ReserveSignatureP reserve_sig;
+ struct TALER_EXCHANGEDB_ReserveHistory *rh = NULL;
+ struct TALER_Amount balance;
+ struct TALER_Amount close_balance = TDB_amount ("10");
+ struct TALER_Amount close_fee = TDB_amount ("0.5");
+ unsigned int matched;
+ uint64_t etag_first;
+ uint64_t etag_out = 0;
+ uint64_t first_off;
+
+ TDB_fill (&reserve_pub,
+ sizeof (reserve_pub),
+ 10);
+ TDB_FILL (reserve_sig,
+ 10);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_history (pg,
+ &reserve_pub,
+ 0,
+ 0,
+ &etag_first,
+ &balance,
+ &rh));
+ first_off = rh->history_offset;
+ TALER_EXCHANGEDB_free_reserve_history (rh);
+ rh = NULL;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_close_request (pg,
+ &reserve_pub,
+ account.payto,
+ &reserve_sig,
+ GNUNET_TIME_timestamp_get (),
+ &close_balance,
+ &close_fee));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_history (pg,
+ &reserve_pub,
+ 0,
+ 0,
+ &etag_out,
+ &balance,
+ &rh));
+ FAILIF_C (2 != count_history (rh,
+ TALER_EXCHANGEDB_RO_CLOSE_REQUEST,
+ &matched),
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ FAILIF_C (1 != matched,
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ /* The query runs newest-first but the rows are walked back to front, so
+ the list that comes out is oldest-first. */
+ FAILIF_C (TALER_EXCHANGEDB_RO_BANK_TO_EXCHANGE != rh->type,
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ FAILIF_C (TALER_EXCHANGEDB_RO_CLOSE_REQUEST != rh->next->type,
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ FAILIF_C (rh->history_offset >= rh->next->history_offset,
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ FAILIF_C (etag_out <= etag_first,
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ TALER_EXCHANGEDB_free_reserve_history (rh);
+ rh = NULL;
+
+ /* starting past the bank transfer leaves only the close request */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_history (pg,
+ &reserve_pub,
+ first_off,
+ 0,
+ &etag_out,
+ &balance,
+ &rh));
+ FAILIF_C (1 != count_history (rh,
+ TALER_EXCHANGEDB_RO_CLOSE_REQUEST,
+ &matched),
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ FAILIF_C (1 != matched,
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ TALER_EXCHANGEDB_free_reserve_history (rh);
+ rh = NULL;
+
+ /* starting past everything leaves an empty history, but the reserve is
+ still known and the ETag is still reported */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_history (pg,
+ &reserve_pub,
+ etag_out,
+ 0,
+ &etag_out,
+ &balance,
+ &rh));
+ FAILIF_C (NULL != rh,
+ TALER_EXCHANGEDB_free_reserve_history (rh));
+ return 0;
+}
+
+
+/**
+ * Passing the current ETag short-circuits the lookup -- and must not leave
+ * the transaction it opened behind.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_etag (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_EXCHANGEDB_ReserveHistory *rh = NULL;
+ struct TALER_Amount balance;
+ uint64_t etag_out = 0;
+ uint64_t etag_again = 0;
+
+ TDB_fill (&reserve_pub,
+ sizeof (reserve_pub),
+ 10);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_history (pg,
+ &reserve_pub,
+ 0,
+ 0,
+ &etag_out,
+ &balance,
+ &rh));
+ TALER_EXCHANGEDB_free_reserve_history (rh);
+ rh = NULL;
+
+ /* the client already has this version */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_history (pg,
+ &reserve_pub,
+ 0,
+ etag_out,
+ &etag_again,
+ &balance,
+ &rh));
+ FAILIF (etag_again != etag_out);
+ FAILIF (NULL != rh);
+
+ /* The short-circuit above returns early. If it forgets to close the
+ read-committed transaction it opened, the connection is parked in
+ "idle in transaction" -- which the next TALER_EXCHANGEDB_start() would
+ paper over, since its preflight rolls the stale transaction back and
+ only logs it. So check the bookkeeping preflight itself looks at. */
+ FAILIF (NULL != pg->transaction_name);
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "test-reserve-history-etag"));
+ FAILIF (NULL == pg->transaction_name);
+ TALER_EXCHANGEDB_rollback (pg);
+ FAILIF (NULL != pg->transaction_name);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "reserve-history-unknown-reserve",
+ &check_unknown_reserve },
+ { "reserve-history-untouched-reserve",
+ &check_untouched_reserve },
+ { "reserve-history-bank-transfer",
+ &check_bank_transfer },
+ { "reserve-history-two-entries",
+ &check_two_entries },
+ { "reserve-history-etag",
+ &check_etag },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-reserve-history",
+ "Tests for the exchangedb `reserve_history' table",
+ tests);
+ TDB_account_free (&account);
+ return ret;
+}
+
+
+/* end of test_reserve_history.c */
diff --git a/src/exchangedb/test_reserves.c b/src/exchangedb/test_reserves.c
@@ -0,0 +1,624 @@
+/*
+ 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/test_reserves.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `reserves`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_get_reserve(),
+ * #TALER_EXCHANGEDB_update_reserve(),
+ * #TALER_EXCHANGEDB_get_reserve_balance(),
+ * #TALER_EXCHANGEDB_get_reserve_close_info(),
+ * #TALER_EXCHANGEDB_iterate_expired_reserves() and
+ * #TALER_EXCHANGEDB_gc().
+ *
+ * A reserve may or may not have a `reserves_in` row: one funded by a bank
+ * transfer has one, one created by a peer-to-peer merge does not. Several
+ * of these functions LEFT JOIN through it to get the originating account,
+ * so both shapes are exercised. The garbage collection check runs last,
+ * as it removes rows the earlier checks created.
+ */
+#include "test_common.h"
+#include "exchange-database/gc.h"
+#include "exchange-database/get_reserve.h"
+#include "exchange-database/get_reserve_balance.h"
+#include "exchange-database/get_reserve_close_info.h"
+#include "exchange-database/iterate_expired_reserves.h"
+#include "exchange-database/update_reserve.h"
+
+
+/**
+ * One second in microseconds. Timestamps read back through
+ * GNUNET_PQ_result_spec_timestamp() have to be whole seconds, so the
+ * checks never write a raw microsecond value that is not a multiple of
+ * this.
+ */
+#define SECOND 1000000LLU
+
+
+/**
+ * Create a reserve with an explicit expiration and garbage collection
+ * date, which TDB_reserve() does not offer.
+ *
+ * @param pg the database context
+ * @param seed seed for the reserve's public key
+ * @param balance balance of the reserve, e.g. "10"
+ * @param expiration_us expiration date, in microseconds since the epoch
+ * @param gc_us garbage collection date, in microseconds since the epoch
+ * @param[out] reserve_pub set to the public key of the reserve
+ */
+static void
+make_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ const char *balance,
+ uint64_t expiration_us,
+ uint64_t gc_us,
+ struct TALER_ReservePublicKeyP *reserve_pub)
+{
+ struct TALER_Amount b = TDB_amount (balance);
+ char *hex;
+
+ TDB_fill (reserve_pub,
+ sizeof (*reserve_pub),
+ seed);
+ hex = TDB_hex (reserve_pub,
+ sizeof (*reserve_pub));
+ GNUNET_assert (GNUNET_OK ==
+ TDB_exec (pg,
+ "INSERT INTO reserves"
+ " (reserve_pub,current_balance"
+ " ,expiration_date,gc_date)"
+ " VALUES (decode('%s','hex')"
+ " ,ROW(%llu,%u)::taler_amount"
+ " ,%llu,%llu)"
+ " ON CONFLICT DO NOTHING;",
+ hex,
+ (unsigned long long) b.value,
+ (unsigned int) b.fraction,
+ (unsigned long long) expiration_us,
+ (unsigned long long) gc_us));
+ GNUNET_free (hex);
+}
+
+
+/**
+ * Closure for #expired_cb().
+ */
+struct ExpiredContext
+{
+ /**
+ * How many reserves did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Public key of the last reserve seen.
+ */
+ struct TALER_ReservePublicKeyP reserve_pub;
+
+ /**
+ * Balance of the last reserve seen.
+ */
+ struct TALER_Amount left;
+
+ /**
+ * Set if the last reserve was reported with an originating account.
+ */
+ bool have_account;
+
+ /**
+ * Close request row of the last reserve seen.
+ */
+ uint64_t close_request_row;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_expired_reserves().
+ *
+ * @param cls a `struct ExpiredContext *`
+ * @param reserve_pub the expired reserve
+ * @param left balance left in the reserve
+ * @param account_details originating account
+ * @param expiration_date when the reserve expired
+ * @param close_request_row row of the close request, 0 if none
+ * @return #GNUNET_OK
+ */
+static enum GNUNET_GenericReturnValue
+expired_cb (void *cls,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ const struct TALER_Amount *left,
+ const struct TALER_FullPayto account_details,
+ struct GNUNET_TIME_Timestamp expiration_date,
+ uint64_t close_request_row)
+{
+ struct ExpiredContext *ctx = cls;
+
+ (void) expiration_date;
+ ctx->total++;
+ ctx->reserve_pub = *reserve_pub;
+ ctx->left = *left;
+ ctx->have_account = (NULL != account_details.full_payto);
+ ctx->close_request_row = close_request_row;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Nothing is known about a reserve that was never created.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_Reserve reserve;
+ struct TALER_Amount balance;
+ struct TALER_FullPayto payto = { NULL };
+ struct ExpiredContext ctx = { 0 };
+
+ memset (&reserve,
+ 0,
+ sizeof (reserve));
+ TDB_FILL (reserve.pub,
+ 1);
+ reserve.balance = TDB_amount ("1");
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_reserve (pg,
+ &reserve));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_reserve (pg,
+ &reserve));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_reserve_balance (pg,
+ &reserve.pub,
+ &balance,
+ &payto));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_reserve_close_info (pg,
+ &reserve.pub,
+ &balance,
+ &payto));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_expired_reserves (pg,
+ GNUNET_TIME_timestamp_get (
+ ),
+ &expired_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ FAILIF (0 != TDB_count (pg,
+ "FROM reserves"));
+ return 0;
+}
+
+
+/**
+ * A reserve without a `reserves_in` row is found, with no origin account.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_without_origin (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_Reserve reserve;
+ struct TALER_Amount balance;
+ struct TALER_Amount expect = TDB_amount ("10");
+ struct TALER_FullPayto payto = {
+ .full_payto = (char *) "sentinel"
+ };
+
+ memset (&reserve,
+ 0,
+ sizeof (reserve));
+ TDB_reserve (pg,
+ 2,
+ "10",
+ &reserve.pub);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve (pg,
+ &reserve));
+ FAILIF (0 != TALER_amount_cmp (&reserve.balance,
+ &expect));
+ FAILIF (GNUNET_TIME_absolute_is_zero (reserve.expiry.abs_time));
+ FAILIF (GNUNET_TIME_absolute_is_zero (reserve.gc.abs_time));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_balance (pg,
+ &reserve.pub,
+ &balance,
+ &payto));
+ FAILIF (0 != TALER_amount_cmp (&balance,
+ &expect));
+ FAILIF (NULL != payto.full_payto);
+
+ payto.full_payto = (char *) "sentinel";
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_close_info (pg,
+ &reserve.pub,
+ &balance,
+ &payto));
+ FAILIF (0 != TALER_amount_cmp (&balance,
+ &expect));
+ FAILIF (NULL != payto.full_payto);
+ return 0;
+}
+
+
+/**
+ * A reserve funded by a bank transfer reports its origin account.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_with_origin (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Account account;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_Amount balance;
+ struct TALER_Amount expect = TDB_amount ("7");
+ struct TALER_FullPayto payto = { NULL };
+
+ TDB_account (pg,
+ 3,
+ &account);
+ TDB_reserve_in (pg,
+ &account,
+ 3,
+ "7",
+ &reserve_pub);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_balance (pg,
+ &reserve_pub,
+ &balance,
+ &payto),
+ TDB_account_free (&account));
+ FAILIF_C (0 != TALER_amount_cmp (&balance,
+ &expect),
+ TDB_account_free (&account));
+ FAILIF_C (NULL == payto.full_payto,
+ TDB_account_free (&account));
+ FAILIF_C (0 != strcmp (payto.full_payto,
+ account.payto.full_payto),
+ GNUNET_free (payto.full_payto); TDB_account_free (&account));
+ GNUNET_free (payto.full_payto);
+
+ payto.full_payto = NULL;
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_close_info (pg,
+ &reserve_pub,
+ &balance,
+ &payto),
+ TDB_account_free (&account));
+ FAILIF_C (NULL == payto.full_payto,
+ TDB_account_free (&account));
+ FAILIF_C (0 != strcmp (payto.full_payto,
+ account.payto.full_payto),
+ GNUNET_free (payto.full_payto); TDB_account_free (&account));
+ GNUNET_free (payto.full_payto);
+ TDB_account_free (&account);
+ return 0;
+}
+
+
+/**
+ * Updating a reserve writes back the balance and both dates.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_update (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_EXCHANGEDB_Reserve reserve;
+ struct TALER_EXCHANGEDB_Reserve got;
+ struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get ();
+
+ memset (&reserve,
+ 0,
+ sizeof (reserve));
+ TDB_FILL (reserve.pub,
+ 2);
+ reserve.balance = TDB_amount ("3.5");
+ reserve.expiry = GNUNET_TIME_absolute_to_timestamp (now);
+ reserve.gc = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_add (now,
+ GNUNET_TIME_UNIT_HOURS));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_reserve (pg,
+ &reserve));
+ memset (&got,
+ 0,
+ sizeof (got));
+ got.pub = reserve.pub;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve (pg,
+ &got));
+ FAILIF (0 != TALER_amount_cmp (&got.balance,
+ &reserve.balance));
+ FAILIF (GNUNET_TIME_timestamp_cmp (got.expiry,
+ !=,
+ reserve.expiry));
+ FAILIF (GNUNET_TIME_timestamp_cmp (got.gc,
+ !=,
+ reserve.gc));
+ return 0;
+}
+
+
+/**
+ * Only an expired reserve that still holds money and has an originating
+ * account is reported for closing.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_expired (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Account account;
+ struct TALER_ReservePublicKeyP funded;
+ struct TALER_ReservePublicKeyP drained;
+ struct ExpiredContext ctx;
+ struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get ();
+ struct TALER_Amount expect = TDB_amount ("7");
+ char *hex;
+
+ /* nothing has expired yet: the reserves so far all expire in 2096 */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_expired_reserves (pg,
+ now,
+ &expired_cb,
+ &ctx));
+
+ /* expire the funded reserve from check_with_origin() */
+ TDB_account (pg,
+ 3,
+ &account);
+ TDB_fill (&funded,
+ sizeof (funded),
+ 3);
+ hex = TDB_hex (&funded,
+ sizeof (funded));
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE reserves"
+ " SET expiration_date=1000000"
+ " WHERE reserve_pub=decode('%s','hex');",
+ hex),
+ GNUNET_free (hex); TDB_account_free (&account));
+ GNUNET_free (hex);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_iterate_expired_reserves (pg,
+ now,
+ &expired_cb,
+ &ctx),
+ TDB_account_free (&account));
+ FAILIF_C (1 != ctx.total,
+ TDB_account_free (&account));
+ FAILIF_C (0 != GNUNET_memcmp (&ctx.reserve_pub,
+ &funded),
+ TDB_account_free (&account));
+ FAILIF_C (0 != TALER_amount_cmp (&ctx.left,
+ &expect),
+ TDB_account_free (&account));
+ FAILIF_C (! ctx.have_account,
+ TDB_account_free (&account));
+
+ /* an expired reserve with a zero balance is not worth closing */
+ make_reserve (pg,
+ 4,
+ "0",
+ SECOND,
+ SECOND,
+ &drained);
+ {
+ char *rhex = TDB_hex (&drained,
+ sizeof (drained));
+ char *ahex = TDB_hex (&account.h_full,
+ sizeof (account.h_full));
+
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "INSERT INTO reserves_in"
+ " (reserve_pub,wire_reference,credit"
+ " ,wire_source_h_payto"
+ " ,exchange_account_section,execution_date)"
+ " VALUES (decode('%s','hex'),44"
+ " ,ROW(0,0)::taler_amount"
+ " ,decode('%s','hex'),'exchange-account-1'"
+ " ,1000000);",
+ rhex,
+ ahex),
+ GNUNET_free (rhex); GNUNET_free (ahex);
+ TDB_account_free (&account));
+ GNUNET_free (rhex);
+ GNUNET_free (ahex);
+ }
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_iterate_expired_reserves (pg,
+ now,
+ &expired_cb,
+ &ctx),
+ TDB_account_free (&account));
+ /* the query returns at most one reserve, and it is still the funded one */
+ FAILIF_C (1 != ctx.total,
+ TDB_account_free (&account));
+ FAILIF_C (0 != GNUNET_memcmp (&ctx.reserve_pub,
+ &funded),
+ TDB_account_free (&account));
+ TDB_account_free (&account);
+
+ /* an expired reserve with money but no originating account is skipped:
+ it was created by a merge, so there is nowhere to wire the money back
+ to. Drain the funded one first so it cannot mask the result. */
+ hex = TDB_hex (&funded,
+ sizeof (funded));
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE reserves"
+ " SET current_balance=ROW(0,0)::taler_amount"
+ " WHERE reserve_pub=decode('%s','hex');",
+ hex),
+ GNUNET_free (hex));
+ GNUNET_free (hex);
+ {
+ struct TALER_ReservePublicKeyP merged;
+
+ make_reserve (pg,
+ 5,
+ "4",
+ SECOND,
+ SECOND,
+ &merged);
+ }
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_expired_reserves (pg,
+ now,
+ &expired_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * Garbage collection removes drained, expired reserves and leaves the
+ * others alone. Runs last.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_gc (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP old_empty;
+ struct TALER_ReservePublicKeyP old_funded;
+ struct TALER_ReservePublicKeyP young_empty;
+ char *hex;
+ uint64_t before;
+
+ /* gc_date in the past and no money left: collectable */
+ make_reserve (pg,
+ 10,
+ "0",
+ SECOND,
+ SECOND,
+ &old_empty);
+ /* gc_date in the past but still holding money: kept */
+ make_reserve (pg,
+ 11,
+ "2",
+ SECOND,
+ SECOND,
+ &old_funded);
+ /* no money left but gc_date far in the future: kept */
+ make_reserve (pg,
+ 12,
+ "0",
+ 4000000000000000LLU,
+ 5000000000000000LLU,
+ &young_empty);
+ before = TDB_count (pg,
+ "FROM reserves");
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_gc (pg));
+
+ hex = TDB_hex (&old_empty,
+ sizeof (old_empty));
+ FAILIF_C (0 != TDB_count (pg,
+ "FROM reserves"
+ " WHERE reserve_pub=decode('%s','hex')",
+ hex),
+ GNUNET_free (hex));
+ GNUNET_free (hex);
+ hex = TDB_hex (&old_funded,
+ sizeof (old_funded));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM reserves"
+ " WHERE reserve_pub=decode('%s','hex')",
+ hex),
+ GNUNET_free (hex));
+ GNUNET_free (hex);
+ hex = TDB_hex (&young_empty,
+ sizeof (young_empty));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM reserves"
+ " WHERE reserve_pub=decode('%s','hex')",
+ hex),
+ GNUNET_free (hex));
+ GNUNET_free (hex);
+ FAILIF (before <= TDB_count (pg,
+ "FROM reserves"));
+ /* running it again on the same database changes nothing further */
+ before = TDB_count (pg,
+ "FROM reserves");
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_gc (pg));
+ FAILIF (before != TDB_count (pg,
+ "FROM reserves"));
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "reserves-empty",
+ &check_empty },
+ { "reserves-without-origin",
+ &check_without_origin },
+ { "reserves-with-origin",
+ &check_with_origin },
+ { "reserves-update",
+ &check_update },
+ { "reserves-expired",
+ &check_expired },
+ { "reserves-gc",
+ &check_gc },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-reserves",
+ "Tests for the exchangedb `reserves' table",
+ tests);
+}
+
+
+/* end of test_reserves.c */
diff --git a/src/exchangedb/test_reserves_close.c b/src/exchangedb/test_reserves_close.c
@@ -0,0 +1,590 @@
+/*
+ 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/test_reserves_close.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `reserves_close`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_reserve_closed(),
+ * #TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id() and
+ * #TALER_EXCHANGEDB_iterate_reserve_close_info().
+ *
+ * The table references `reserves` and `wire_targets`, both of which the
+ * checks create with TDB_reserve() and TDB_account(). The KYC-facing
+ * iterator groups by the *normalized* account, so the checks close two
+ * reserves to the same account and one to another.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_reserve_closed.h"
+#include "exchange-database/iterate_reserve_closed_above_serial_id.h"
+#include "exchange-database/iterate_reserve_close_info.h"
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #closed_cb().
+ */
+struct ClosedContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Reserve we are looking for, NULL to match nothing.
+ */
+ const struct TALER_ReservePublicKeyP *reserve_pub;
+
+ /**
+ * How many times did we see @e reserve_pub?
+ */
+ unsigned int matched;
+
+ /**
+ * Amount reported for @e reserve_pub.
+ */
+ struct TALER_Amount amount;
+
+ /**
+ * Closing fee reported for @e reserve_pub.
+ */
+ struct TALER_Amount closing_fee;
+
+ /**
+ * Wire transfer identifier reported for @e reserve_pub.
+ */
+ struct TALER_WireTransferIdentifierRawP wtid;
+
+ /**
+ * Close request row reported for @e reserve_pub.
+ */
+ uint64_t close_request_row;
+
+ /**
+ * Receiver account reported for @e reserve_pub, owned by this struct.
+ */
+ char *receiver;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id().
+ *
+ * @param cls a `struct ClosedContext *`
+ * @param rowid row of the close operation
+ * @param execution_date when the transfer was made
+ * @param amount_with_fee how much was debited
+ * @param closing_fee how much was charged for closing
+ * @param reserve_pub the reserve that was closed
+ * @param receiver_account where the money went
+ * @param wtid identifier of the wire transfer
+ * @param close_request_row close request that triggered this, 0 if none
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+closed_cb (void *cls,
+ uint64_t rowid,
+ struct GNUNET_TIME_Timestamp execution_date,
+ const struct TALER_Amount *amount_with_fee,
+ const struct TALER_Amount *closing_fee,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ const struct TALER_FullPayto receiver_account,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ uint64_t close_request_row)
+{
+ struct ClosedContext *ctx = cls;
+
+ (void) rowid;
+ (void) execution_date;
+ ctx->total++;
+ if ( (NULL != ctx->reserve_pub) &&
+ (0 == GNUNET_memcmp (reserve_pub,
+ ctx->reserve_pub)) )
+ {
+ ctx->matched++;
+ ctx->amount = *amount_with_fee;
+ ctx->closing_fee = *closing_fee;
+ ctx->wtid = *wtid;
+ ctx->close_request_row = close_request_row;
+ GNUNET_free (ctx->receiver);
+ ctx->receiver = GNUNET_strdup (receiver_account.full_payto);
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Closure for #amount_cb().
+ */
+struct AmountContext
+{
+ /**
+ * How many amounts did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Sum of the whole-unit parts of the amounts seen.
+ */
+ uint64_t value_sum;
+
+ /**
+ * Date of the first amount seen.
+ */
+ struct GNUNET_TIME_Absolute first_date;
+
+ /**
+ * Return this from the callback.
+ */
+ enum GNUNET_GenericReturnValue ret;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_reserve_close_info().
+ *
+ * @param cls a `struct AmountContext *`
+ * @param amount the closed amount
+ * @param date when the reserve was closed
+ * @return what @e ret of the closure says
+ */
+static enum GNUNET_GenericReturnValue
+amount_cb (void *cls,
+ const struct TALER_Amount *amount,
+ struct GNUNET_TIME_Absolute date)
+{
+ struct AmountContext *ctx = cls;
+
+ if (0 == ctx->total++)
+ ctx->first_date = date;
+ ctx->value_sum += amount->value;
+ return ctx->ret;
+}
+
+
+/**
+ * Close a reserve.
+ *
+ * @param pg the database context
+ * @param reserve_pub reserve to close
+ * @param account where the money goes
+ * @param seed seed for the wire transfer identifier
+ * @param when when the transfer was made, in seconds since the epoch
+ * @param amount amount debited from the reserve, e.g. "10"
+ * @param fee closing fee, e.g. "0.5"
+ * @param close_request_row close request that triggered this, 0 if none
+ * @param[out] wtid set to the wire transfer identifier used
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+close_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ const struct TDB_Account *account,
+ uint32_t seed,
+ uint64_t when,
+ const char *amount,
+ const char *fee,
+ uint64_t close_request_row,
+ struct TALER_WireTransferIdentifierRawP *wtid)
+{
+ struct TALER_Amount a = TDB_amount (amount);
+ struct TALER_Amount f = TDB_amount (fee);
+
+ TDB_fill (wtid,
+ sizeof (*wtid),
+ seed);
+ return TALER_EXCHANGEDB_insert_reserve_closed (pg,
+ reserve_pub,
+ ts (when),
+ account->payto,
+ wtid,
+ &a,
+ &f,
+ close_request_row);
+}
+
+
+/**
+ * Nothing is reported while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct ClosedContext ctx = { 0 };
+ struct AmountContext actx = { 0 };
+ struct TALER_NormalizedPaytoHashP h_payto;
+
+ TDB_FILL (h_payto,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id (
+ pg,
+ 0,
+ &closed_cb,
+ &ctx));
+ actx.ret = GNUNET_OK;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_reserve_close_info (
+ pg,
+ &h_payto,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &actx));
+ FAILIF (0 != ctx.total);
+ FAILIF (0 != actx.total);
+ return 0;
+}
+
+
+/**
+ * A closed reserve is reported with everything it was closed with.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_and_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Account account;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct TALER_Amount expect = TDB_amount ("10");
+ struct TALER_Amount expect_fee = TDB_amount ("0.5");
+ struct ClosedContext ctx;
+
+ TDB_account (pg,
+ 10,
+ &account);
+ /* insert_reserve_closed() debits the reserve, so it has to hold exactly
+ what is being closed */
+ TDB_reserve (pg,
+ 10,
+ "10",
+ &reserve_pub);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ close_reserve (pg,
+ &reserve_pub,
+ &account,
+ 10,
+ 1600000000,
+ "10",
+ "0.5",
+ 0,
+ &wtid),
+ TDB_account_free (&account));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.reserve_pub = &reserve_pub;
+ FAILIF_C (0 >=
+ TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id (
+ pg,
+ 0,
+ &closed_cb,
+ &ctx),
+ TDB_account_free (&account));
+ FAILIF_C (1 != ctx.matched,
+ GNUNET_free (ctx.receiver); TDB_account_free (&account));
+ FAILIF_C (0 != TALER_amount_cmp (&ctx.amount,
+ &expect),
+ GNUNET_free (ctx.receiver); TDB_account_free (&account));
+ FAILIF_C (0 != TALER_amount_cmp (&ctx.closing_fee,
+ &expect_fee),
+ GNUNET_free (ctx.receiver); TDB_account_free (&account));
+ FAILIF_C (0 != GNUNET_memcmp (&ctx.wtid,
+ &wtid),
+ GNUNET_free (ctx.receiver); TDB_account_free (&account));
+ FAILIF_C (0 != ctx.close_request_row,
+ GNUNET_free (ctx.receiver); TDB_account_free (&account));
+ FAILIF_C (0 != strcmp (ctx.receiver,
+ account.payto.full_payto),
+ GNUNET_free (ctx.receiver); TDB_account_free (&account));
+ GNUNET_free (ctx.receiver);
+ TDB_account_free (&account);
+ return 0;
+}
+
+
+/**
+ * The serial bound, the abort return of the callback and a second account
+ * all behave as documented.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate_filters (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Account account;
+ struct TDB_Account other;
+ struct TALER_ReservePublicKeyP r2;
+ struct TALER_ReservePublicKeyP r3;
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct ClosedContext ctx;
+
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_account (pg,
+ 11,
+ &other);
+ TDB_reserve (pg,
+ 11,
+ "4",
+ &r2);
+ TDB_reserve (pg,
+ 12,
+ "9",
+ &r3);
+ /* a second close to the same account, later */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ close_reserve (pg,
+ &r2,
+ &account,
+ 11,
+ 1600003600,
+ "4",
+ "0.5",
+ 0,
+ &wtid),
+ TDB_account_free (&account); TDB_account_free (&other));
+ /* and one to a different account */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ close_reserve (pg,
+ &r3,
+ &other,
+ 12,
+ 1600007200,
+ "9",
+ "0.5",
+ 0,
+ &wtid),
+ TDB_account_free (&account); TDB_account_free (&other));
+ FAILIF_C (3 != TDB_count (pg,
+ "FROM reserves_close"),
+ TDB_account_free (&account); TDB_account_free (&other));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF_C (3 !=
+ TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id (
+ pg,
+ 0,
+ &closed_cb,
+ &ctx),
+ TDB_account_free (&account); TDB_account_free (&other));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id (
+ pg,
+ 1000,
+ &closed_cb,
+ &ctx),
+ TDB_account_free (&account); TDB_account_free (&other));
+ FAILIF_C (0 != ctx.total,
+ TDB_account_free (&account); TDB_account_free (&other));
+
+ /* an aborting callback stops the iteration */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 2;
+ FAILIF_C (3 !=
+ TALER_EXCHANGEDB_iterate_reserve_closed_above_serial_id (
+ pg,
+ 0,
+ &closed_cb,
+ &ctx),
+ TDB_account_free (&account); TDB_account_free (&other));
+ FAILIF_C (2 != ctx.total,
+ TDB_account_free (&account); TDB_account_free (&other));
+ TDB_account_free (&account);
+ TDB_account_free (&other);
+ return 0;
+}
+
+
+/**
+ * The KYC view sums the closures per account, newest first, and honours
+ * the time limit and the callback's abort.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_close_info (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Account account;
+ struct TDB_Account other;
+ struct AmountContext ctx;
+
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_account (pg,
+ 11,
+ &other);
+
+ /* both closures to the first account */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_OK;
+ FAILIF_C (2 !=
+ TALER_EXCHANGEDB_iterate_reserve_close_info (
+ pg,
+ &account.h_normalized,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &ctx),
+ TDB_account_free (&account); TDB_account_free (&other));
+ FAILIF_C (10 + 4 != ctx.value_sum,
+ TDB_account_free (&account); TDB_account_free (&other));
+ /* newest first */
+ FAILIF_C (ctx.first_date.abs_value_us !=
+ ts (1600003600).abs_time.abs_value_us,
+ TDB_account_free (&account); TDB_account_free (&other));
+
+ /* the other account has just the one */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_OK;
+ FAILIF_C (1 !=
+ TALER_EXCHANGEDB_iterate_reserve_close_info (
+ pg,
+ &other.h_normalized,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &ctx),
+ TDB_account_free (&account); TDB_account_free (&other));
+ FAILIF_C (9 != ctx.value_sum,
+ TDB_account_free (&account); TDB_account_free (&other));
+
+ /* the time limit drops the older closure */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_OK;
+ FAILIF_C (1 !=
+ TALER_EXCHANGEDB_iterate_reserve_close_info (
+ pg,
+ &account.h_normalized,
+ ts (1600001800).abs_time,
+ &amount_cb,
+ &ctx),
+ TDB_account_free (&account); TDB_account_free (&other));
+ FAILIF_C (4 != ctx.value_sum,
+ TDB_account_free (&account); TDB_account_free (&other));
+
+ /* a limit past every closure leaves nothing */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_OK;
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_reserve_close_info (
+ pg,
+ &account.h_normalized,
+ ts (1700000000).abs_time,
+ &amount_cb,
+ &ctx),
+ TDB_account_free (&account); TDB_account_free (&other));
+ FAILIF_C (0 != ctx.total,
+ TDB_account_free (&account); TDB_account_free (&other));
+
+ /* a callback that aborts with GNUNET_NO is not an error */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.ret = GNUNET_NO;
+ FAILIF_C (0 >
+ TALER_EXCHANGEDB_iterate_reserve_close_info (
+ pg,
+ &account.h_normalized,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &ctx),
+ TDB_account_free (&account); TDB_account_free (&other));
+ FAILIF_C (1 != ctx.total,
+ TDB_account_free (&account); TDB_account_free (&other));
+ TDB_account_free (&account);
+ TDB_account_free (&other);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "reserves-close-empty",
+ &check_empty },
+ { "reserves-close-insert-and-iterate",
+ &check_insert_and_iterate },
+ { "reserves-close-iterate-filters",
+ &check_iterate_filters },
+ { "reserves-close-close-info",
+ &check_close_info },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-reserves-close",
+ "Tests for the exchangedb `reserves_close' table",
+ tests);
+}
+
+
+/* end of test_reserves_close.c */
diff --git a/src/exchangedb/test_reserves_in.c b/src/exchangedb/test_reserves_in.c
@@ -0,0 +1,695 @@
+/*
+ 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/test_reserves_in.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `reserves_in`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_import_credits() (for its reserve half; the
+ * KYC-auth half is in test_kycauths_in.c),
+ * #TALER_EXCHANGEDB_get_reserve_origin(),
+ * #TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id(),
+ * #TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id_by_account() and
+ * #TALER_EXCHANGEDB_iterate_exchange_credit_transfers().
+ *
+ * do_import_credits() creates the `wire_targets` and `reserves` rows it
+ * needs on its own, so the only fixture the checks have to set up is a work
+ * shard for the batch to advance.
+ */
+#include "test_common.h"
+#include "exchange-database/begin_shard.h"
+#include "exchange-database/do_import_credits.h"
+#include "exchange-database/get_reserve_origin.h"
+#include "exchange-database/iterate_exchange_credit_transfers.h"
+#include "exchange-database/iterate_reserves_in_above_serial_id.h"
+#include "exchange-database/iterate_reserves_in_above_serial_id_by_account.h"
+
+
+/**
+ * Account the first two transfers come from.
+ */
+#define PAYTO_A "payto://x-taler-bank/localhost/ri-a?receiver-name=A"
+
+/**
+ * Account the third transfer comes from.
+ */
+#define PAYTO_B "payto://x-taler-bank/localhost/ri-b?receiver-name=B"
+
+
+/**
+ * Closure for #reserve_in_cb().
+ */
+struct InContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Reserve we are looking for, NULL to match nothing.
+ */
+ const struct TALER_ReservePublicKeyP *reserve_pub;
+
+ /**
+ * How many times did we see @e reserve_pub?
+ */
+ unsigned int matched;
+
+ /**
+ * Credit reported for @e reserve_pub.
+ */
+ struct TALER_Amount credit;
+
+ /**
+ * Row reported for @e reserve_pub.
+ */
+ uint64_t rowid;
+
+ /**
+ * Wire reference reported for @e reserve_pub.
+ */
+ uint64_t wire_reference;
+
+ /**
+ * Sender account reported for @e reserve_pub, owned by this struct.
+ */
+ char *sender;
+};
+
+
+/**
+ * Callback for the `reserves_in` iterators.
+ *
+ * @param cls a `struct InContext *`
+ * @param rowid row of the transfer
+ * @param reserve_pub reserve that was credited
+ * @param credit amount that was credited
+ * @param sender_account_details account the money came from
+ * @param wire_reference bank's identifier for the transfer
+ * @param execution_date when the transfer was made
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+reserve_in_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ const struct TALER_Amount *credit,
+ const struct TALER_FullPayto sender_account_details,
+ uint64_t wire_reference,
+ struct GNUNET_TIME_Timestamp execution_date)
+{
+ struct InContext *ctx = cls;
+
+ (void) execution_date;
+ ctx->total++;
+ if ( (NULL != ctx->reserve_pub) &&
+ (0 == GNUNET_memcmp (reserve_pub,
+ ctx->reserve_pub)) )
+ {
+ ctx->matched++;
+ ctx->credit = *credit;
+ ctx->rowid = rowid;
+ ctx->wire_reference = wire_reference;
+ GNUNET_free (ctx->sender);
+ ctx->sender = (NULL == sender_account_details.full_payto)
+ ? NULL
+ : GNUNET_strdup (sender_account_details.full_payto);
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Closure for #transfer_cb().
+ */
+struct TransferContext
+{
+ /**
+ * How many transfers did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Row of the first transfer seen.
+ */
+ uint64_t first_row;
+
+ /**
+ * Sum of the whole-unit parts of the amounts seen.
+ */
+ uint64_t value_sum;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_exchange_credit_transfers().
+ *
+ * @param cls a `struct TransferContext *`
+ * @param row_id row of the transfer
+ * @param payto_uri account the money came from
+ * @param execution_time when the transfer was made
+ * @param amount amount that was transferred
+ */
+static void
+transfer_cb (void *cls,
+ uint64_t row_id,
+ const char *payto_uri,
+ struct GNUNET_TIME_Absolute execution_time,
+ const struct TALER_Amount *amount)
+{
+ struct TransferContext *ctx = cls;
+
+ (void) payto_uri;
+ (void) execution_time;
+ if (0 == ctx->total++)
+ ctx->first_row = row_id;
+ ctx->value_sum += amount->value;
+}
+
+
+/**
+ * Import one wire transfer into a reserve, advancing a work shard of its
+ * own along with it.
+ *
+ * @param pg the database context
+ * @param job_name job whose shard to open and advance
+ * @param account_name exchange bank account the transfer arrived at
+ * @param payto account the money came from
+ * @param seed seed for the reserve's public key
+ * @param amount amount that was transferred, e.g. "10"
+ * @param[out] reserve_pub set to the credited reserve
+ * @param[out] status set to the per-transfer status
+ * @return transaction status of the import
+ */
+static enum GNUNET_DB_QueryStatus
+import_one (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const char *job_name,
+ const char *account_name,
+ const char *payto,
+ uint32_t seed,
+ const char *amount,
+ struct TALER_ReservePublicKeyP *reserve_pub,
+ enum GNUNET_DB_QueryStatus *status)
+{
+ struct TALER_Amount balance = TDB_amount (amount);
+ struct TALER_EXCHANGEDB_ReserveInInfo info;
+ struct TALER_EXCHANGEDB_CreditBatch batch;
+ uint64_t start;
+ uint64_t end;
+ uint64_t progress;
+
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_begin_shard (pg,
+ job_name,
+ GNUNET_TIME_UNIT_HOURS,
+ 1024,
+ &start,
+ &end,
+ &progress));
+ TDB_fill (reserve_pub,
+ sizeof (*reserve_pub),
+ seed);
+ memset (&info,
+ 0,
+ sizeof (info));
+ info.reserve_pub = reserve_pub;
+ info.balance = &balance;
+ info.execution_time = GNUNET_TIME_timestamp_get ();
+ info.sender_account_details.full_payto = (char *) payto;
+ info.wire_reference = start + 1;
+ memset (&batch,
+ 0,
+ sizeof (batch));
+ batch.exchange_account_name = account_name;
+ batch.reserves = &info;
+ batch.reserves_length = 1;
+ batch.job_name = job_name;
+ batch.shard_start = start;
+ batch.shard_end = end;
+ batch.progress_row = end;
+ batch.lease = GNUNET_TIME_UNIT_HOURS;
+ return TALER_EXCHANGEDB_do_import_credits (pg,
+ &batch,
+ status);
+}
+
+
+/**
+ * Nothing is known while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_FullPaytoHashP h_payto;
+ struct TALER_FullPayto payto = { NULL };
+ struct InContext ctx = { 0 };
+ struct TransferContext tctx = { 0 };
+ struct TALER_Amount zero = TDB_amount ("0");
+
+ TDB_FILL (reserve_pub,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_reserve_origin (pg,
+ &reserve_pub,
+ &h_payto,
+ &payto));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id (pg,
+ 0,
+ &reserve_in_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id_by_account (
+ pg,
+ "exchange-account-1",
+ 0,
+ &reserve_in_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg,
+ &zero,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &tctx));
+ FAILIF (0 != ctx.total);
+ FAILIF (0 != tctx.total);
+ return 0;
+}
+
+
+/**
+ * Importing a transfer creates the reserve, the account and the
+ * `reserves_in` row, and doing it again is a no-op.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_import (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_FullPaytoHashP h_payto;
+ struct TALER_FullPayto payto = { NULL };
+ struct TALER_FullPayto expect = {
+ .full_payto = (char *) PAYTO_A
+ };
+ struct TALER_FullPaytoHashP expect_h;
+ struct TALER_Amount expect_amount = TDB_amount ("10");
+ enum GNUNET_DB_QueryStatus status;
+
+ FAILIF (0 >
+ import_one (pg,
+ "ri-job-a",
+ "exchange-account-1",
+ PAYTO_A,
+ 10,
+ "10",
+ &reserve_pub,
+ &status));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != status);
+ FAILIF (1 != TDB_count (pg,
+ "FROM reserves_in"));
+ FAILIF (1 != TDB_count (pg,
+ "FROM reserves"));
+
+ TALER_full_payto_hash (expect,
+ &expect_h);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_origin (pg,
+ &reserve_pub,
+ &h_payto,
+ &payto));
+ FAILIF (0 != GNUNET_memcmp (&h_payto,
+ &expect_h));
+ FAILIF (0 != strcmp (payto.full_payto,
+ PAYTO_A));
+ GNUNET_free (payto.full_payto);
+
+ /* the same transfer again: the row is already there */
+ {
+ struct TALER_ReservePublicKeyP again;
+
+ FAILIF (0 >
+ import_one (pg,
+ "ri-job-a",
+ "exchange-account-1",
+ PAYTO_A,
+ 10,
+ "10",
+ &again,
+ &status));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != status);
+ FAILIF (1 != TDB_count (pg,
+ "FROM reserves_in"));
+ }
+
+ {
+ struct InContext ctx;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.reserve_pub = &reserve_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id (
+ pg,
+ 0,
+ &reserve_in_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.matched,
+ GNUNET_free (ctx.sender));
+ FAILIF_C (0 != TALER_amount_cmp (&ctx.credit,
+ &expect_amount),
+ GNUNET_free (ctx.sender));
+ FAILIF_C (0 != strcmp (ctx.sender,
+ PAYTO_A),
+ GNUNET_free (ctx.sender));
+ GNUNET_free (ctx.sender);
+ }
+ return 0;
+}
+
+
+/**
+ * With several transfers on file the iterators filter by serial and by
+ * exchange bank account, and honour an aborting callback.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP r2;
+ struct TALER_ReservePublicKeyP r3;
+ struct InContext ctx;
+ enum GNUNET_DB_QueryStatus status;
+
+ /* a second transfer from the same account to the same exchange account */
+ FAILIF (0 >
+ import_one (pg,
+ "ri-job-a",
+ "exchange-account-1",
+ PAYTO_A,
+ 11,
+ "2",
+ &r2,
+ &status));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != status);
+ /* and one from a different account to a different exchange account */
+ FAILIF (0 >
+ import_one (pg,
+ "ri-job-b",
+ "exchange-account-2",
+ PAYTO_B,
+ 12,
+ "5",
+ &r3,
+ &status));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != status);
+ FAILIF (3 != TDB_count (pg,
+ "FROM reserves_in"));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.reserve_pub = &r3;
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id (pg,
+ 0,
+ &reserve_in_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.matched,
+ GNUNET_free (ctx.sender));
+ FAILIF_C (0 != strcmp (ctx.sender,
+ PAYTO_B),
+ GNUNET_free (ctx.sender));
+ GNUNET_free (ctx.sender);
+
+ /* by exchange bank account */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id_by_account (
+ pg,
+ "exchange-account-1",
+ 0,
+ &reserve_in_cb,
+ &ctx));
+ GNUNET_free (ctx.sender);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id_by_account (
+ pg,
+ "exchange-account-2",
+ 0,
+ &reserve_in_cb,
+ &ctx));
+ GNUNET_free (ctx.sender);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id_by_account (
+ pg,
+ "exchange-account-does-not-exist",
+ 0,
+ &reserve_in_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* A callback that gives up stops the iteration but is not an error: the
+ status stays the number of rows the query matched, not the number the
+ callback was shown. */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id (pg,
+ 0,
+ &reserve_in_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ GNUNET_free (ctx.sender);
+ return 0;
+}
+
+
+/**
+ * The serial number bound of the plain iterator excludes earlier rows.
+ * The bound is inclusive, so passing the row of the second transfer
+ * leaves the first one out and nothing else.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_serial_bound (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct InContext ctx;
+
+ FAILIF (3 != TDB_count (pg,
+ "FROM reserves_in"));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id (pg,
+ 2,
+ &reserve_in_cb,
+ &ctx));
+ GNUNET_free (ctx.sender);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_reserves_in_above_serial_id (pg,
+ 1000,
+ &reserve_in_cb,
+ &ctx));
+ GNUNET_free (ctx.sender);
+ return 0;
+}
+
+
+/**
+ * The AML view of the same rows filters by amount and by account, and can
+ * walk the table backwards.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_credit_transfers (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_NormalizedPayto npayto;
+ struct TALER_NormalizedPaytoHashP h_payto;
+ struct TALER_FullPayto payto_a = {
+ .full_payto = (char *) PAYTO_A
+ };
+ struct TALER_Amount zero = TDB_amount ("0");
+ struct TALER_Amount five = TDB_amount ("5");
+ struct TALER_Amount huge = TDB_amount ("1000");
+ struct TransferContext ctx;
+ uint64_t lowest;
+
+ /* everything, ascending */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg,
+ &zero,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (3 != ctx.total);
+ FAILIF (10 + 2 + 5 != ctx.value_sum);
+ lowest = ctx.first_row;
+
+ /* the amount threshold drops the EUR:2 transfer */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg,
+ &five,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (10 + 5 != ctx.value_sum);
+
+ /* a threshold nobody meets */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg,
+ &huge,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* filtering by account keeps the two transfers from PAYTO_A */
+ npayto = TALER_payto_normalize (payto_a);
+ GNUNET_assert (NULL != npayto.normalized_payto);
+ TALER_normalized_payto_hash (npayto,
+ &h_payto);
+ GNUNET_free (npayto.normalized_payto);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg,
+ &zero,
+ 0,
+ 10,
+ &h_payto,
+ &transfer_cb,
+ &ctx));
+ FAILIF (10 + 2 != ctx.value_sum);
+
+ /* the limit caps the result set... */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg,
+ &zero,
+ 0,
+ 1,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (lowest != ctx.first_row);
+
+ /* ...and a negative limit walks the table the other way, starting from
+ the offset rather than from the beginning */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_exchange_credit_transfers (pg,
+ &zero,
+ 1000,
+ -10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (lowest == ctx.first_row);
+ FAILIF (10 + 2 + 5 != ctx.value_sum);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "reserves-in-empty",
+ &check_empty },
+ { "reserves-in-import",
+ &check_import },
+ { "reserves-in-iterate",
+ &check_iterate },
+ { "reserves-in-serial-bound",
+ &check_serial_bound },
+ { "reserves-in-credit-transfers",
+ &check_credit_transfers },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-reserves-in",
+ "Tests for the exchangedb `reserves_in' table",
+ tests);
+}
+
+
+/* end of test_reserves_in.c */
diff --git a/src/exchangedb/test_reserves_open_deposits.c b/src/exchangedb/test_reserves_open_deposits.c
@@ -0,0 +1,381 @@
+/*
+ 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/test_reserves_open_deposits.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `reserves_open_deposits`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_reserve_open_deposit(), the only
+ * exported function that touches the table. The rows are read back by the
+ * coin history, which test_coin_history.c covers.
+ *
+ * The table's primary key is (coin_pub, coin_sig), which is what makes a
+ * replayed request idempotent. On insufficient coin balance the row is
+ * inserted first and the balance check fails afterwards, so the caller has
+ * to roll back -- the check below does exactly that, since that is how the
+ * /reserves/$RP/open handler uses the function.
+ */
+#include "test_common.h"
+#include "exchange-database/commit.h"
+#include "exchange-database/get_known_coin.h"
+#include "exchange-database/insert_reserve_open_deposit.h"
+#include "exchange-database/rollback.h"
+#include "exchange-database/start.h"
+
+
+/**
+ * Read the remaining balance of a coin.
+ *
+ * @param pg the database context
+ * @param coin_pub coin to look up
+ * @param[out] remaining set to the coin's remaining value
+ */
+static void
+coin_remaining (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_CoinSpendPublicKeyP *coin_pub,
+ struct TALER_Amount *remaining)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_auto_from_type (coin_pub),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ TALER_PQ_result_spec_amount ("remaining",
+ pg->currency,
+ remaining),
+ GNUNET_PQ_result_spec_end
+ };
+
+ GNUNET_assert (GNUNET_OK ==
+ GNUNET_PQ_prepare_anon (pg->conn,
+ "SELECT remaining"
+ " FROM known_coins"
+ " WHERE coin_pub=$1;"));
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs));
+}
+
+
+/**
+ * Spend part of a coin on keeping a reserve open.
+ *
+ * @param pg the database context
+ * @param coin the coin to spend from
+ * @param known_coin_id row of @a coin
+ * @param reserve_pub reserve being kept open
+ * @param seed seed for the coin and reserve signatures
+ * @param amount how much of the coin to spend, e.g. "1"
+ * @param[out] insufficient_funds set if the coin does not have that much
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+open_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_CoinPublicInfo *coin,
+ uint64_t known_coin_id,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ uint32_t seed,
+ const char *amount,
+ bool *insufficient_funds)
+{
+ struct TALER_CoinSpendSignatureP coin_sig;
+ struct TALER_ReserveSignatureP reserve_sig;
+ struct TALER_Amount a = TDB_amount (amount);
+
+ TDB_fill (&coin_sig,
+ sizeof (coin_sig),
+ seed);
+ TDB_fill (&reserve_sig,
+ sizeof (reserve_sig),
+ seed);
+ return TALER_EXCHANGEDB_insert_reserve_open_deposit (pg,
+ coin,
+ &coin_sig,
+ known_coin_id,
+ &a,
+ &reserve_sig,
+ reserve_pub,
+ insufficient_funds);
+}
+
+
+/**
+ * Spending part of a coin records the deposit and debits the coin.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Denom denom;
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_Amount remaining;
+ struct TALER_Amount expect = TDB_amount ("4");
+ uint64_t known_coin_id;
+ bool insufficient = true;
+
+ FAILIF (0 != TDB_count (pg,
+ "FROM reserves_open_deposits"));
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_coin (pg,
+ &denom,
+ 10,
+ &coin,
+ &known_coin_id);
+ TDB_reserve (pg,
+ 10,
+ "0",
+ &reserve_pub);
+
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ open_deposit (pg,
+ &coin,
+ known_coin_id,
+ &reserve_pub,
+ 10,
+ "1",
+ &insufficient),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (insufficient,
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM reserves_open_deposits"),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ coin_remaining (pg,
+ &coin.coin_pub,
+ &remaining);
+ FAILIF_C (0 != TALER_amount_cmp (&remaining,
+ &expect),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+
+ /* a replay of the very same request is absorbed and does not debit the
+ coin a second time */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ open_deposit (pg,
+ &coin,
+ known_coin_id,
+ &reserve_pub,
+ 10,
+ "1",
+ &insufficient),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (insufficient,
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM reserves_open_deposits"),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ coin_remaining (pg,
+ &coin.coin_pub,
+ &remaining);
+ FAILIF_C (0 != TALER_amount_cmp (&remaining,
+ &expect),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ TDB_coin_free (&coin);
+ TDB_denom_free (&denom);
+ return 0;
+}
+
+
+/**
+ * A second, larger deposit from the same coin is refused, and the caller
+ * has to roll back to undo the row the function inserted first.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insufficient_funds (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Denom denom;
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_Amount remaining;
+ struct TALER_Amount expect = TDB_amount ("4");
+ uint64_t known_coin_id;
+ bool insufficient = false;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_coin (pg,
+ &denom,
+ 10,
+ &coin,
+ &known_coin_id);
+ TDB_reserve (pg,
+ 10,
+ "0",
+ &reserve_pub);
+
+ FAILIF_C (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "test-open-deposit-insufficient"),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ /* EUR:4 is left, so EUR:5 cannot be taken */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ open_deposit (pg,
+ &coin,
+ known_coin_id,
+ &reserve_pub,
+ 11,
+ "5",
+ &insufficient),
+ TALER_EXCHANGEDB_rollback (pg);
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (! insufficient,
+ TALER_EXCHANGEDB_rollback (pg);
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ TALER_EXCHANGEDB_rollback (pg);
+ /* the rollback took the row with it */
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM reserves_open_deposits"),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ coin_remaining (pg,
+ &coin.coin_pub,
+ &remaining);
+ FAILIF_C (0 != TALER_amount_cmp (&remaining,
+ &expect),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ TDB_coin_free (&coin);
+ TDB_denom_free (&denom);
+ return 0;
+}
+
+
+/**
+ * Spending exactly what is left works and leaves the coin at zero.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_exact_balance (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TDB_Denom denom;
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_Amount remaining;
+ struct TALER_Amount zero = TDB_amount ("0");
+ uint64_t known_coin_id;
+ bool insufficient = true;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_coin (pg,
+ &denom,
+ 10,
+ &coin,
+ &known_coin_id);
+ TDB_reserve (pg,
+ 10,
+ "0",
+ &reserve_pub);
+ FAILIF_C (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "test-open-deposit-exact"),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ open_deposit (pg,
+ &coin,
+ known_coin_id,
+ &reserve_pub,
+ 12,
+ "4",
+ &insufficient),
+ TALER_EXCHANGEDB_rollback (pg);
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (insufficient,
+ TALER_EXCHANGEDB_rollback (pg);
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (0 >
+ TALER_EXCHANGEDB_commit (pg),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ coin_remaining (pg,
+ &coin.coin_pub,
+ &remaining);
+ FAILIF_C (0 != TALER_amount_cmp (&remaining,
+ &zero),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (2 != TDB_count (pg,
+ "FROM reserves_open_deposits"),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ /* and now even the smallest further deposit is refused */
+ insufficient = false;
+ FAILIF_C (GNUNET_OK !=
+ TALER_EXCHANGEDB_start (pg,
+ "test-open-deposit-drained"),
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ open_deposit (pg,
+ &coin,
+ known_coin_id,
+ &reserve_pub,
+ 13,
+ "0.01",
+ &insufficient),
+ TALER_EXCHANGEDB_rollback (pg);
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ FAILIF_C (! insufficient,
+ TALER_EXCHANGEDB_rollback (pg);
+ TDB_coin_free (&coin); TDB_denom_free (&denom));
+ TALER_EXCHANGEDB_rollback (pg);
+ TDB_coin_free (&coin);
+ TDB_denom_free (&denom);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "reserves-open-deposits-deposit",
+ &check_deposit },
+ { "reserves-open-deposits-insufficient-funds",
+ &check_insufficient_funds },
+ { "reserves-open-deposits-exact-balance",
+ &check_exact_balance },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-reserves-open-deposits",
+ "Tests for the exchangedb `reserves_open_deposits' table",
+ tests);
+}
+
+
+/* end of test_reserves_open_deposits.c */
diff --git a/src/exchangedb/test_reserves_open_requests.c b/src/exchangedb/test_reserves_open_requests.c
@@ -0,0 +1,621 @@
+/*
+ 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/test_reserves_open_requests.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `reserves_open_requests`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_reserve_open() and
+ * #TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id().
+ *
+ * The table references `reserves`, which TDB_reserve() creates. The
+ * interesting cases of do_reserve_open() are the ones where it declines to
+ * do the work: an unknown reserve, a payment that does not cover the cost,
+ * a reserve that cannot pay its share, and a request that asks for nothing
+ * that is not already granted. Only the success path writes the table.
+ */
+#include "test_common.h"
+#include "exchange-database/do_reserve_open.h"
+#include "exchange-database/get_reserve.h"
+#include "exchange-database/iterate_reserve_open_above_serial_id.h"
+
+
+/**
+ * A year in microseconds, as exchange_do_reserve_open() counts it.
+ */
+#define YEAR_US 31536000000000LLU
+
+
+/**
+ * Closure for #open_cb().
+ */
+struct OpenContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Reserve we are looking for, NULL to match nothing.
+ */
+ const struct TALER_ReservePublicKeyP *reserve_pub;
+
+ /**
+ * How many times did we see @e reserve_pub?
+ */
+ unsigned int matched;
+
+ /**
+ * Reserve payment reported for @e reserve_pub.
+ */
+ struct TALER_Amount reserve_payment;
+
+ /**
+ * Purse limit reported for @e reserve_pub.
+ */
+ uint32_t purse_limit;
+
+ /**
+ * Signature reported for @e reserve_pub.
+ */
+ struct TALER_ReserveSignatureP reserve_sig;
+
+ /**
+ * Expiration reported for @e reserve_pub.
+ */
+ struct GNUNET_TIME_Timestamp expiration;
+
+ /**
+ * Request timestamp reported for @e reserve_pub.
+ */
+ struct GNUNET_TIME_Timestamp request_timestamp;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id().
+ *
+ * @param cls a `struct OpenContext *`
+ * @param rowid row of the request
+ * @param reserve_payment how much the reserve itself paid
+ * @param request_timestamp when the request was made
+ * @param reserve_expiration until when the reserve is now open
+ * @param purse_limit how many purses were asked for
+ * @param reserve_pub the reserve in question
+ * @param reserve_sig signature affirming the request
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+open_cb (void *cls,
+ uint64_t rowid,
+ const struct TALER_Amount *reserve_payment,
+ struct GNUNET_TIME_Timestamp request_timestamp,
+ struct GNUNET_TIME_Timestamp reserve_expiration,
+ uint32_t purse_limit,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ const struct TALER_ReserveSignatureP *reserve_sig)
+{
+ struct OpenContext *ctx = cls;
+
+ (void) rowid;
+ ctx->total++;
+ if ( (NULL != ctx->reserve_pub) &&
+ (0 == GNUNET_memcmp (reserve_pub,
+ ctx->reserve_pub)) )
+ {
+ ctx->matched++;
+ ctx->reserve_payment = *reserve_payment;
+ ctx->purse_limit = purse_limit;
+ ctx->reserve_sig = *reserve_sig;
+ ctx->expiration = reserve_expiration;
+ ctx->request_timestamp = request_timestamp;
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Ask to keep a reserve open.
+ *
+ * @param pg the database context
+ * @param reserve_pub reserve to open
+ * @param seed seed for the reserve signature
+ * @param total_paid total the client is paying, e.g. "1"
+ * @param reserve_payment share of that taken from the reserve, e.g. "1"
+ * @param purse_limit how many purses the client wants
+ * @param desired_expiration until when the client wants the reserve open
+ * @param now when the client made the request
+ * @param open_fee annual fee, e.g. "1"
+ * @param[out] no_funds set if the reserve cannot pay its share
+ * @param[out] open_cost set to what the operation costs
+ * @param[out] final_expiration set to the expiration that was granted
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+open_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ uint32_t seed,
+ const char *total_paid,
+ const char *reserve_payment,
+ uint32_t purse_limit,
+ struct GNUNET_TIME_Timestamp desired_expiration,
+ struct GNUNET_TIME_Timestamp now,
+ const char *open_fee,
+ bool *no_funds,
+ struct TALER_Amount *open_cost,
+ struct GNUNET_TIME_Timestamp *final_expiration)
+{
+ struct TALER_ReserveSignatureP reserve_sig;
+ struct TALER_Amount paid = TDB_amount (total_paid);
+ struct TALER_Amount payment = TDB_amount (reserve_payment);
+ struct TALER_Amount fee = TDB_amount (open_fee);
+ struct TALER_Amount balance;
+
+ TDB_fill (&reserve_sig,
+ sizeof (reserve_sig),
+ seed);
+ return TALER_EXCHANGEDB_do_reserve_open (pg,
+ reserve_pub,
+ &paid,
+ &payment,
+ purse_limit,
+ &reserve_sig,
+ desired_expiration,
+ now,
+ &fee,
+ no_funds,
+ &balance,
+ open_cost,
+ final_expiration);
+}
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * A reserve that does not exist cannot be opened, and nothing is on file.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_Amount open_cost;
+ struct GNUNET_TIME_Timestamp final_expiration;
+ struct OpenContext ctx = { 0 };
+ bool no_funds = false;
+
+ TDB_FILL (reserve_pub,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ open_reserve (pg,
+ &reserve_pub,
+ 1,
+ "1",
+ "1",
+ 1,
+ ts (2000000000),
+ ts (1600000000),
+ "1",
+ &no_funds,
+ &open_cost,
+ &final_expiration));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id (
+ pg,
+ 0,
+ &open_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ FAILIF (0 != TDB_count (pg,
+ "FROM reserves_open_requests"));
+ return 0;
+}
+
+
+/**
+ * Opening a reserve charges it, extends it and records the request.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_open (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_EXCHANGEDB_Reserve reserve;
+ struct TALER_Amount open_cost;
+ struct TALER_Amount expect_cost = TDB_amount ("1");
+ struct TALER_Amount expect_balance = TDB_amount ("9");
+ struct TALER_ReserveSignatureP expect_sig;
+ struct GNUNET_TIME_Timestamp final_expiration;
+ struct GNUNET_TIME_Timestamp now = ts (1600000000);
+ struct OpenContext ctx;
+ bool no_funds = true;
+
+ TDB_reserve (pg,
+ 10,
+ "10",
+ &reserve_pub);
+ /* TDB_reserve() gives the reserve an expiration far in the future, which
+ would leave nothing for this call to do; pull it back to `now'. */
+ {
+ char *hex = TDB_hex (&reserve_pub,
+ sizeof (reserve_pub));
+
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE reserves"
+ " SET expiration_date=%llu"
+ " WHERE reserve_pub=decode('%s','hex');",
+ (unsigned long long) now.abs_time.abs_value_us,
+ hex),
+ GNUNET_free (hex));
+ GNUNET_free (hex);
+ }
+
+ /* one year of extension at an annual fee of EUR:1 */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ open_reserve (pg,
+ &reserve_pub,
+ 10,
+ "1",
+ "1",
+ 1,
+ ts (1600000000 + YEAR_US / 1000000),
+ now,
+ "1",
+ &no_funds,
+ &open_cost,
+ &final_expiration));
+ FAILIF (no_funds);
+ FAILIF (0 != TALER_amount_cmp (&open_cost,
+ &expect_cost));
+ FAILIF (final_expiration.abs_time.abs_value_us !=
+ now.abs_time.abs_value_us + YEAR_US);
+
+ /* the reserve paid for it */
+ memset (&reserve,
+ 0,
+ sizeof (reserve));
+ reserve.pub = reserve_pub;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve (pg,
+ &reserve));
+ FAILIF (0 != TALER_amount_cmp (&reserve.balance,
+ &expect_balance));
+ FAILIF (GNUNET_TIME_timestamp_cmp (reserve.expiry,
+ !=,
+ final_expiration));
+
+ /* ...and the request itself was recorded */
+ TDB_FILL (expect_sig,
+ 10);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.reserve_pub = &reserve_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id (
+ pg,
+ 0,
+ &open_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (0 != GNUNET_memcmp (&ctx.reserve_sig,
+ &expect_sig));
+ FAILIF (0 != TALER_amount_cmp (&ctx.reserve_payment,
+ &expect_cost));
+ FAILIF (1 != ctx.purse_limit);
+ FAILIF (GNUNET_TIME_timestamp_cmp (ctx.expiration,
+ !=,
+ final_expiration));
+ FAILIF (GNUNET_TIME_timestamp_cmp (ctx.request_timestamp,
+ !=,
+ now));
+
+ /* a retry of the very same request is absorbed */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ open_reserve (pg,
+ &reserve_pub,
+ 10,
+ "1",
+ "1",
+ 1,
+ ts (1600000000 + YEAR_US / 1000000),
+ now,
+ "1",
+ &no_funds,
+ &open_cost,
+ &final_expiration));
+ FAILIF (1 != TDB_count (pg,
+ "FROM reserves_open_requests"));
+ return 0;
+}
+
+
+/**
+ * A request that asks for nothing beyond what the reserve already has
+ * costs nothing and is not recorded.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_nothing_to_do (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_Amount open_cost;
+ struct TALER_Amount zero = TDB_amount ("0");
+ struct GNUNET_TIME_Timestamp final_expiration;
+ bool no_funds = true;
+ uint64_t before;
+
+ TDB_reserve (pg,
+ 11,
+ "10",
+ &reserve_pub);
+ before = TDB_count (pg,
+ "FROM reserves_open_requests");
+ /* The reserve already runs to 2096 and no additional purses are asked
+ for, so there is nothing left to grant. Note that asking for even one
+ purse would *not* be a no-op: a fresh reserve has purses_allowed=0. */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ open_reserve (pg,
+ &reserve_pub,
+ 11,
+ "1",
+ "1",
+ 0,
+ ts (1600000000),
+ ts (1600000000),
+ "1",
+ &no_funds,
+ &open_cost,
+ &final_expiration));
+ FAILIF (no_funds);
+ FAILIF (0 != TALER_amount_cmp (&open_cost,
+ &zero));
+ FAILIF (before != TDB_count (pg,
+ "FROM reserves_open_requests"));
+ return 0;
+}
+
+
+/**
+ * Too little paid, or too little in the reserve, both decline the work.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insufficient (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_Amount open_cost;
+ struct TALER_Amount expect_cost = TDB_amount ("2");
+ struct GNUNET_TIME_Timestamp final_expiration;
+ struct GNUNET_TIME_Timestamp now = ts (1600000000);
+ bool no_funds = false;
+ uint64_t before;
+
+ TDB_reserve (pg,
+ 12,
+ "1",
+ &reserve_pub);
+ {
+ char *hex = TDB_hex (&reserve_pub,
+ sizeof (reserve_pub));
+
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE reserves"
+ " SET expiration_date=%llu"
+ " WHERE reserve_pub=decode('%s','hex');",
+ (unsigned long long) now.abs_time.abs_value_us,
+ hex),
+ GNUNET_free (hex));
+ GNUNET_free (hex);
+ }
+ before = TDB_count (pg,
+ "FROM reserves_open_requests");
+
+ /* two years at EUR:1 a year, but only EUR:1 offered */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ open_reserve (pg,
+ &reserve_pub,
+ 12,
+ "1",
+ "1",
+ 1,
+ ts (1600000000 + 2 * (YEAR_US / 1000000)),
+ now,
+ "1",
+ &no_funds,
+ &open_cost,
+ &final_expiration));
+ FAILIF (no_funds);
+ FAILIF (0 != TALER_amount_cmp (&open_cost,
+ &expect_cost));
+ FAILIF (before != TDB_count (pg,
+ "FROM reserves_open_requests"));
+
+ /* enough offered, but the reserve holds only EUR:1 of the EUR:2 */
+ no_funds = false;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ open_reserve (pg,
+ &reserve_pub,
+ 12,
+ "2",
+ "2",
+ 1,
+ ts (1600000000 + 2 * (YEAR_US / 1000000)),
+ now,
+ "1",
+ &no_funds,
+ &open_cost,
+ &final_expiration));
+ FAILIF (! no_funds);
+ FAILIF (before != TDB_count (pg,
+ "FROM reserves_open_requests"));
+ return 0;
+}
+
+
+/**
+ * The iterator's serial bound and its abort return behave as documented.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_Amount open_cost;
+ struct GNUNET_TIME_Timestamp final_expiration;
+ struct GNUNET_TIME_Timestamp now = ts (1600000000);
+ struct OpenContext ctx;
+ bool no_funds = true;
+
+ /* a second recorded request, from another reserve */
+ TDB_reserve (pg,
+ 13,
+ "10",
+ &reserve_pub);
+ {
+ char *hex = TDB_hex (&reserve_pub,
+ sizeof (reserve_pub));
+
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE reserves"
+ " SET expiration_date=%llu"
+ " WHERE reserve_pub=decode('%s','hex');",
+ (unsigned long long) now.abs_time.abs_value_us,
+ hex),
+ GNUNET_free (hex));
+ GNUNET_free (hex);
+ }
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ open_reserve (pg,
+ &reserve_pub,
+ 13,
+ "1",
+ "1",
+ 1,
+ ts (1600000000 + YEAR_US / 1000000),
+ now,
+ "1",
+ &no_funds,
+ &open_cost,
+ &final_expiration));
+ FAILIF (2 != TDB_count (pg,
+ "FROM reserves_open_requests"));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id (
+ pg,
+ 0,
+ &open_cb,
+ &ctx));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id (
+ pg,
+ 1000,
+ &open_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_reserve_open_above_serial_id (
+ pg,
+ 0,
+ &open_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "reserves-open-requests-empty",
+ &check_empty },
+ { "reserves-open-requests-open",
+ &check_open },
+ { "reserves-open-requests-nothing-to-do",
+ &check_nothing_to_do },
+ { "reserves-open-requests-insufficient",
+ &check_insufficient },
+ { "reserves-open-requests-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-reserves-open-requests",
+ "Tests for the exchangedb `reserves_open_requests' table",
+ tests);
+}
+
+
+/* end of test_reserves_open_requests.c */
diff --git a/src/exchangedb/test_revolving_work_shards.c b/src/exchangedb/test_revolving_work_shards.c
@@ -0,0 +1,269 @@
+/*
+ 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/test_revolving_work_shards.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `revolving_work_shards`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_begin_revolving_shard(),
+ * #TALER_EXCHANGEDB_release_revolving_shard() and, for its
+ * `revolving_work_shards` half, #TALER_EXCHANGEDB_delete_shard_locks().
+ *
+ * Unlike `work_shards`, this table is finite: once the configured shard
+ * limit is covered, the same shards are handed out over and over, which is
+ * what the checks below pin down. No foreign keys, so no fixtures.
+ */
+#include "test_common.h"
+#include "exchange-database/begin_revolving_shard.h"
+#include "exchange-database/delete_shard_locks.h"
+#include "exchange-database/release_revolving_shard.h"
+
+
+/**
+ * Releasing a shard that was never claimed does nothing.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_release_revolving_shard (pg,
+ "rws-nothing",
+ 0,
+ 9));
+ FAILIF (0 != TDB_count (pg,
+ "FROM revolving_work_shards"));
+ return 0;
+}
+
+
+/**
+ * Fresh shards tile the range [0,shard_limit) and are handed out active.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_claim_fresh (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ uint32_t start;
+ uint32_t end;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_revolving_shard (pg,
+ "rws-claim",
+ 10,
+ 25,
+ &start,
+ &end));
+ FAILIF (0 != start);
+ FAILIF (9 != end);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_revolving_shard (pg,
+ "rws-claim",
+ 10,
+ 25,
+ &start,
+ &end));
+ FAILIF (10 != start);
+ FAILIF (19 != end);
+ /* the last shard is clipped to the limit */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_revolving_shard (pg,
+ "rws-claim",
+ 10,
+ 25,
+ &start,
+ &end));
+ FAILIF (20 != start);
+ FAILIF (25 != end);
+ FAILIF (3 != TDB_count (pg,
+ "FROM revolving_work_shards"
+ " WHERE job_name='rws-claim'"
+ " AND active"));
+ return 0;
+}
+
+
+/**
+ * Once the range is covered, no further shard can be claimed until one is
+ * released, and then it is that one that comes back.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_exhausted_and_release (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ uint32_t start;
+ uint32_t end;
+
+ /* everything from check_claim_fresh() is still active */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_begin_revolving_shard (pg,
+ "rws-claim",
+ 10,
+ 25,
+ &start,
+ &end));
+
+ /* releasing a range that was never claimed changes nothing */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_release_revolving_shard (pg,
+ "rws-claim",
+ 0,
+ 8));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_release_revolving_shard (pg,
+ "rws-claim-elsewhere",
+ 10,
+ 19));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_release_revolving_shard (pg,
+ "rws-claim",
+ 10,
+ 19));
+ FAILIF (1 != TDB_count (pg,
+ "FROM revolving_work_shards"
+ " WHERE job_name='rws-claim'"
+ " AND NOT active"));
+ /* the released shard is the only one available, so it comes back */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_revolving_shard (pg,
+ "rws-claim",
+ 10,
+ 25,
+ &start,
+ &end));
+ FAILIF (10 != start);
+ FAILIF (19 != end);
+ FAILIF (3 != TDB_count (pg,
+ "FROM revolving_work_shards"
+ " WHERE job_name='rws-claim'"
+ " AND active"));
+ /* releasing twice is idempotent from the caller's point of view */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_release_revolving_shard (pg,
+ "rws-claim",
+ 10,
+ 19));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_release_revolving_shard (pg,
+ "rws-claim",
+ 10,
+ 19));
+ return 0;
+}
+
+
+/**
+ * A shard size at or above the limit yields a single shard covering
+ * everything.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_single_shard (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ uint32_t start;
+ uint32_t end;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_revolving_shard (pg,
+ "rws-single",
+ 1000,
+ 4,
+ &start,
+ &end));
+ FAILIF (0 != start);
+ FAILIF (4 != end);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_begin_revolving_shard (pg,
+ "rws-single",
+ 1000,
+ 4,
+ &start,
+ &end));
+ return 0;
+}
+
+
+/**
+ * Deleting the shard locks empties the table. Runs last.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_delete_locks (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ uint32_t start;
+ uint32_t end;
+
+ FAILIF (0 == TDB_count (pg,
+ "FROM revolving_work_shards"));
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_delete_shard_locks (pg));
+ FAILIF (0 != TDB_count (pg,
+ "FROM revolving_work_shards"));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_revolving_shard (pg,
+ "rws-claim",
+ 10,
+ 25,
+ &start,
+ &end));
+ FAILIF (0 != start);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "revolving-work-shards-empty",
+ &check_empty },
+ { "revolving-work-shards-claim-fresh",
+ &check_claim_fresh },
+ { "revolving-work-shards-exhausted-and-release",
+ &check_exhausted_and_release },
+ { "revolving-work-shards-single-shard",
+ &check_single_shard },
+ { "revolving-work-shards-delete-locks",
+ &check_delete_locks },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-revolving-work-shards",
+ "Tests for the exchangedb `revolving_work_shards' table",
+ tests);
+}
+
+
+/* end of test_revolving_work_shards.c */
diff --git a/src/exchangedb/test_signkey_revocations.c b/src/exchangedb/test_signkey_revocations.c
@@ -0,0 +1,275 @@
+/*
+ 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/test_signkey_revocations.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `signkey_revocations`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_signkey_revocation() and
+ * #TALER_EXCHANGEDB_get_signkey_revocation().
+ *
+ * `signkey_revocations` references `exchange_sign_keys`, and both functions
+ * find the row through the signing key rather than through the revocation's
+ * own primary key -- so an unknown signing key has to come back as "no
+ * revocation" rather than as an error. The fixture is inserted with
+ * #TALER_EXCHANGEDB_insert_signkey(), whose own table is covered by
+ * test_exchange_sign_keys.c.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_signkey.h"
+#include "exchange-database/insert_signkey_revocation.h"
+#include "exchange-database/get_signkey_revocation.h"
+#include "exchange-database/iterate_active_signkeys.h"
+
+
+/**
+ * Insert a signing key that is valid for another hour.
+ *
+ * @param pg the database context
+ * @param seed seed for the key and its master signature
+ * @param[out] exchange_pub set to the public key
+ */
+static void
+add_signkey (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint32_t seed,
+ struct TALER_ExchangePublicKeyP *exchange_pub)
+{
+ struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get ();
+ struct TALER_MasterSignatureP master_sig;
+ struct TALER_EXCHANGEDB_SignkeyMetaData meta;
+
+ TDB_fill (exchange_pub,
+ sizeof (*exchange_pub),
+ seed);
+ TDB_FILL (master_sig,
+ seed);
+ meta.start = GNUNET_TIME_absolute_to_timestamp (now);
+ meta.expire_sign
+ = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_add (now,
+ GNUNET_TIME_UNIT_HOURS));
+ meta.expire_legal
+ = GNUNET_TIME_absolute_to_timestamp (
+ GNUNET_TIME_absolute_add (now,
+ GNUNET_TIME_relative_multiply (
+ GNUNET_TIME_UNIT_HOURS,
+ 24)));
+ GNUNET_assert (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ TALER_EXCHANGEDB_insert_signkey (pg,
+ exchange_pub,
+ &meta,
+ &master_sig));
+}
+
+
+/**
+ * Counts the keys the active-signkey iterator reports.
+ */
+struct CountContext
+{
+ /**
+ * Key we are looking for.
+ */
+ const struct TALER_ExchangePublicKeyP *exchange_pub;
+
+ /**
+ * How many times did we see @e exchange_pub?
+ */
+ unsigned int matched;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_active_signkeys().
+ *
+ * @param cls a `struct CountContext *`
+ * @param exchange_pub public key of the exchange
+ * @param meta meta data of @a exchange_pub
+ * @param master_sig master signature over @a exchange_pub
+ */
+static void
+count_cb (void *cls,
+ const struct TALER_ExchangePublicKeyP *exchange_pub,
+ const struct TALER_EXCHANGEDB_SignkeyMetaData *meta,
+ const struct TALER_MasterSignatureP *master_sig)
+{
+ struct CountContext *ctx = cls;
+
+ (void) meta;
+ (void) master_sig;
+ if (0 == GNUNET_memcmp (exchange_pub,
+ ctx->exchange_pub))
+ ctx->matched++;
+}
+
+
+/**
+ * A signing key that does not exist has no revocation, and revoking it
+ * does nothing.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_unknown_signkey (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ExchangePublicKeyP exchange_pub;
+ struct TALER_MasterSignatureP master_sig;
+ struct TALER_MasterSignatureP got;
+
+ TDB_FILL (exchange_pub,
+ 1);
+ TDB_FILL (master_sig,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_signkey_revocation (pg,
+ &exchange_pub,
+ &got));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_insert_signkey_revocation (pg,
+ &exchange_pub,
+ &master_sig));
+ FAILIF (0 != TDB_count (pg,
+ "FROM signkey_revocations"));
+ return 0;
+}
+
+
+/**
+ * A key that exists but was not revoked has no revocation either.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_not_revoked (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ExchangePublicKeyP exchange_pub;
+ struct TALER_MasterSignatureP got;
+
+ add_signkey (pg,
+ 2,
+ &exchange_pub);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_signkey_revocation (pg,
+ &exchange_pub,
+ &got));
+ return 0;
+}
+
+
+/**
+ * Revoking a key records the signature and takes the key out of the
+ * active set.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_revoke (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ExchangePublicKeyP exchange_pub;
+ struct TALER_MasterSignatureP master_sig;
+ struct TALER_MasterSignatureP got;
+ struct CountContext ctx;
+
+ add_signkey (pg,
+ 3,
+ &exchange_pub);
+ TDB_FILL (master_sig,
+ 33);
+
+ /* while unrevoked, the key is active */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.exchange_pub = &exchange_pub;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_active_signkeys (pg,
+ &count_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_signkey_revocation (pg,
+ &exchange_pub,
+ &master_sig));
+ memset (&got,
+ 0,
+ sizeof (got));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_signkey_revocation (pg,
+ &exchange_pub,
+ &got));
+ FAILIF (0 != GNUNET_memcmp (&got,
+ &master_sig));
+
+ /* ...and now it is gone from the active set */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.exchange_pub = &exchange_pub;
+ FAILIF (0 >
+ TALER_EXCHANGEDB_iterate_active_signkeys (pg,
+ &count_cb,
+ &ctx));
+ FAILIF (0 != ctx.matched);
+
+ /* the untouched key from the previous check is still fine */
+ {
+ struct TALER_ExchangePublicKeyP other;
+
+ TDB_FILL (other,
+ 2);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_signkey_revocation (pg,
+ &other,
+ &got));
+ }
+ FAILIF (1 != TDB_count (pg,
+ "FROM signkey_revocations"));
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "signkey-revocations-unknown-signkey",
+ &check_unknown_signkey },
+ { "signkey-revocations-not-revoked",
+ &check_not_revoked },
+ { "signkey-revocations-revoke",
+ &check_revoke },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-signkey-revocations",
+ "Tests for the exchangedb `signkey_revocations' table",
+ tests);
+}
+
+
+/* end of test_signkey_revocations.c */
diff --git a/src/exchangedb/test_table.sh b/src/exchangedb/test_table.sh
@@ -0,0 +1,63 @@
+#!/bin/sh
+# This file is in the public domain.
+#
+# Driver for the per-table src/exchangedb/ tests. Takes the test binary to
+# run as its first argument, provisions a scratch database of its own for it
+# and removes the database again afterwards. No pre-existing database is
+# ever touched: the name is derived from the binary and the PID and is
+# deliberately *not* taken from the environment.
+set -eu
+
+BINARY="$1"
+shift
+
+# Where the build tree lives; meson passes this, the fallback is for
+# running the script by hand from its build directory.
+BUILD_ROOT="${TALER_BUILD_ROOT:-$(cd ../../.. && pwd)}"
+SQL_DIR="${TALER_SQL_DIR:-${BUILD_ROOT}/src/exchangedb/sql-schema}"
+
+# Skip (77) rather than fail if there is no usable PostgreSQL around.
+command -v createdb > /dev/null 2>&1 || exit 77
+command -v dropdb > /dev/null 2>&1 || exit 77
+psql -l < /dev/null > /dev/null 2>&1 || exit 77
+
+# Postgres database names are limited to 63 characters, so use a digest of
+# the binary name rather than the name itself.
+TAG=$(echo "$BINARY" | cksum | cut -d' ' -f1)
+DBNAME="taler_exdb_t${TAG}_$$"
+CONF="${BINARY}_$$.conf"
+
+# The freshly built libraries must outrank any installed copy, as
+# LD_LIBRARY_PATH beats the build tree's RUNPATH.
+for d in "${BUILD_ROOT}"/src/*/; do
+ LD_LIBRARY_PATH="${d%/}${LD_LIBRARY_PATH:+:}${LD_LIBRARY_PATH:-}"
+done
+export LD_LIBRARY_PATH
+
+cleanup ()
+{
+ dropdb --if-exists "$DBNAME" > /dev/null 2>&1 || true
+ rm -f "$CONF"
+}
+trap cleanup EXIT
+
+cat > "$CONF" <<EOF
+[exchange]
+CURRENCY = EUR
+BASE_URL = http://localhost/
+[exchangedb-postgres]
+CONFIG = postgres:///${DBNAME}
+SQL_DIR = ${SQL_DIR}/
+[exchangedb]
+MAX_AML_PROGRAM_RUNTIME = 1 minute
+IDLE_RESERVE_EXPIRATION_TIME = 4 weeks
+LEGAL_RESERVE_EXPIRATION_TIME = 7 years
+AGGREGATOR_SHIFT = 1s
+DEFAULT_PURSE_LIMIT = 1
+EOF
+
+createdb "$DBNAME" > /dev/null 2>&1 || exit 77
+
+RET=0
+"./$BINARY" -c "$CONF" "$@" || RET=$?
+exit $RET
diff --git a/src/exchangedb/test_wire_accounts.c b/src/exchangedb/test_wire_accounts.c
@@ -0,0 +1,406 @@
+/*
+ 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/test_wire_accounts.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `wire_accounts`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_wire(), #TALER_EXCHANGEDB_update_wire(),
+ * #TALER_EXCHANGEDB_get_wire_timestamp() and
+ * #TALER_EXCHANGEDB_iterate_wire_accounts().
+ *
+ * `wire_accounts` has no foreign keys -- it is the exchange's own list of
+ * bank accounts, not the `wire_targets` list of everyone else's. The
+ * optional columns (conversion URL, gateways, bank label) are exercised
+ * both set and NULL, since the iterator has to hand both back.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_wire.h"
+#include "exchange-database/update_wire.h"
+#include "exchange-database/get_wire_timestamp.h"
+#include "exchange-database/iterate_wire_accounts.h"
+
+
+/**
+ * Closure for #account_cb().
+ */
+struct AccountContext
+{
+ /**
+ * Account we are looking for.
+ */
+ const char *payto_uri;
+
+ /**
+ * How many accounts did the callback see in total?
+ */
+ unsigned int total;
+
+ /**
+ * How many times did we see @e payto_uri?
+ */
+ unsigned int matched;
+
+ /**
+ * Set if the account was reported with data we did not store.
+ */
+ bool mismatch;
+
+ /**
+ * Set if the account was reported with a conversion URL.
+ */
+ bool have_conversion_url;
+
+ /**
+ * Set if the account was reported with a bank label.
+ */
+ bool have_bank_label;
+
+ /**
+ * Priority reported for the account.
+ */
+ int64_t priority;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_wire_accounts().
+ *
+ * @param cls a `struct AccountContext *`
+ * @param payto_uri the account
+ * @param conversion_url conversion service, NULL if none
+ * @param open_banking_gateway open banking gateway, NULL if none
+ * @param prepared_transfer_url transfer gateway, NULL if none
+ * @param debit_restrictions debit restrictions on the account
+ * @param credit_restrictions credit restrictions on the account
+ * @param master_sig master signature over the account
+ * @param bank_label label for the UI, NULL if none
+ * @param priority ordering hint for the UI
+ */
+static void
+account_cb (void *cls,
+ const struct TALER_FullPayto payto_uri,
+ const char *conversion_url,
+ const char *open_banking_gateway,
+ const char *prepared_transfer_url,
+ const json_t *debit_restrictions,
+ const json_t *credit_restrictions,
+ const struct TALER_MasterSignatureP *master_sig,
+ const char *bank_label,
+ int64_t priority)
+{
+ struct AccountContext *ctx = cls;
+
+ (void) open_banking_gateway;
+ (void) prepared_transfer_url;
+ (void) master_sig;
+ ctx->total++;
+ if (0 != strcmp (payto_uri.full_payto,
+ ctx->payto_uri))
+ return;
+ ctx->matched++;
+ ctx->have_conversion_url = (NULL != conversion_url);
+ ctx->have_bank_label = (NULL != bank_label);
+ ctx->priority = priority;
+ if ( (! json_is_array (debit_restrictions)) ||
+ (! json_is_array (credit_restrictions)) )
+ ctx->mismatch = true;
+}
+
+
+/**
+ * Nothing is known about an account that was never inserted, and updating
+ * it does not create it.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_FullPayto payto = {
+ .full_payto = (char *) "payto://x-taler-bank/localhost/wa-none"
+ };
+ struct GNUNET_TIME_Timestamp last_date;
+ struct TALER_MasterSignatureP master_sig;
+ json_t *empty = json_array ();
+
+ TDB_FILL (master_sig,
+ 1);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_wire_timestamp (pg,
+ payto,
+ &last_date),
+ json_decref (empty));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_wire (pg,
+ payto,
+ NULL,
+ NULL,
+ NULL,
+ empty,
+ empty,
+ GNUNET_TIME_timestamp_get (),
+ &master_sig,
+ NULL,
+ 0,
+ true),
+ json_decref (empty));
+ FAILIF_C (0 != TDB_count (pg,
+ "FROM wire_accounts"),
+ json_decref (empty));
+ json_decref (empty);
+ return 0;
+}
+
+
+/**
+ * Insert an account with all optional columns set and read it back.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_full (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ const char *uri = "payto://x-taler-bank/localhost/wa-full";
+ struct TALER_FullPayto payto = {
+ .full_payto = (char *) uri
+ };
+ struct GNUNET_TIME_Timestamp start = GNUNET_TIME_timestamp_get ();
+ struct GNUNET_TIME_Timestamp last_date;
+ struct TALER_MasterSignatureP master_sig;
+ struct AccountContext ctx;
+ json_t *restrictions;
+
+ TDB_FILL (master_sig,
+ 2);
+ restrictions = json_pack ("[{s:s}]",
+ "type",
+ "deny");
+ GNUNET_assert (NULL != restrictions);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_wire (pg,
+ payto,
+ "https://conversion.example/",
+ "https://obg.example/",
+ "https://ptu.example/",
+ restrictions,
+ restrictions,
+ start,
+ &master_sig,
+ "My Bank",
+ 7),
+ json_decref (restrictions));
+ json_decref (restrictions);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_wire_timestamp (pg,
+ payto,
+ &last_date));
+ FAILIF (GNUNET_TIME_timestamp_cmp (last_date,
+ !=,
+ start));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.payto_uri = uri;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_wire_accounts (pg,
+ &account_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (ctx.mismatch);
+ FAILIF (! ctx.have_conversion_url);
+ FAILIF (! ctx.have_bank_label);
+ FAILIF (7 != ctx.priority);
+ return 0;
+}
+
+
+/**
+ * Insert an account with every optional column left out; the iterator has
+ * to hand the NULLs back as NULL.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert_minimal (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ const char *uri = "payto://x-taler-bank/localhost/wa-min";
+ struct TALER_FullPayto payto = {
+ .full_payto = (char *) uri
+ };
+ struct TALER_MasterSignatureP master_sig;
+ struct AccountContext ctx;
+ json_t *empty = json_array ();
+
+ TDB_FILL (master_sig,
+ 3);
+ GNUNET_assert (NULL != empty);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_insert_wire (pg,
+ payto,
+ NULL,
+ NULL,
+ NULL,
+ empty,
+ empty,
+ GNUNET_TIME_timestamp_get (),
+ &master_sig,
+ NULL,
+ -3),
+ json_decref (empty));
+ json_decref (empty);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.payto_uri = uri;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_wire_accounts (pg,
+ &account_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (ctx.mismatch);
+ FAILIF (ctx.have_conversion_url);
+ FAILIF (ctx.have_bank_label);
+ FAILIF (-3 != ctx.priority);
+ FAILIF (2 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * Disabling an account takes it out of the iterator but leaves the row.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_disable (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ const char *uri = "payto://x-taler-bank/localhost/wa-min";
+ struct TALER_FullPayto payto = {
+ .full_payto = (char *) uri
+ };
+ struct GNUNET_TIME_Timestamp change = GNUNET_TIME_timestamp_get ();
+ struct GNUNET_TIME_Timestamp last_date;
+ struct AccountContext ctx;
+
+ /* a disabled account may be stored without restrictions and signature */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_wire (pg,
+ payto,
+ NULL,
+ NULL,
+ NULL,
+ NULL,
+ NULL,
+ change,
+ NULL,
+ NULL,
+ 0,
+ false));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_wire_timestamp (pg,
+ payto,
+ &last_date));
+ FAILIF (GNUNET_TIME_timestamp_cmp (last_date,
+ !=,
+ change));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.payto_uri = uri;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_wire_accounts (pg,
+ &account_cb,
+ &ctx));
+ FAILIF (0 != ctx.matched);
+ FAILIF (1 != ctx.total);
+ FAILIF (2 != TDB_count (pg,
+ "FROM wire_accounts"));
+
+ /* re-enabling brings it back, with the new details */
+ {
+ struct TALER_MasterSignatureP master_sig;
+ json_t *empty = json_array ();
+
+ TDB_FILL (master_sig,
+ 4);
+ GNUNET_assert (NULL != empty);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_wire (pg,
+ payto,
+ "https://conv2.example/",
+ NULL,
+ NULL,
+ empty,
+ empty,
+ change,
+ &master_sig,
+ "Relabelled",
+ 11,
+ true),
+ json_decref (empty));
+ json_decref (empty);
+ }
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.payto_uri = uri;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_wire_accounts (pg,
+ &account_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (! ctx.have_conversion_url);
+ FAILIF (! ctx.have_bank_label);
+ FAILIF (11 != ctx.priority);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "wire-accounts-empty",
+ &check_empty },
+ { "wire-accounts-insert-full",
+ &check_insert_full },
+ { "wire-accounts-insert-minimal",
+ &check_insert_minimal },
+ { "wire-accounts-disable",
+ &check_disable },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-wire-accounts",
+ "Tests for the exchangedb `wire_accounts' table",
+ tests);
+}
+
+
+/* end of test_wire_accounts.c */
diff --git a/src/exchangedb/test_wire_fee.c b/src/exchangedb/test_wire_fee.c
@@ -0,0 +1,407 @@
+/*
+ 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/test_wire_fee.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `wire_fee`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_wire_fee(),
+ * #TALER_EXCHANGEDB_get_wire_fee(),
+ * #TALER_EXCHANGEDB_get_wire_fee_by_time() and
+ * #TALER_EXCHANGEDB_iterate_wire_fees().
+ *
+ * `wire_fee` has no foreign keys. What the checks pin down is the shape of
+ * the validity intervals: `get_wire_fee` treats them as [start,end), and
+ * `get_wire_fee_by_time` returns an invalid amount rather than an error
+ * when the range it is asked about spans two different fees.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_wire_fee.h"
+#include "exchange-database/get_wire_fee.h"
+#include "exchange-database/get_wire_fee_by_time.h"
+#include "exchange-database/iterate_wire_fees.h"
+
+
+/**
+ * Wire method the checks use.
+ */
+#define METHOD "x-taler-bank"
+
+/**
+ * Start of the first fee period; a fixed point in time so that the checks
+ * do not depend on when they run.
+ */
+#define T0 1600000000
+
+/**
+ * One hour, in seconds.
+ */
+#define HOUR 3600
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #fee_cb().
+ */
+struct FeeContext
+{
+ /**
+ * How many fee rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Sum of the whole-unit parts of the wire fees seen, as a cheap way to
+ * tell which rows were reported.
+ */
+ uint64_t value_sum;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_wire_fees().
+ *
+ * @param cls a `struct FeeContext *`
+ * @param fees the fees of this period
+ * @param start_date start of the period
+ * @param end_date end of the period
+ * @param master_sig signature over the period
+ */
+static void
+fee_cb (void *cls,
+ const struct TALER_WireFeeSet *fees,
+ struct GNUNET_TIME_Timestamp start_date,
+ struct GNUNET_TIME_Timestamp end_date,
+ const struct TALER_MasterSignatureP *master_sig)
+{
+ struct FeeContext *ctx = cls;
+
+ (void) start_date;
+ (void) end_date;
+ (void) master_sig;
+ ctx->total++;
+ ctx->value_sum += fees->wire.value;
+}
+
+
+/**
+ * Insert a fee period.
+ *
+ * @param pg the database context
+ * @param from start of the period
+ * @param until end of the period
+ * @param wire wire fee, e.g. "1"
+ * @param closing closing fee, e.g. "2"
+ * @param seed seed for the master signature
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+add_fee (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ uint64_t from,
+ uint64_t until,
+ const char *wire,
+ const char *closing,
+ uint32_t seed)
+{
+ struct TALER_WireFeeSet fees;
+ struct TALER_MasterSignatureP master_sig;
+
+ fees.wire = TDB_amount (wire);
+ fees.closing = TDB_amount (closing);
+ TDB_FILL (master_sig,
+ seed);
+ return TALER_EXCHANGEDB_insert_wire_fee (pg,
+ METHOD,
+ ts (from),
+ ts (until),
+ &fees,
+ &master_sig);
+}
+
+
+/**
+ * No fees means no lookups succeed and the iterator sees nothing.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct GNUNET_TIME_Timestamp start_date;
+ struct GNUNET_TIME_Timestamp end_date;
+ struct TALER_WireFeeSet fees;
+ struct TALER_MasterSignatureP master_sig;
+ struct FeeContext ctx = { 0 };
+ uint64_t rowid;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_wire_fee (pg,
+ METHOD,
+ ts (T0),
+ &rowid,
+ &start_date,
+ &end_date,
+ &fees,
+ &master_sig));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_wire_fee_by_time (pg,
+ METHOD,
+ ts (T0),
+ ts (T0 + HOUR),
+ &fees));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_wire_fees (pg,
+ METHOD,
+ &fee_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * A single fee period is found inside its interval and nowhere else.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_single_period (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct GNUNET_TIME_Timestamp start_date;
+ struct GNUNET_TIME_Timestamp end_date;
+ struct TALER_WireFeeSet fees;
+ struct TALER_Amount expect_wire = TDB_amount ("1");
+ struct TALER_Amount expect_closing = TDB_amount ("2");
+ struct TALER_MasterSignatureP master_sig;
+ struct TALER_MasterSignatureP expect_sig;
+ uint64_t rowid = 0;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_fee (pg,
+ T0,
+ T0 + HOUR,
+ "1",
+ "2",
+ 10));
+ TDB_FILL (expect_sig,
+ 10);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_wire_fee (pg,
+ METHOD,
+ ts (T0),
+ &rowid,
+ &start_date,
+ &end_date,
+ &fees,
+ &master_sig));
+ FAILIF (0 == rowid);
+ FAILIF (GNUNET_TIME_timestamp_cmp (start_date,
+ !=,
+ ts (T0)));
+ FAILIF (GNUNET_TIME_timestamp_cmp (end_date,
+ !=,
+ ts (T0 + HOUR)));
+ FAILIF (0 != TALER_amount_cmp (&fees.wire,
+ &expect_wire));
+ FAILIF (0 != TALER_amount_cmp (&fees.closing,
+ &expect_closing));
+ FAILIF (0 != GNUNET_memcmp (&master_sig,
+ &expect_sig));
+
+ /* the interval is half-open: the end is not covered... */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_wire_fee (pg,
+ METHOD,
+ ts (T0 + HOUR),
+ &rowid,
+ &start_date,
+ &end_date,
+ &fees,
+ &master_sig));
+ /* ...and neither is anything before the start */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_wire_fee (pg,
+ METHOD,
+ ts (T0 - 1),
+ &rowid,
+ &start_date,
+ &end_date,
+ &fees,
+ &master_sig));
+ /* another wire method has no fees at all */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_wire_fee (pg,
+ "iban",
+ ts (T0),
+ &rowid,
+ &start_date,
+ &end_date,
+ &fees,
+ &master_sig));
+ return 0;
+}
+
+
+/**
+ * Over a range with one fee, get_wire_fee_by_time() returns it; over a
+ * range spanning two different fees it returns an invalid amount.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_by_time (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_WireFeeSet fees;
+ struct TALER_Amount expect_wire = TDB_amount ("1");
+ struct FeeContext ctx;
+
+ /* a second period with the *same* fees, and a third with different ones */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_fee (pg,
+ T0 + HOUR,
+ T0 + 2 * HOUR,
+ "1",
+ "2",
+ 11));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_fee (pg,
+ T0 + 2 * HOUR,
+ T0 + 3 * HOUR,
+ "5",
+ "6",
+ 12));
+
+ /* one period: the fee itself */
+ memset (&fees,
+ 0,
+ sizeof (fees));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_wire_fee_by_time (pg,
+ METHOD,
+ ts (T0),
+ ts (T0 + HOUR),
+ &fees));
+ FAILIF (0 != TALER_amount_cmp (&fees.wire,
+ &expect_wire));
+
+ /* two periods that agree: still the fee. The status is the number of
+ rows the range covered, so it is 2 rather than ONE_RESULT. */
+ memset (&fees,
+ 0,
+ sizeof (fees));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_get_wire_fee_by_time (pg,
+ METHOD,
+ ts (T0),
+ ts (T0 + 2 * HOUR),
+ &fees));
+ FAILIF (0 != TALER_amount_cmp (&fees.wire,
+ &expect_wire));
+
+ /* three periods that disagree: success, but an invalid amount */
+ memset (&fees,
+ 0,
+ sizeof (fees));
+ FAILIF (0 >
+ TALER_EXCHANGEDB_get_wire_fee_by_time (pg,
+ METHOD,
+ ts (T0),
+ ts (T0 + 3 * HOUR),
+ &fees));
+ FAILIF (GNUNET_OK ==
+ TALER_amount_is_valid (&fees.wire));
+ FAILIF (GNUNET_OK ==
+ TALER_amount_is_valid (&fees.closing));
+
+ /* a range outside every period finds nothing */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_wire_fee_by_time (pg,
+ METHOD,
+ ts (T0 + 10 * HOUR),
+ ts (T0 + 11 * HOUR),
+ &fees));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_wire_fees (pg,
+ METHOD,
+ &fee_cb,
+ &ctx));
+ FAILIF (3 != ctx.total);
+ FAILIF (1 + 1 + 5 != ctx.value_sum);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_wire_fees (pg,
+ "iban",
+ &fee_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "wire-fee-empty",
+ &check_empty },
+ { "wire-fee-single-period",
+ &check_single_period },
+ { "wire-fee-by-time",
+ &check_by_time },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-wire-fee",
+ "Tests for the exchangedb `wire_fee' table",
+ tests);
+}
+
+
+/* end of test_wire_fee.c */
diff --git a/src/exchangedb/test_wire_out.c b/src/exchangedb/test_wire_out.c
@@ -0,0 +1,632 @@
+/*
+ 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/test_wire_out.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `wire_out`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_insert_wire_out(),
+ * #TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id(),
+ * #TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id_by_account(),
+ * #TALER_EXCHANGEDB_iterate_exchange_debit_transfers() and
+ * #TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check().
+ *
+ * `wire_out` records the transfers the exchange actually made. It
+ * references `wire_targets` for the receiver, which TDB_account() creates;
+ * the exchange's own account is stored as a plain string.
+ */
+#include "test_common.h"
+#include "exchange-database/insert_wire_out.h"
+#include "exchange-database/iterate_aggregation_amounts_for_kyc_check.h"
+#include "exchange-database/iterate_exchange_debit_transfers.h"
+#include "exchange-database/iterate_wire_outs_above_serial_id.h"
+#include "exchange-database/iterate_wire_outs_above_serial_id_by_account.h"
+
+
+/**
+ * Account the checks pay out to.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * A second account, to show the filters are per account.
+ */
+static struct TDB_Account other;
+
+
+/**
+ * Build a timestamp from a number of seconds since the epoch.
+ *
+ * @param secs seconds since the epoch
+ * @return the timestamp
+ */
+static struct GNUNET_TIME_Timestamp
+ts (uint64_t secs)
+{
+ struct GNUNET_TIME_Absolute abs = {
+ .abs_value_us = secs * 1000LLU * 1000LLU
+ };
+
+ return GNUNET_TIME_absolute_to_timestamp (abs);
+}
+
+
+/**
+ * Closure for #wire_out_cb().
+ */
+struct OutContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Wire transfer we are looking for, NULL to match nothing.
+ */
+ const struct TALER_WireTransferIdentifierRawP *wtid;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Amount reported for it.
+ */
+ struct TALER_Amount amount;
+
+ /**
+ * Receiver account reported for it, owned by this struct.
+ */
+ char *payto;
+};
+
+
+/**
+ * Callback for the `wire_out` iterators.
+ *
+ * @param cls a `struct OutContext *`
+ * @param rowid row of the transfer
+ * @param date when it was made
+ * @param wtid its wire transfer identifier
+ * @param payto_uri where the money went
+ * @param amount how much was transferred
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+wire_out_cb (void *cls,
+ uint64_t rowid,
+ struct GNUNET_TIME_Timestamp date,
+ const struct TALER_WireTransferIdentifierRawP *wtid,
+ const struct TALER_FullPayto payto_uri,
+ const struct TALER_Amount *amount)
+{
+ struct OutContext *ctx = cls;
+
+ (void) rowid;
+ (void) date;
+ ctx->total++;
+ if ( (NULL != ctx->wtid) &&
+ (0 == GNUNET_memcmp (wtid,
+ ctx->wtid)) )
+ {
+ ctx->matched++;
+ ctx->amount = *amount;
+ GNUNET_free (ctx->payto);
+ ctx->payto = GNUNET_strdup (payto_uri.full_payto);
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Closure for #transfer_cb().
+ */
+struct TransferContext
+{
+ /**
+ * How many transfers did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Row of the first transfer seen.
+ */
+ uint64_t first_row;
+
+ /**
+ * Sum of the whole-unit parts of the amounts seen.
+ */
+ uint64_t value_sum;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_exchange_debit_transfers().
+ *
+ * @param cls a `struct TransferContext *`
+ * @param row_id row of the transfer
+ * @param payto_uri where the money went
+ * @param execution_time when it was made
+ * @param amount how much was transferred
+ */
+static void
+transfer_cb (void *cls,
+ uint64_t row_id,
+ const char *payto_uri,
+ struct GNUNET_TIME_Absolute execution_time,
+ const struct TALER_Amount *amount)
+{
+ struct TransferContext *ctx = cls;
+
+ (void) payto_uri;
+ (void) execution_time;
+ if (0 == ctx->total++)
+ ctx->first_row = row_id;
+ ctx->value_sum += amount->value;
+}
+
+
+/**
+ * Closure for #amount_cb().
+ */
+struct AmountContext
+{
+ /**
+ * How many amounts did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Sum of the whole-unit parts of the amounts seen.
+ */
+ uint64_t value_sum;
+
+ /**
+ * Return this from the callback.
+ */
+ enum GNUNET_GenericReturnValue ret;
+};
+
+
+/**
+ * Callback for
+ * #TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check().
+ *
+ * @param cls a `struct AmountContext *`
+ * @param amount the transferred amount
+ * @param date when it was transferred
+ * @return what @e ret of the closure says
+ */
+static enum GNUNET_GenericReturnValue
+amount_cb (void *cls,
+ const struct TALER_Amount *amount,
+ struct GNUNET_TIME_Absolute date)
+{
+ struct AmountContext *ctx = cls;
+
+ (void) date;
+ ctx->total++;
+ ctx->value_sum += amount->value;
+ return ctx->ret;
+}
+
+
+/**
+ * Record an executed wire transfer.
+ *
+ * @param pg the database context
+ * @param acc account the money went to
+ * @param seed seed for the wire transfer identifier
+ * @param when when the transfer was made, in seconds since the epoch
+ * @param amount how much was transferred, e.g. "10"
+ * @param metadata extra wire subject metadata, NULL for none
+ * @param[out] wtid set to the wire transfer identifier used
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+add_wire_out (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TDB_Account *acc,
+ uint32_t seed,
+ uint64_t when,
+ const char *amount,
+ const char *metadata,
+ struct TALER_WireTransferIdentifierRawP *wtid)
+{
+ struct TALER_FullPayto exchange_payto = {
+ .full_payto = (char *) "payto://x-taler-bank/localhost/exchange"
+ };
+ struct TALER_Amount a = TDB_amount (amount);
+
+ TDB_fill (wtid,
+ sizeof (*wtid),
+ seed);
+ return TALER_EXCHANGEDB_insert_wire_out (pg,
+ ts (when),
+ wtid,
+ &acc->h_full,
+ "exchange-account-1",
+ exchange_payto,
+ &a,
+ metadata);
+}
+
+
+/**
+ * Nothing is reported while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct OutContext ctx = { 0 };
+ struct TransferContext tctx = { 0 };
+ struct AmountContext actx = { 0 };
+ struct TALER_Amount zero = TDB_amount ("0");
+
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_account (pg,
+ 11,
+ &other);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id (pg,
+ 0,
+ &wire_out_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id_by_account (
+ pg,
+ "exchange-account-1",
+ 0,
+ &wire_out_cb,
+ &ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_exchange_debit_transfers (pg,
+ &zero,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &tctx));
+ actx.ret = GNUNET_OK;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check (
+ pg,
+ &account.h_normalized,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &actx));
+ FAILIF (0 != ctx.total);
+ FAILIF (0 != tctx.total);
+ FAILIF (0 != actx.total);
+ return 0;
+}
+
+
+/**
+ * A recorded transfer comes back with what it was recorded with.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insert (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_WireTransferIdentifierRawP wtid;
+ struct OutContext ctx;
+ struct TALER_Amount expect = TDB_amount ("10");
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_wire_out (pg,
+ &account,
+ 10,
+ 1600000000,
+ "10",
+ NULL,
+ &wtid));
+ FAILIF (1 != TDB_count (pg,
+ "FROM wire_out"));
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.wtid = &wtid;
+ FAILIF (0 >=
+ TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id (pg,
+ 0,
+ &wire_out_cb,
+ &ctx));
+ FAILIF_C (1 != ctx.matched,
+ GNUNET_free (ctx.payto));
+ FAILIF_C (0 != TALER_amount_cmp (&ctx.amount,
+ &expect),
+ GNUNET_free (ctx.payto));
+ FAILIF_C (0 != strcmp (ctx.payto,
+ account.payto.full_payto),
+ GNUNET_free (ctx.payto));
+ GNUNET_free (ctx.payto);
+ return 0;
+}
+
+
+/**
+ * The by-account iterator filters on the exchange's own bank account, and
+ * the serial bound and the abort return behave as documented.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_WireTransferIdentifierRawP w2;
+ struct TALER_WireTransferIdentifierRawP w3;
+ struct OutContext ctx;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_wire_out (pg,
+ &account,
+ 11,
+ 1600003600,
+ "2",
+ "invoice-42",
+ &w2));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ add_wire_out (pg,
+ &other,
+ 12,
+ 1600007200,
+ "5",
+ NULL,
+ &w3));
+ FAILIF (3 != TDB_count (pg,
+ "FROM wire_out"));
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id (pg,
+ 0,
+ &wire_out_cb,
+ &ctx));
+ GNUNET_free (ctx.payto);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id (pg,
+ 1000,
+ &wire_out_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* everything went out through the same exchange account */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id_by_account (
+ pg,
+ "exchange-account-1",
+ 0,
+ &wire_out_cb,
+ &ctx));
+ GNUNET_free (ctx.payto);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id_by_account (
+ pg,
+ "exchange-account-2",
+ 0,
+ &wire_out_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ /* a callback that gives up stops the iteration */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_wire_outs_above_serial_id (pg,
+ 0,
+ &wire_out_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+ GNUNET_free (ctx.payto);
+ return 0;
+}
+
+
+/**
+ * The AML views filter by amount and by receiving account.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_aml_views (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_Amount zero = TDB_amount ("0");
+ struct TALER_Amount five = TDB_amount ("5");
+ struct TransferContext ctx;
+ struct AmountContext actx;
+ uint64_t lowest;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_exchange_debit_transfers (pg,
+ &zero,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (10 + 2 + 5 != ctx.value_sum);
+ lowest = ctx.first_row;
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_exchange_debit_transfers (pg,
+ &five,
+ 0,
+ 10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (10 + 5 != ctx.value_sum);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_exchange_debit_transfers (pg,
+ &zero,
+ 0,
+ 10,
+ &account.
+ h_normalized,
+ &transfer_cb,
+ &ctx));
+ FAILIF (10 + 2 != ctx.value_sum);
+
+ /* the negative limit walks the table backwards */
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (3 !=
+ TALER_EXCHANGEDB_iterate_exchange_debit_transfers (pg,
+ &zero,
+ 1000,
+ -10,
+ NULL,
+ &transfer_cb,
+ &ctx));
+ FAILIF (lowest == ctx.first_row);
+
+ /* the KYC view groups by the credited account */
+ memset (&actx,
+ 0,
+ sizeof (actx));
+ actx.ret = GNUNET_OK;
+ FAILIF (2 !=
+ TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check (
+ pg,
+ &account.h_normalized,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &actx));
+ FAILIF (10 + 2 != actx.value_sum);
+
+ memset (&actx,
+ 0,
+ sizeof (actx));
+ actx.ret = GNUNET_OK;
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check (
+ pg,
+ &other.h_normalized,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &actx));
+ FAILIF (5 != actx.value_sum);
+
+ /* a limit in the future hides everything */
+ memset (&actx,
+ 0,
+ sizeof (actx));
+ actx.ret = GNUNET_OK;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check (
+ pg,
+ &account.h_normalized,
+ GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_HOURS),
+ &amount_cb,
+ &actx));
+ FAILIF (0 != actx.total);
+
+ /* and an aborting callback is not an error */
+ memset (&actx,
+ 0,
+ sizeof (actx));
+ actx.ret = GNUNET_NO;
+ FAILIF (0 >
+ TALER_EXCHANGEDB_iterate_aggregation_amounts_for_kyc_check (
+ pg,
+ &account.h_normalized,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &actx));
+ FAILIF (1 != actx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "wire-out-empty",
+ &check_empty },
+ { "wire-out-insert",
+ &check_insert },
+ { "wire-out-iterate",
+ &check_iterate },
+ { "wire-out-aml-views",
+ &check_aml_views },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-wire-out",
+ "Tests for the exchangedb `wire_out' table",
+ tests);
+ TDB_account_free (&account);
+ TDB_account_free (&other);
+ return ret;
+}
+
+
+/* end of test_wire_out.c */
diff --git a/src/exchangedb/test_withdraw.c b/src/exchangedb/test_withdraw.c
@@ -0,0 +1,825 @@
+/*
+ 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/test_withdraw.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `withdraw`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_do_withdraw(),
+ * #TALER_EXCHANGEDB_get_withdraw(),
+ * #TALER_EXCHANGEDB_get_reserve_by_h_planchets(),
+ * #TALER_EXCHANGEDB_iterate_withdrawals_above_serial_id() and
+ * #TALER_EXCHANGEDB_iterate_withdraw_amounts_for_kyc_check().
+ *
+ * `withdraw` references `reserves` and `denominations`. do_withdraw() has
+ * four distinct "did not do the work" answers -- unknown reserve,
+ * insufficient balance, age requirement not met and blinding-seed reuse --
+ * plus an idempotent replay, and each of them is checked.
+ */
+#include "test_common.h"
+#include "exchange-database/do_withdraw.h"
+#include "exchange-database/get_withdraw.h"
+#include "exchange-database/get_reserve.h"
+#include "exchange-database/get_reserve_by_h_planchets.h"
+#include "exchange-database/iterate_withdrawals_above_serial_id.h"
+#include "exchange-database/iterate_withdraw_amounts_for_kyc_check.h"
+
+
+/**
+ * Account the checks fund their reserves from.
+ */
+static struct TDB_Account account;
+
+
+/**
+ * Denomination the checks withdraw.
+ */
+static struct TDB_Denom denom;
+
+
+/**
+ * Fill in a withdraw request for one coin of #denom.
+ *
+ * @param seed seed for the planchet hash, signature and blinding seed
+ * @param amount amount to withdraw, e.g. "5"
+ * @param reserve_pub reserve to withdraw from
+ * @param with_seed true to pass a blinding seed
+ * @param[out] wd set to the request; release with free_withdraw()
+ */
+static void
+make_withdraw (uint32_t seed,
+ const char *amount,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ bool with_seed,
+ struct TALER_EXCHANGEDB_Withdraw *wd)
+{
+ memset (wd,
+ 0,
+ sizeof (*wd));
+ wd->amount_with_fee = TDB_amount (amount);
+ wd->age_proof_required = false;
+ wd->reserve_pub = *reserve_pub;
+ TDB_fill (&wd->planchets_h,
+ sizeof (wd->planchets_h),
+ seed);
+ TDB_fill (&wd->reserve_sig,
+ sizeof (wd->reserve_sig),
+ seed);
+ wd->num_coins = 1;
+ wd->denom_serials = GNUNET_new (uint64_t);
+ wd->denom_serials[0] = denom.serial;
+ wd->denom_sigs = GNUNET_new (struct TALER_BlindedDenominationSignature);
+ TDB_blinded_denom_sig (seed,
+ &wd->denom_sigs[0]);
+ wd->no_blinding_seed = ! with_seed;
+ if (with_seed)
+ TDB_fill (&wd->blinding_seed,
+ sizeof (wd->blinding_seed),
+ seed);
+}
+
+
+/**
+ * Release what make_withdraw() allocated.
+ *
+ * @param[in,out] wd request to clean up
+ */
+static void
+free_withdraw (struct TALER_EXCHANGEDB_Withdraw *wd)
+{
+ for (size_t i = 0; i<wd->num_coins; i++)
+ TALER_blinded_denom_sig_free (&wd->denom_sigs[i]);
+ GNUNET_free (wd->denom_sigs);
+ GNUNET_free (wd->denom_serials);
+}
+
+
+/**
+ * Run a withdraw request.
+ *
+ * @param pg the database context
+ * @param wd the request
+ * @param[out] st set to the outcome flags
+ * @return transaction status
+ */
+struct WithdrawStatus
+{
+ /**
+ * Was the balance sufficient?
+ */
+ bool balance_ok;
+
+ /**
+ * Were the age requirements met?
+ */
+ bool age_ok;
+
+ /**
+ * Was this a replay?
+ */
+ bool idempotent;
+
+ /**
+ * Was the blinding seed used before?
+ */
+ bool nonce_reuse;
+
+ /**
+ * Balance the reserve had.
+ */
+ struct TALER_Amount reserve_balance;
+
+ /**
+ * Maximum age the reserve allows.
+ */
+ uint16_t allowed_maximum_age;
+
+ /**
+ * Birthday recorded for the reserve.
+ */
+ uint32_t reserve_birthday;
+
+ /**
+ * Index the exchange chose not to reveal.
+ */
+ uint16_t noreveal_index;
+};
+
+
+/**
+ * Perform a withdraw request.
+ *
+ * @param pg the database context
+ * @param wd the request
+ * @param[out] st set to the outcome
+ * @return transaction status
+ */
+static enum GNUNET_DB_QueryStatus
+run_withdraw (struct TALER_EXCHANGEDB_PostgresContext *pg,
+ const struct TALER_EXCHANGEDB_Withdraw *wd,
+ struct WithdrawStatus *st)
+{
+ struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get ();
+
+ memset (st,
+ 0,
+ sizeof (*st));
+ return TALER_EXCHANGEDB_do_withdraw (pg,
+ wd,
+ &now,
+ &st->balance_ok,
+ &st->reserve_balance,
+ &st->age_ok,
+ &st->allowed_maximum_age,
+ &st->reserve_birthday,
+ &st->idempotent,
+ &st->noreveal_index,
+ &st->nonce_reuse);
+}
+
+
+/**
+ * Closure for #withdraw_cb().
+ */
+struct WithdrawContext
+{
+ /**
+ * How many rows did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Stop after this many rows; 0 for no limit.
+ */
+ unsigned int stop_after;
+
+ /**
+ * Planchet hash we are looking for, NULL to match nothing.
+ */
+ const struct TALER_HashBlindedPlanchetsP *planchets_h;
+
+ /**
+ * How many times did we see it?
+ */
+ unsigned int matched;
+
+ /**
+ * Amount reported for it.
+ */
+ struct TALER_Amount amount;
+
+ /**
+ * Denomination serials reported for it.
+ */
+ uint64_t denom_serial;
+
+ /**
+ * Number of denominations reported for it.
+ */
+ size_t num_denom_serials;
+
+ /**
+ * Was a blinding seed reported for it?
+ */
+ bool have_seed;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_withdrawals_above_serial_id().
+ *
+ * @param cls a `struct WithdrawContext *`
+ * @param rowid row of the withdraw
+ * @param num_denom_serials number of denominations withdrawn
+ * @param denom_serials the denominations withdrawn
+ * @param selected_h hash over the selected planchets
+ * @param h_planchets hash over all planchets
+ * @param blinding_seed blinding seed, NULL if none
+ * @param age_proof_required was an age proof required?
+ * @param max_age maximum age of the coins
+ * @param noreveal_index index the exchange did not reveal
+ * @param reserve_pub reserve that was drained
+ * @param reserve_sig signature over the request
+ * @param execution_date when the withdraw happened
+ * @param amount_with_fee how much was withdrawn
+ * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop
+ */
+static enum GNUNET_GenericReturnValue
+withdraw_cb (void *cls,
+ uint64_t rowid,
+ size_t num_denom_serials,
+ const uint64_t *denom_serials,
+ const struct TALER_HashBlindedPlanchetsP *selected_h,
+ const struct TALER_HashBlindedPlanchetsP *h_planchets,
+ const struct TALER_BlindingMasterSeedP *blinding_seed,
+ bool age_proof_required,
+ uint8_t max_age,
+ uint8_t noreveal_index,
+ const struct TALER_ReservePublicKeyP *reserve_pub,
+ const struct TALER_ReserveSignatureP *reserve_sig,
+ struct GNUNET_TIME_Timestamp execution_date,
+ const struct TALER_Amount *amount_with_fee)
+{
+ struct WithdrawContext *ctx = cls;
+
+ (void) rowid;
+ (void) selected_h;
+ (void) age_proof_required;
+ (void) max_age;
+ (void) noreveal_index;
+ (void) reserve_pub;
+ (void) reserve_sig;
+ (void) execution_date;
+ ctx->total++;
+ if ( (NULL != ctx->planchets_h) &&
+ (0 == GNUNET_memcmp (h_planchets,
+ ctx->planchets_h)) )
+ {
+ ctx->matched++;
+ ctx->amount = *amount_with_fee;
+ ctx->num_denom_serials = num_denom_serials;
+ if (0 < num_denom_serials)
+ ctx->denom_serial = denom_serials[0];
+ ctx->have_seed = (NULL != blinding_seed);
+ }
+ if ( (0 != ctx->stop_after) &&
+ (ctx->total >= ctx->stop_after) )
+ return GNUNET_SYSERR;
+ return GNUNET_OK;
+}
+
+
+/**
+ * Closure for #amount_cb().
+ */
+struct AmountContext
+{
+ /**
+ * How many amounts did the callback see?
+ */
+ unsigned int total;
+
+ /**
+ * Sum of the whole-unit parts of the amounts seen.
+ */
+ uint64_t value_sum;
+
+ /**
+ * Return this from the callback.
+ */
+ enum GNUNET_GenericReturnValue ret;
+};
+
+
+/**
+ * Callback for #TALER_EXCHANGEDB_iterate_withdraw_amounts_for_kyc_check().
+ *
+ * @param cls a `struct AmountContext *`
+ * @param amount the withdrawn amount
+ * @param date when it was withdrawn
+ * @return what @e ret of the closure says
+ */
+static enum GNUNET_GenericReturnValue
+amount_cb (void *cls,
+ const struct TALER_Amount *amount,
+ struct GNUNET_TIME_Absolute date)
+{
+ struct AmountContext *ctx = cls;
+
+ (void) date;
+ ctx->total++;
+ ctx->value_sum += amount->value;
+ return ctx->ret;
+}
+
+
+/**
+ * Withdrawing from a reserve that does not exist does nothing.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_unknown_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_EXCHANGEDB_Withdraw wd;
+ struct WithdrawStatus st;
+ struct TALER_HashBlindedPlanchetsP h;
+ uint64_t withdraw_serial_id;
+
+ TDB_denom (pg,
+ 10,
+ "5",
+ "0.1",
+ &denom);
+ TDB_FILL (reserve_pub,
+ 1);
+ make_withdraw (1,
+ "5",
+ &reserve_pub,
+ false,
+ &wd);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ run_withdraw (pg,
+ &wd,
+ &st),
+ free_withdraw (&wd));
+ free_withdraw (&wd);
+ FAILIF (0 != TDB_count (pg,
+ "FROM withdraw"));
+ TDB_FILL (h,
+ 1);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_reserve_by_h_planchets (pg,
+ &h,
+ &reserve_pub,
+ &withdraw_serial_id));
+ {
+ struct TALER_EXCHANGEDB_Withdraw got;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_get_withdraw (pg,
+ &h,
+ &got));
+ }
+ return 0;
+}
+
+
+/**
+ * A reserve without enough money keeps it.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_insufficient_balance (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_EXCHANGEDB_Withdraw wd;
+ struct WithdrawStatus st;
+ struct TALER_Amount expect = TDB_amount ("1");
+
+ TDB_account (pg,
+ 10,
+ &account);
+ TDB_reserve_in (pg,
+ &account,
+ 10,
+ "1",
+ &reserve_pub);
+ make_withdraw (2,
+ "5",
+ &reserve_pub,
+ false,
+ &wd);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_withdraw (pg,
+ &wd,
+ &st),
+ free_withdraw (&wd));
+ free_withdraw (&wd);
+ FAILIF (st.balance_ok);
+ FAILIF (! st.age_ok);
+ FAILIF (st.idempotent);
+ FAILIF (0 != TALER_amount_cmp (&st.reserve_balance,
+ &expect));
+ FAILIF (0 != TDB_count (pg,
+ "FROM withdraw"));
+ return 0;
+}
+
+
+/**
+ * A funded reserve is drained and the withdraw is recorded.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_withdraw (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_EXCHANGEDB_Withdraw wd;
+ struct TALER_EXCHANGEDB_Withdraw got;
+ struct TALER_EXCHANGEDB_Reserve reserve;
+ struct WithdrawStatus st;
+ struct TALER_Amount expect_balance = TDB_amount ("5");
+ struct TALER_Amount expect_left = TDB_amount ("5");
+ struct TALER_ReservePublicKeyP got_pub;
+ uint64_t withdraw_serial_id = 0;
+
+ TDB_reserve_in (pg,
+ &account,
+ 11,
+ "10",
+ &reserve_pub);
+ make_withdraw (11,
+ "5",
+ &reserve_pub,
+ true,
+ &wd);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_withdraw (pg,
+ &wd,
+ &st),
+ free_withdraw (&wd));
+ FAILIF_C (! st.balance_ok,
+ free_withdraw (&wd));
+ FAILIF_C (! st.age_ok,
+ free_withdraw (&wd));
+ FAILIF_C (st.idempotent,
+ free_withdraw (&wd));
+ FAILIF_C (st.nonce_reuse,
+ free_withdraw (&wd));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM withdraw"),
+ free_withdraw (&wd));
+
+ /* the reserve was debited */
+ memset (&reserve,
+ 0,
+ sizeof (reserve));
+ reserve.pub = reserve_pub;
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve (pg,
+ &reserve),
+ free_withdraw (&wd));
+ FAILIF_C (0 != TALER_amount_cmp (&reserve.balance,
+ &expect_left),
+ free_withdraw (&wd));
+
+ /* the request can be looked up by its planchet hash */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve_by_h_planchets (pg,
+ &wd.planchets_h,
+ &got_pub,
+ &withdraw_serial_id),
+ free_withdraw (&wd));
+ FAILIF_C (0 != GNUNET_memcmp (&got_pub,
+ &reserve_pub),
+ free_withdraw (&wd));
+ FAILIF_C (0 == withdraw_serial_id,
+ free_withdraw (&wd));
+
+ memset (&got,
+ 0,
+ sizeof (got));
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_withdraw (pg,
+ &wd.planchets_h,
+ &got),
+ free_withdraw (&wd));
+ FAILIF_C (0 != TALER_amount_cmp (&got.amount_with_fee,
+ &expect_balance),
+ free_withdraw (&got); free_withdraw (&wd));
+ FAILIF_C (0 != GNUNET_memcmp (&got.reserve_pub,
+ &reserve_pub),
+ free_withdraw (&got); free_withdraw (&wd));
+ FAILIF_C (0 != GNUNET_memcmp (&got.reserve_sig,
+ &wd.reserve_sig),
+ free_withdraw (&got); free_withdraw (&wd));
+ FAILIF_C (1 != got.num_coins,
+ free_withdraw (&got); free_withdraw (&wd));
+ FAILIF_C (denom.serial != got.denom_serials[0],
+ free_withdraw (&got); free_withdraw (&wd));
+ FAILIF_C (got.no_blinding_seed,
+ free_withdraw (&got); free_withdraw (&wd));
+ free_withdraw (&got);
+
+ /* a replay of the same request is idempotent and does not debit again */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_withdraw (pg,
+ &wd,
+ &st),
+ free_withdraw (&wd));
+ FAILIF_C (! st.idempotent,
+ free_withdraw (&wd));
+ FAILIF_C (1 != TDB_count (pg,
+ "FROM withdraw"),
+ free_withdraw (&wd));
+ memset (&reserve,
+ 0,
+ sizeof (reserve));
+ reserve.pub = reserve_pub;
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve (pg,
+ &reserve),
+ free_withdraw (&wd));
+ FAILIF_C (0 != TALER_amount_cmp (&reserve.balance,
+ &expect_left),
+ free_withdraw (&wd));
+ free_withdraw (&wd);
+ return 0;
+}
+
+
+/**
+ * Reusing a blinding seed for a different withdraw is refused, and the
+ * reserve is left debited -- the caller has to roll back.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_nonce_reuse (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_EXCHANGEDB_Withdraw wd;
+ struct WithdrawStatus st;
+
+ TDB_reserve_in (pg,
+ &account,
+ 12,
+ "10",
+ &reserve_pub);
+ /* a fresh planchet hash, but the blinding seed of check_withdraw() */
+ make_withdraw (13,
+ "5",
+ &reserve_pub,
+ true,
+ &wd);
+ TDB_fill (&wd.blinding_seed,
+ sizeof (wd.blinding_seed),
+ 11);
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_withdraw (pg,
+ &wd,
+ &st),
+ free_withdraw (&wd));
+ FAILIF_C (! st.nonce_reuse,
+ free_withdraw (&wd));
+ FAILIF_C (st.idempotent,
+ free_withdraw (&wd));
+ free_withdraw (&wd);
+ /* No withdraw row was written... */
+ FAILIF (1 != TDB_count (pg,
+ "FROM withdraw"));
+ /* ...but the reserve was debited before the seed was checked, so the
+ caller has to roll the transaction back. This is what the check is
+ really about: the function is not safe to call outside a transaction. */
+ {
+ struct TALER_EXCHANGEDB_Reserve reserve;
+ struct TALER_Amount debited = TDB_amount ("5");
+
+ memset (&reserve,
+ 0,
+ sizeof (reserve));
+ reserve.pub = reserve_pub;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_get_reserve (pg,
+ &reserve));
+ FAILIF (0 != TALER_amount_cmp (&reserve.balance,
+ &debited));
+ }
+ return 0;
+}
+
+
+/**
+ * A reserve with a birthday refuses a withdraw that commits to too high
+ * an age.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_age_restriction (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_EXCHANGEDB_Withdraw wd;
+ struct WithdrawStatus st;
+ char *hex;
+
+ TDB_reserve_in (pg,
+ &account,
+ 14,
+ "10",
+ &reserve_pub);
+ /* born 20000 days after the epoch, i.e. in 2024 */
+ hex = TDB_hex (&reserve_pub,
+ sizeof (reserve_pub));
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE reserves"
+ " SET birthday=20000"
+ " WHERE reserve_pub=decode('%s','hex');",
+ hex),
+ GNUNET_free (hex));
+ GNUNET_free (hex);
+
+ make_withdraw (14,
+ "5",
+ &reserve_pub,
+ false,
+ &wd);
+ /* no age commitment at all, from a reserve that has a birthday */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_withdraw (pg,
+ &wd,
+ &st),
+ free_withdraw (&wd));
+ free_withdraw (&wd);
+ FAILIF (st.age_ok);
+ FAILIF (20000 != st.reserve_birthday);
+ FAILIF (1 != TDB_count (pg,
+ "FROM withdraw"));
+ return 0;
+}
+
+
+/**
+ * The iterators see the withdraw, and the KYC view attributes it to the
+ * account that funded the reserve.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_HashBlindedPlanchetsP h;
+ struct WithdrawContext ctx;
+ struct AmountContext actx;
+
+ TDB_FILL (h,
+ 11);
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.planchets_h = &h;
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_withdrawals_above_serial_id (pg,
+ 0,
+ &withdraw_cb,
+ &ctx));
+ FAILIF (1 != ctx.matched);
+ FAILIF (1 != ctx.num_denom_serials);
+ FAILIF (denom.serial != ctx.denom_serial);
+ FAILIF (! ctx.have_seed);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_withdrawals_above_serial_id (pg,
+ 1000,
+ &withdraw_cb,
+ &ctx));
+ FAILIF (0 != ctx.total);
+
+ memset (&ctx,
+ 0,
+ sizeof (ctx));
+ ctx.stop_after = 1;
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_withdrawals_above_serial_id (pg,
+ 0,
+ &withdraw_cb,
+ &ctx));
+ FAILIF (1 != ctx.total);
+
+ /* the KYC view groups by the account that funded the reserve */
+ memset (&actx,
+ 0,
+ sizeof (actx));
+ actx.ret = GNUNET_OK;
+ FAILIF (1 !=
+ TALER_EXCHANGEDB_iterate_withdraw_amounts_for_kyc_check (
+ pg,
+ &account.h_normalized,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &actx));
+ FAILIF (5 != actx.value_sum);
+
+ /* an account nobody withdrew against has nothing */
+ {
+ struct TALER_NormalizedPaytoHashP other;
+
+ TDB_FILL (other,
+ 77);
+ memset (&actx,
+ 0,
+ sizeof (actx));
+ actx.ret = GNUNET_OK;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_withdraw_amounts_for_kyc_check (
+ pg,
+ &other,
+ GNUNET_TIME_UNIT_ZERO_ABS,
+ &amount_cb,
+ &actx));
+ FAILIF (0 != actx.total);
+ }
+
+ /* a time limit in the future hides everything */
+ memset (&actx,
+ 0,
+ sizeof (actx));
+ actx.ret = GNUNET_OK;
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_iterate_withdraw_amounts_for_kyc_check (
+ pg,
+ &account.h_normalized,
+ GNUNET_TIME_relative_to_absolute (GNUNET_TIME_UNIT_HOURS),
+ &amount_cb,
+ &actx));
+ FAILIF (0 != actx.total);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "withdraw-unknown-reserve",
+ &check_unknown_reserve },
+ { "withdraw-insufficient-balance",
+ &check_insufficient_balance },
+ { "withdraw-withdraw",
+ &check_withdraw },
+ { "withdraw-nonce-reuse",
+ &check_nonce_reuse },
+ { "withdraw-age-restriction",
+ &check_age_restriction },
+ { "withdraw-iterate",
+ &check_iterate },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ int ret;
+
+ ret = TDB_main (argc,
+ argv,
+ "test-withdraw",
+ "Tests for the exchangedb `withdraw' table",
+ tests);
+ TDB_account_free (&account);
+ TDB_denom_free (&denom);
+ return ret;
+}
+
+
+/* end of test_withdraw.c */
diff --git a/src/exchangedb/test_work_shards.c b/src/exchangedb/test_work_shards.c
@@ -0,0 +1,374 @@
+/*
+ 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/test_work_shards.c
+ * @brief tests for the exchangedb functions whose primary table is
+ * `work_shards`
+ * @author Christian Grothoff
+ *
+ * Covers #TALER_EXCHANGEDB_begin_shard(),
+ * #TALER_EXCHANGEDB_update_shard_progress(),
+ * #TALER_EXCHANGEDB_abort_shard() and, for its `work_shards` half,
+ * #TALER_EXCHANGEDB_delete_shard_locks().
+ *
+ * `work_shards` has no foreign keys. Every check uses a job name of its
+ * own so that the shard numbering of one check cannot confuse the next; the
+ * delete-locks check runs last because it empties the table.
+ */
+#include "test_common.h"
+#include "exchange-database/abort_shard.h"
+#include "exchange-database/begin_shard.h"
+#include "exchange-database/delete_shard_locks.h"
+#include "exchange-database/update_shard_progress.h"
+
+
+/**
+ * Lease we ask for; long enough that no check can lose its shard while it
+ * is running.
+ */
+#define LEASE GNUNET_TIME_UNIT_HOURS
+
+
+/**
+ * Nothing to abort and no progress to record while the table is empty.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_abort_shard (pg,
+ "ws-nothing",
+ 0,
+ 10));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_shard_progress (pg,
+ "ws-nothing",
+ 0,
+ 10,
+ 5,
+ LEASE));
+ FAILIF (0 != TDB_count (pg,
+ "FROM work_shards"));
+ return 0;
+}
+
+
+/**
+ * The first shard of a job starts at 0, and a job whose only shard is
+ * leased gets the next range rather than the leased one.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_begin (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ uint64_t start;
+ uint64_t end;
+ uint64_t progress;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_shard (pg,
+ "ws-begin",
+ LEASE,
+ 100,
+ &start,
+ &end,
+ &progress));
+ FAILIF (0 != start);
+ FAILIF (100 != end);
+ FAILIF (0 != progress);
+
+ /* the shard we just took is leased, so this must open the next one */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_shard (pg,
+ "ws-begin",
+ LEASE,
+ 100,
+ &start,
+ &end,
+ &progress));
+ FAILIF (100 != start);
+ FAILIF (200 != end);
+ FAILIF (100 != progress);
+ FAILIF (2 != TDB_count (pg,
+ "FROM work_shards"
+ " WHERE job_name='ws-begin'"));
+ /* a different job has its own numbering */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_shard (pg,
+ "ws-begin-other",
+ LEASE,
+ 7,
+ &start,
+ &end,
+ &progress));
+ FAILIF (0 != start);
+ FAILIF (7 != end);
+ return 0;
+}
+
+
+/**
+ * Progress is recorded, never rewound, and completes the shard exactly
+ * when it reaches the end of the range.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_progress (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ uint64_t start;
+ uint64_t end;
+ uint64_t progress;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_shard (pg,
+ "ws-progress",
+ LEASE,
+ 100,
+ &start,
+ &end,
+ &progress));
+ FAILIF (0 != start);
+
+ /* partial progress leaves the shard open */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_shard_progress (pg,
+ "ws-progress",
+ 0,
+ 100,
+ 50,
+ LEASE));
+ FAILIF (1 != TDB_count (pg,
+ "FROM work_shards"
+ " WHERE job_name='ws-progress'"
+ " AND progress_row=50"
+ " AND NOT completed"));
+
+ /* ...and cannot be pushed back by a straggler */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_shard_progress (pg,
+ "ws-progress",
+ 0,
+ 100,
+ 30,
+ LEASE));
+ FAILIF (1 != TDB_count (pg,
+ "FROM work_shards"
+ " WHERE job_name='ws-progress'"
+ " AND progress_row=50"));
+
+ /* reaching the end completes it */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_shard_progress (pg,
+ "ws-progress",
+ 0,
+ 100,
+ 100,
+ LEASE));
+ FAILIF (1 != TDB_count (pg,
+ "FROM work_shards"
+ " WHERE job_name='ws-progress'"
+ " AND completed"));
+
+ /* a shard range that does not exist is not silently created */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_shard_progress (pg,
+ "ws-progress",
+ 1000,
+ 1100,
+ 1050,
+ LEASE));
+ /* neither is one belonging to another job */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_update_shard_progress (pg,
+ "ws-progress-elsewhere",
+ 0,
+ 100,
+ 100,
+ LEASE));
+ FAILIF (1 != TDB_count (pg,
+ "FROM work_shards"
+ " WHERE job_name='ws-progress'"));
+ return 0;
+}
+
+
+/**
+ * An aborted shard is handed out again, together with the progress the
+ * previous worker had committed. A completed shard is not.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_abort_and_takeover (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ uint64_t start;
+ uint64_t end;
+ uint64_t progress;
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_shard (pg,
+ "ws-abort",
+ LEASE,
+ 10,
+ &start,
+ &end,
+ &progress));
+ FAILIF (0 != start);
+ FAILIF (10 != end);
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_shard_progress (pg,
+ "ws-abort",
+ 0,
+ 10,
+ 4,
+ LEASE));
+
+ /* aborting a range that does not exist changes nothing */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_abort_shard (pg,
+ "ws-abort",
+ 0,
+ 11));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
+ TALER_EXCHANGEDB_abort_shard (pg,
+ "ws-abort-elsewhere",
+ 0,
+ 10));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_abort_shard (pg,
+ "ws-abort",
+ 0,
+ 10));
+ /* the abandoned shard comes back, resuming where it was left off */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_shard (pg,
+ "ws-abort",
+ LEASE,
+ 10,
+ &start,
+ &end,
+ &progress));
+ FAILIF (0 != start);
+ FAILIF (10 != end);
+ FAILIF (4 != progress);
+ FAILIF (1 != TDB_count (pg,
+ "FROM work_shards"
+ " WHERE job_name='ws-abort'"));
+
+ /* once completed, aborting it still works but it is never handed out */
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_update_shard_progress (pg,
+ "ws-abort",
+ 0,
+ 10,
+ 10,
+ LEASE));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_abort_shard (pg,
+ "ws-abort",
+ 0,
+ 10));
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_shard (pg,
+ "ws-abort",
+ LEASE,
+ 10,
+ &start,
+ &end,
+ &progress));
+ FAILIF (10 != start);
+ FAILIF (20 != end);
+ return 0;
+}
+
+
+/**
+ * Deleting the shard locks empties the table and restarts the numbering.
+ * Runs last, as it discards what the other checks built.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_delete_locks (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ uint64_t start;
+ uint64_t end;
+ uint64_t progress;
+
+ FAILIF (0 == TDB_count (pg,
+ "FROM work_shards"));
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_delete_shard_locks (pg));
+ FAILIF (0 != TDB_count (pg,
+ "FROM work_shards"));
+ /* ...and on an already empty table it is a no-op, not an error */
+ FAILIF (GNUNET_OK !=
+ TALER_EXCHANGEDB_delete_shard_locks (pg));
+
+ FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ TALER_EXCHANGEDB_begin_shard (pg,
+ "ws-begin",
+ LEASE,
+ 100,
+ &start,
+ &end,
+ &progress));
+ FAILIF (0 != start);
+ return 0;
+}
+
+
+/**
+ * The checks to run, in order.
+ */
+static const struct TDB_Test tests[] = {
+ { "work-shards-empty",
+ &check_empty },
+ { "work-shards-begin",
+ &check_begin },
+ { "work-shards-progress",
+ &check_progress },
+ { "work-shards-abort-and-takeover",
+ &check_abort_and_takeover },
+ { "work-shards-delete-locks",
+ &check_delete_locks },
+ { NULL, NULL }
+};
+
+
+int
+main (int argc,
+ char *const *argv)
+{
+ return TDB_main (argc,
+ argv,
+ "test-work-shards",
+ "Tests for the exchangedb `work_shards' table",
+ tests);
+}
+
+
+/* end of test_work_shards.c */