exchange

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

test_reserves.c (20782B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2026 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12 
     13   You should have received a copy of the GNU General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 /**
     17  * @file exchangedb/test_reserves.c
     18  * @brief tests for the exchangedb functions whose primary table is
     19  *        `reserves`
     20  * @author Christian Grothoff
     21  *
     22  * Covers #TALER_EXCHANGEDB_get_reserve(),
     23  * #TALER_EXCHANGEDB_update_reserve(),
     24  * #TALER_EXCHANGEDB_get_reserve_balance(),
     25  * #TALER_EXCHANGEDB_get_reserve_close_info(),
     26  * #TALER_EXCHANGEDB_iterate_expired_reserves() and
     27  * #TALER_EXCHANGEDB_gc().
     28  *
     29  * A reserve may or may not have a `reserves_in` row: one funded by a bank
     30  * transfer has one, one created by a peer-to-peer merge does not.  Several
     31  * of these functions LEFT JOIN through it to get the originating account,
     32  * so both shapes are exercised.  The garbage collection check runs last,
     33  * as it removes rows the earlier checks created.
     34  */
     35 #include "test_common.h"
     36 #include "exchange-database/gc.h"
     37 #include "exchange-database/get_reserve.h"
     38 #include "exchange-database/get_reserve_balance.h"
     39 #include "exchange-database/get_reserve_close_info.h"
     40 #include "exchange-database/iterate_expired_reserves.h"
     41 #include "exchange-database/update_reserve.h"
     42 
     43 
     44 /**
     45  * One second in microseconds.  Timestamps read back through
     46  * GNUNET_PQ_result_spec_timestamp() have to be whole seconds, so the
     47  * checks never write a raw microsecond value that is not a multiple of
     48  * this.
     49  */
     50 #define SECOND 1000000LLU
     51 
     52 
     53 /**
     54  * Create a reserve with an explicit expiration and garbage collection
     55  * date, which TDB_reserve() does not offer.
     56  *
     57  * @param pg the database context
     58  * @param seed seed for the reserve's public key
     59  * @param balance balance of the reserve, e.g. "10"
     60  * @param expiration_us expiration date, in microseconds since the epoch
     61  * @param gc_us garbage collection date, in microseconds since the epoch
     62  * @param[out] reserve_pub set to the public key of the reserve
     63  */
     64 static void
     65 make_reserve (struct TALER_EXCHANGEDB_PostgresContext *pg,
     66               uint32_t seed,
     67               const char *balance,
     68               uint64_t expiration_us,
     69               uint64_t gc_us,
     70               struct TALER_ReservePublicKeyP *reserve_pub)
     71 {
     72   struct TALER_Amount b = TDB_amount (balance);
     73   char *hex;
     74 
     75   TDB_fill (reserve_pub,
     76             sizeof (*reserve_pub),
     77             seed);
     78   hex = TDB_hex (reserve_pub,
     79                  sizeof (*reserve_pub));
     80   GNUNET_assert (GNUNET_OK ==
     81                  TDB_exec (pg,
     82                            "INSERT INTO reserves"
     83                            " (reserve_pub,current_balance"
     84                            " ,expiration_date,gc_date)"
     85                            " VALUES (decode('%s','hex')"
     86                            "        ,ROW(%llu,%u)::taler_amount"
     87                            "        ,%llu,%llu)"
     88                            " ON CONFLICT DO NOTHING;",
     89                            hex,
     90                            (unsigned long long) b.value,
     91                            (unsigned int) b.fraction,
     92                            (unsigned long long) expiration_us,
     93                            (unsigned long long) gc_us));
     94   GNUNET_free (hex);
     95 }
     96 
     97 
     98 /**
     99  * Closure for #expired_cb().
    100  */
    101 struct ExpiredContext
    102 {
    103   /**
    104    * How many reserves did the callback see?
    105    */
    106   unsigned int total;
    107 
    108   /**
    109    * Public key of the last reserve seen.
    110    */
    111   struct TALER_ReservePublicKeyP reserve_pub;
    112 
    113   /**
    114    * Balance of the last reserve seen.
    115    */
    116   struct TALER_Amount left;
    117 
    118   /**
    119    * Set if the last reserve was reported with an originating account.
    120    */
    121   bool have_account;
    122 
    123   /**
    124    * Close request row of the last reserve seen.
    125    */
    126   uint64_t close_request_row;
    127 };
    128 
    129 
    130 /**
    131  * Callback for #TALER_EXCHANGEDB_iterate_expired_reserves().
    132  *
    133  * @param cls a `struct ExpiredContext *`
    134  * @param reserve_pub the expired reserve
    135  * @param left balance left in the reserve
    136  * @param account_details originating account
    137  * @param expiration_date when the reserve expired
    138  * @param close_request_row row of the close request, 0 if none
    139  * @return #GNUNET_OK
    140  */
    141 static enum GNUNET_GenericReturnValue
    142 expired_cb (void *cls,
    143             const struct TALER_ReservePublicKeyP *reserve_pub,
    144             const struct TALER_Amount *left,
    145             const struct TALER_FullPayto account_details,
    146             struct GNUNET_TIME_Timestamp expiration_date,
    147             uint64_t close_request_row)
    148 {
    149   struct ExpiredContext *ctx = cls;
    150 
    151   (void) expiration_date;
    152   ctx->total++;
    153   ctx->reserve_pub = *reserve_pub;
    154   ctx->left = *left;
    155   ctx->have_account = (NULL != account_details.full_payto);
    156   ctx->close_request_row = close_request_row;
    157   return GNUNET_OK;
    158 }
    159 
    160 
    161 /**
    162  * Nothing is known about a reserve that was never created.
    163  *
    164  * @param pg the database context
    165  * @return 0 on success
    166  */
    167 static int
    168 check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg)
    169 {
    170   struct TALER_EXCHANGEDB_Reserve reserve;
    171   struct TALER_Amount balance;
    172   struct TALER_FullPayto payto = { NULL };
    173   struct ExpiredContext ctx = { 0 };
    174 
    175   memset (&reserve,
    176           0,
    177           sizeof (reserve));
    178   TDB_FILL (reserve.pub,
    179             1);
    180   reserve.balance = TDB_amount ("1");
    181   FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
    182           TALER_EXCHANGEDB_get_reserve (pg,
    183                                         &reserve));
    184   FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
    185           TALER_EXCHANGEDB_update_reserve (pg,
    186                                            &reserve));
    187   FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
    188           TALER_EXCHANGEDB_get_reserve_balance (pg,
    189                                                 &reserve.pub,
    190                                                 &balance,
    191                                                 &payto));
    192   FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
    193           TALER_EXCHANGEDB_get_reserve_close_info (pg,
    194                                                    &reserve.pub,
    195                                                    &balance,
    196                                                    &payto));
    197   FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
    198           TALER_EXCHANGEDB_iterate_expired_reserves (pg,
    199                                                      GNUNET_TIME_timestamp_get (
    200                                                        ),
    201                                                      &expired_cb,
    202                                                      &ctx));
    203   FAILIF (0 != ctx.total);
    204   FAILIF (0 != TDB_count (pg,
    205                           "FROM reserves"));
    206   return 0;
    207 }
    208 
    209 
    210 /**
    211  * A reserve without a `reserves_in` row is found, with no origin account.
    212  *
    213  * @param pg the database context
    214  * @return 0 on success
    215  */
    216 static int
    217 check_without_origin (struct TALER_EXCHANGEDB_PostgresContext *pg)
    218 {
    219   struct TALER_EXCHANGEDB_Reserve reserve;
    220   struct TALER_Amount balance;
    221   struct TALER_Amount expect = TDB_amount ("10");
    222   struct TALER_FullPayto payto = {
    223     .full_payto = (char *) "sentinel"
    224   };
    225 
    226   memset (&reserve,
    227           0,
    228           sizeof (reserve));
    229   TDB_reserve (pg,
    230                2,
    231                "10",
    232                &reserve.pub);
    233   FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
    234           TALER_EXCHANGEDB_get_reserve (pg,
    235                                         &reserve));
    236   FAILIF (0 != TALER_amount_cmp (&reserve.balance,
    237                                  &expect));
    238   FAILIF (GNUNET_TIME_absolute_is_zero (reserve.expiry.abs_time));
    239   FAILIF (GNUNET_TIME_absolute_is_zero (reserve.gc.abs_time));
    240 
    241   FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
    242           TALER_EXCHANGEDB_get_reserve_balance (pg,
    243                                                 &reserve.pub,
    244                                                 &balance,
    245                                                 &payto));
    246   FAILIF (0 != TALER_amount_cmp (&balance,
    247                                  &expect));
    248   FAILIF (NULL != payto.full_payto);
    249 
    250   payto.full_payto = (char *) "sentinel";
    251   FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
    252           TALER_EXCHANGEDB_get_reserve_close_info (pg,
    253                                                    &reserve.pub,
    254                                                    &balance,
    255                                                    &payto));
    256   FAILIF (0 != TALER_amount_cmp (&balance,
    257                                  &expect));
    258   FAILIF (NULL != payto.full_payto);
    259   return 0;
    260 }
    261 
    262 
    263 /**
    264  * A reserve funded by a bank transfer reports its origin account.
    265  *
    266  * @param pg the database context
    267  * @return 0 on success
    268  */
    269 static int
    270 check_with_origin (struct TALER_EXCHANGEDB_PostgresContext *pg)
    271 {
    272   struct TDB_Account account;
    273   struct TALER_ReservePublicKeyP reserve_pub;
    274   struct TALER_Amount balance;
    275   struct TALER_Amount expect = TDB_amount ("7");
    276   struct TALER_FullPayto payto = { NULL };
    277 
    278   TDB_account (pg,
    279                3,
    280                &account);
    281   TDB_reserve_in (pg,
    282                   &account,
    283                   3,
    284                   "7",
    285                   &reserve_pub);
    286   FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
    287             TALER_EXCHANGEDB_get_reserve_balance (pg,
    288                                                   &reserve_pub,
    289                                                   &balance,
    290                                                   &payto),
    291             TDB_account_free (&account));
    292   FAILIF_C (0 != TALER_amount_cmp (&balance,
    293                                    &expect),
    294             TDB_account_free (&account));
    295   FAILIF_C (NULL == payto.full_payto,
    296             TDB_account_free (&account));
    297   FAILIF_C (0 != strcmp (payto.full_payto,
    298                          account.payto.full_payto),
    299             GNUNET_free (payto.full_payto); TDB_account_free (&account));
    300   GNUNET_free (payto.full_payto);
    301 
    302   payto.full_payto = NULL;
    303   FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
    304             TALER_EXCHANGEDB_get_reserve_close_info (pg,
    305                                                      &reserve_pub,
    306                                                      &balance,
    307                                                      &payto),
    308             TDB_account_free (&account));
    309   FAILIF_C (NULL == payto.full_payto,
    310             TDB_account_free (&account));
    311   FAILIF_C (0 != strcmp (payto.full_payto,
    312                          account.payto.full_payto),
    313             GNUNET_free (payto.full_payto); TDB_account_free (&account));
    314   GNUNET_free (payto.full_payto);
    315   TDB_account_free (&account);
    316   return 0;
    317 }
    318 
    319 
    320 /**
    321  * Updating a reserve writes back the balance and both dates.
    322  *
    323  * @param pg the database context
    324  * @return 0 on success
    325  */
    326 static int
    327 check_update (struct TALER_EXCHANGEDB_PostgresContext *pg)
    328 {
    329   struct TALER_EXCHANGEDB_Reserve reserve;
    330   struct TALER_EXCHANGEDB_Reserve got;
    331   struct GNUNET_TIME_Absolute now = GNUNET_TIME_absolute_get ();
    332 
    333   memset (&reserve,
    334           0,
    335           sizeof (reserve));
    336   TDB_FILL (reserve.pub,
    337             2);
    338   reserve.balance = TDB_amount ("3.5");
    339   reserve.expiry = GNUNET_TIME_absolute_to_timestamp (now);
    340   reserve.gc = GNUNET_TIME_absolute_to_timestamp (
    341     GNUNET_TIME_absolute_add (now,
    342                               GNUNET_TIME_UNIT_HOURS));
    343   FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
    344           TALER_EXCHANGEDB_update_reserve (pg,
    345                                            &reserve));
    346   memset (&got,
    347           0,
    348           sizeof (got));
    349   got.pub = reserve.pub;
    350   FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
    351           TALER_EXCHANGEDB_get_reserve (pg,
    352                                         &got));
    353   FAILIF (0 != TALER_amount_cmp (&got.balance,
    354                                  &reserve.balance));
    355   FAILIF (GNUNET_TIME_timestamp_cmp (got.expiry,
    356                                      !=,
    357                                      reserve.expiry));
    358   FAILIF (GNUNET_TIME_timestamp_cmp (got.gc,
    359                                      !=,
    360                                      reserve.gc));
    361   return 0;
    362 }
    363 
    364 
    365 /**
    366  * Only an expired reserve that still holds money and has an originating
    367  * account is reported for closing.
    368  *
    369  * @param pg the database context
    370  * @return 0 on success
    371  */
    372 static int
    373 check_expired (struct TALER_EXCHANGEDB_PostgresContext *pg)
    374 {
    375   struct TDB_Account account;
    376   struct TALER_ReservePublicKeyP funded;
    377   struct TALER_ReservePublicKeyP drained;
    378   struct ExpiredContext ctx;
    379   struct GNUNET_TIME_Timestamp now = GNUNET_TIME_timestamp_get ();
    380   struct TALER_Amount expect = TDB_amount ("7");
    381   char *hex;
    382 
    383   /* nothing has expired yet: the reserves so far all expire in 2096 */
    384   memset (&ctx,
    385           0,
    386           sizeof (ctx));
    387   FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
    388           TALER_EXCHANGEDB_iterate_expired_reserves (pg,
    389                                                      now,
    390                                                      &expired_cb,
    391                                                      &ctx));
    392 
    393   /* expire the funded reserve from check_with_origin() */
    394   TDB_account (pg,
    395                3,
    396                &account);
    397   TDB_fill (&funded,
    398             sizeof (funded),
    399             3);
    400   hex = TDB_hex (&funded,
    401                  sizeof (funded));
    402   FAILIF_C (GNUNET_OK !=
    403             TDB_exec (pg,
    404                       "UPDATE reserves"
    405                       " SET expiration_date=1000000"
    406                       " WHERE reserve_pub=decode('%s','hex');",
    407                       hex),
    408             GNUNET_free (hex); TDB_account_free (&account));
    409   GNUNET_free (hex);
    410   memset (&ctx,
    411           0,
    412           sizeof (ctx));
    413   FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
    414             TALER_EXCHANGEDB_iterate_expired_reserves (pg,
    415                                                        now,
    416                                                        &expired_cb,
    417                                                        &ctx),
    418             TDB_account_free (&account));
    419   FAILIF_C (1 != ctx.total,
    420             TDB_account_free (&account));
    421   FAILIF_C (0 != GNUNET_memcmp (&ctx.reserve_pub,
    422                                 &funded),
    423             TDB_account_free (&account));
    424   FAILIF_C (0 != TALER_amount_cmp (&ctx.left,
    425                                    &expect),
    426             TDB_account_free (&account));
    427   FAILIF_C (! ctx.have_account,
    428             TDB_account_free (&account));
    429 
    430   /* an expired reserve with a zero balance is not worth closing */
    431   make_reserve (pg,
    432                 4,
    433                 "0",
    434                 SECOND,
    435                 SECOND,
    436                 &drained);
    437   {
    438     char *rhex = TDB_hex (&drained,
    439                           sizeof (drained));
    440     char *ahex = TDB_hex (&account.h_full,
    441                           sizeof (account.h_full));
    442 
    443     FAILIF_C (GNUNET_OK !=
    444               TDB_exec (pg,
    445                         "INSERT INTO reserves_in"
    446                         " (reserve_pub,wire_reference,credit"
    447                         " ,wire_source_h_payto"
    448                         " ,exchange_account_section,execution_date)"
    449                         " VALUES (decode('%s','hex'),44"
    450                         "        ,ROW(0,0)::taler_amount"
    451                         "        ,decode('%s','hex'),'exchange-account-1'"
    452                         "        ,1000000);",
    453                         rhex,
    454                         ahex),
    455               GNUNET_free (rhex); GNUNET_free (ahex);
    456               TDB_account_free (&account));
    457     GNUNET_free (rhex);
    458     GNUNET_free (ahex);
    459   }
    460   memset (&ctx,
    461           0,
    462           sizeof (ctx));
    463   FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
    464             TALER_EXCHANGEDB_iterate_expired_reserves (pg,
    465                                                        now,
    466                                                        &expired_cb,
    467                                                        &ctx),
    468             TDB_account_free (&account));
    469   /* the query returns at most one reserve, and it is still the funded one */
    470   FAILIF_C (1 != ctx.total,
    471             TDB_account_free (&account));
    472   FAILIF_C (0 != GNUNET_memcmp (&ctx.reserve_pub,
    473                                 &funded),
    474             TDB_account_free (&account));
    475   TDB_account_free (&account);
    476 
    477   /* an expired reserve with money but no originating account is skipped:
    478      it was created by a merge, so there is nowhere to wire the money back
    479      to.  Drain the funded one first so it cannot mask the result. */
    480   hex = TDB_hex (&funded,
    481                  sizeof (funded));
    482   FAILIF_C (GNUNET_OK !=
    483             TDB_exec (pg,
    484                       "UPDATE reserves"
    485                       " SET current_balance=ROW(0,0)::taler_amount"
    486                       " WHERE reserve_pub=decode('%s','hex');",
    487                       hex),
    488             GNUNET_free (hex));
    489   GNUNET_free (hex);
    490   {
    491     struct TALER_ReservePublicKeyP merged;
    492 
    493     make_reserve (pg,
    494                   5,
    495                   "4",
    496                   SECOND,
    497                   SECOND,
    498                   &merged);
    499   }
    500   memset (&ctx,
    501           0,
    502           sizeof (ctx));
    503   FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS !=
    504           TALER_EXCHANGEDB_iterate_expired_reserves (pg,
    505                                                      now,
    506                                                      &expired_cb,
    507                                                      &ctx));
    508   FAILIF (0 != ctx.total);
    509   return 0;
    510 }
    511 
    512 
    513 /**
    514  * Garbage collection removes drained, expired reserves and leaves the
    515  * others alone.  Runs last.
    516  *
    517  * @param pg the database context
    518  * @return 0 on success
    519  */
    520 static int
    521 check_gc (struct TALER_EXCHANGEDB_PostgresContext *pg)
    522 {
    523   struct TALER_ReservePublicKeyP old_empty;
    524   struct TALER_ReservePublicKeyP old_funded;
    525   struct TALER_ReservePublicKeyP young_empty;
    526   char *hex;
    527   uint64_t before;
    528 
    529   /* gc_date in the past and no money left: collectable */
    530   make_reserve (pg,
    531                 10,
    532                 "0",
    533                 SECOND,
    534                 SECOND,
    535                 &old_empty);
    536   /* gc_date in the past but still holding money: kept */
    537   make_reserve (pg,
    538                 11,
    539                 "2",
    540                 SECOND,
    541                 SECOND,
    542                 &old_funded);
    543   /* no money left but gc_date far in the future: kept */
    544   make_reserve (pg,
    545                 12,
    546                 "0",
    547                 4000000000000000LLU,
    548                 5000000000000000LLU,
    549                 &young_empty);
    550   before = TDB_count (pg,
    551                       "FROM reserves");
    552   FAILIF (GNUNET_OK !=
    553           TALER_EXCHANGEDB_gc (pg));
    554 
    555   hex = TDB_hex (&old_empty,
    556                  sizeof (old_empty));
    557   FAILIF_C (0 != TDB_count (pg,
    558                             "FROM reserves"
    559                             " WHERE reserve_pub=decode('%s','hex')",
    560                             hex),
    561             GNUNET_free (hex));
    562   GNUNET_free (hex);
    563   hex = TDB_hex (&old_funded,
    564                  sizeof (old_funded));
    565   FAILIF_C (1 != TDB_count (pg,
    566                             "FROM reserves"
    567                             " WHERE reserve_pub=decode('%s','hex')",
    568                             hex),
    569             GNUNET_free (hex));
    570   GNUNET_free (hex);
    571   hex = TDB_hex (&young_empty,
    572                  sizeof (young_empty));
    573   FAILIF_C (1 != TDB_count (pg,
    574                             "FROM reserves"
    575                             " WHERE reserve_pub=decode('%s','hex')",
    576                             hex),
    577             GNUNET_free (hex));
    578   GNUNET_free (hex);
    579   FAILIF (before <= TDB_count (pg,
    580                                "FROM reserves"));
    581   /* running it again on the same database changes nothing further */
    582   before = TDB_count (pg,
    583                       "FROM reserves");
    584   FAILIF (GNUNET_OK !=
    585           TALER_EXCHANGEDB_gc (pg));
    586   FAILIF (before != TDB_count (pg,
    587                                "FROM reserves"));
    588   return 0;
    589 }
    590 
    591 
    592 /**
    593  * The checks to run, in order.
    594  */
    595 static const struct TDB_Test tests[] = {
    596   { "reserves-empty",
    597     &check_empty },
    598   { "reserves-without-origin",
    599     &check_without_origin },
    600   { "reserves-with-origin",
    601     &check_with_origin },
    602   { "reserves-update",
    603     &check_update },
    604   { "reserves-expired",
    605     &check_expired },
    606   { "reserves-gc",
    607     &check_gc },
    608   { NULL, NULL }
    609 };
    610 
    611 
    612 int
    613 main (int argc,
    614       char *const *argv)
    615 {
    616   return TDB_main (argc,
    617                    argv,
    618                    "test-reserves",
    619                    "Tests for the exchangedb `reserves' table",
    620                    tests);
    621 }
    622 
    623 
    624 /* end of test_reserves.c */