test_purse_deposits.c (21401B)
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_purse_deposits.c 18 * @brief tests for the exchangedb functions whose primary table is 19 * `purse_deposits` 20 * @author Christian Grothoff 21 * 22 * Covers #TALER_EXCHANGEDB_do_purse_deposit(), 23 * #TALER_EXCHANGEDB_get_purse_deposit(), 24 * #TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id() and 25 * #TALER_EXCHANGEDB_iterate_purse_deposits_by_purse(). 26 * 27 * `purse_deposits` references `purse_requests` and `known_coins`, which 28 * TDB_purse() and TDB_coin() create. do_purse_deposit() declines in three 29 * ways: the coin has too little left, the purse has already been decided 30 * (too late), and the same coin was already paid into the purse with a 31 * different amount (conflict). 32 */ 33 #include "test_common.h" 34 #include "exchange-database/do_expire_purse.h" 35 #include "exchange-database/do_purse_deposit.h" 36 #include "exchange-database/get_purse_deposit.h" 37 #include "exchange-database/iterate_purse_deposits_above_serial_id.h" 38 #include "exchange-database/iterate_purse_deposits_by_purse.h" 39 #include "exchange-database/rollback.h" 40 #include "exchange-database/start.h" 41 42 43 /** 44 * Denomination the checks deposit. 45 */ 46 static struct TDB_Denom denom; 47 48 49 /** 50 * Build a timestamp from a number of seconds since the epoch. 51 * 52 * @param secs seconds since the epoch 53 * @return the timestamp 54 */ 55 static struct GNUNET_TIME_Timestamp 56 ts (uint64_t secs) 57 { 58 struct GNUNET_TIME_Absolute abs = { 59 .abs_value_us = secs * 1000LLU * 1000LLU 60 }; 61 62 return GNUNET_TIME_absolute_to_timestamp (abs); 63 } 64 65 66 /** 67 * Closure for #deposit_cb() and #refund_cb(). 68 */ 69 struct DepositContext 70 { 71 /** 72 * How many rows did the callback see? 73 */ 74 unsigned int total; 75 76 /** 77 * Stop after this many rows; 0 for no limit. 78 */ 79 unsigned int stop_after; 80 81 /** 82 * Coin we are looking for, NULL to match nothing. 83 */ 84 const struct TALER_CoinSpendPublicKeyP *coin_pub; 85 86 /** 87 * How many times did we see it? 88 */ 89 unsigned int matched; 90 91 /** 92 * Amount reported for it. 93 */ 94 struct TALER_Amount amount; 95 96 /** 97 * Was a reserve reported for its purse? 98 */ 99 bool have_reserve; 100 }; 101 102 103 /** 104 * Callback for 105 * #TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id(). 106 * 107 * @param cls a `struct DepositContext *` 108 * @param rowid row of the deposit 109 * @param deposit details of the deposit 110 * @param reserve_pub reserve the purse was merged into, NULL if none 111 * @param flags flags of the purse 112 * @param auditor_balance balance of the purse 113 * @param purse_total target amount of the purse 114 * @param denom_pub denomination of the coin 115 * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop 116 */ 117 static enum GNUNET_GenericReturnValue 118 deposit_cb (void *cls, 119 uint64_t rowid, 120 const struct TALER_EXCHANGEDB_PurseDeposit *deposit, 121 const struct TALER_ReservePublicKeyP *reserve_pub, 122 enum TALER_WalletAccountMergeFlags flags, 123 const struct TALER_Amount *auditor_balance, 124 const struct TALER_Amount *purse_total, 125 const struct TALER_DenominationPublicKey *denom_pub) 126 { 127 struct DepositContext *ctx = cls; 128 129 (void) rowid; 130 (void) flags; 131 (void) auditor_balance; 132 (void) purse_total; 133 (void) denom_pub; 134 ctx->total++; 135 if ( (NULL != ctx->coin_pub) && 136 (0 == GNUNET_memcmp (&deposit->coin_pub, 137 ctx->coin_pub)) ) 138 { 139 ctx->matched++; 140 ctx->amount = deposit->amount; 141 ctx->have_reserve = (NULL != reserve_pub); 142 } 143 if ( (0 != ctx->stop_after) && 144 (ctx->total >= ctx->stop_after) ) 145 return GNUNET_SYSERR; 146 return GNUNET_OK; 147 } 148 149 150 /** 151 * Callback for #TALER_EXCHANGEDB_iterate_purse_deposits_by_purse(). 152 * 153 * @param cls a `struct DepositContext *` 154 * @param rowid row of the deposit 155 * @param amount_with_fee how much was paid in 156 * @param coin_pub which coin paid 157 * @param denom_pub denomination of that coin 158 * @return #GNUNET_OK to continue, #GNUNET_SYSERR to stop 159 */ 160 static enum GNUNET_GenericReturnValue 161 refund_cb (void *cls, 162 uint64_t rowid, 163 const struct TALER_Amount *amount_with_fee, 164 const struct TALER_CoinSpendPublicKeyP *coin_pub, 165 const struct TALER_DenominationPublicKey *denom_pub) 166 { 167 struct DepositContext *ctx = cls; 168 169 (void) rowid; 170 (void) denom_pub; 171 ctx->total++; 172 if ( (NULL != ctx->coin_pub) && 173 (0 == GNUNET_memcmp (coin_pub, 174 ctx->coin_pub)) ) 175 { 176 ctx->matched++; 177 ctx->amount = *amount_with_fee; 178 } 179 if ( (0 != ctx->stop_after) && 180 (ctx->total >= ctx->stop_after) ) 181 return GNUNET_SYSERR; 182 return GNUNET_OK; 183 } 184 185 186 /** 187 * Outcome of a purse deposit. 188 */ 189 struct DepositStatus 190 { 191 /** 192 * Did the coin have enough left? 193 */ 194 bool balance_ok; 195 196 /** 197 * Was the purse already decided? 198 */ 199 bool too_late; 200 201 /** 202 * Was the coin already paid into this purse differently? 203 */ 204 bool conflict; 205 }; 206 207 208 /** 209 * Pay a coin into a purse. 210 * 211 * @param pg the database context 212 * @param purse purse to credit 213 * @param coin coin to debit 214 * @param seed seed for the coin signature 215 * @param amount how much to take from the coin, e.g. "1" 216 * @param fee purse deposit fee included in @a amount, e.g. "0" 217 * @param[out] st set to the outcome 218 * @return transaction status 219 */ 220 static enum GNUNET_DB_QueryStatus 221 run_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg, 222 const struct TDB_Purse *purse, 223 const struct TALER_CoinPublicInfo *coin, 224 uint32_t seed, 225 const char *amount, 226 const char *fee, 227 struct DepositStatus *st) 228 { 229 struct TALER_CoinSpendSignatureP coin_sig; 230 struct TALER_Amount a = TDB_amount (amount); 231 struct TALER_Amount f = TDB_amount (fee); 232 struct TALER_Amount net; 233 234 TDB_fill (&coin_sig, 235 sizeof (coin_sig), 236 seed); 237 GNUNET_assert (0 <= 238 TALER_amount_subtract (&net, 239 &a, 240 &f)); 241 memset (st, 242 0, 243 sizeof (*st)); 244 return TALER_EXCHANGEDB_do_purse_deposit (pg, 245 &purse->purse_pub, 246 &coin->coin_pub, 247 &a, 248 &coin_sig, 249 &net, 250 &st->balance_ok, 251 &st->too_late, 252 &st->conflict); 253 } 254 255 256 /** 257 * Nothing is reported while the table is empty. 258 * 259 * @param pg the database context 260 * @return 0 on success 261 */ 262 static int 263 check_empty (struct TALER_EXCHANGEDB_PostgresContext *pg) 264 { 265 struct TALER_PurseContractPublicKeyP purse_pub; 266 struct TALER_CoinSpendPublicKeyP coin_pub; 267 struct TALER_Amount amount; 268 struct TALER_DenominationHashP h_denom_pub; 269 struct TALER_AgeCommitmentHashP phac; 270 struct TALER_CoinSpendSignatureP coin_sig; 271 struct DepositContext ctx = { 0 }; 272 char *partner_url = NULL; 273 bool no_age_commitment; 274 275 TDB_denom (pg, 276 10, 277 "5", 278 "0.1", 279 &denom); 280 TDB_FILL (purse_pub, 281 1); 282 TDB_FILL (coin_pub, 283 1); 284 FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != 285 TALER_EXCHANGEDB_get_purse_deposit (pg, 286 &purse_pub, 287 &coin_pub, 288 &amount, 289 &h_denom_pub, 290 &phac, 291 &no_age_commitment, 292 &coin_sig, 293 &partner_url)); 294 FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != 295 TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id ( 296 pg, 297 0, 298 &deposit_cb, 299 &ctx)); 300 FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != 301 TALER_EXCHANGEDB_iterate_purse_deposits_by_purse (pg, 302 &purse_pub, 303 &refund_cb, 304 &ctx)); 305 FAILIF (0 != ctx.total); 306 return 0; 307 } 308 309 310 /** 311 * Paying a coin into a purse debits the coin and credits the purse. 312 * 313 * @param pg the database context 314 * @return 0 on success 315 */ 316 static int 317 check_deposit (struct TALER_EXCHANGEDB_PostgresContext *pg) 318 { 319 struct TDB_Purse purse; 320 struct TALER_CoinPublicInfo coin; 321 struct DepositStatus st; 322 struct TALER_Amount amount; 323 struct TALER_Amount expect = TDB_amount ("1"); 324 struct TALER_DenominationHashP h_denom_pub; 325 struct TALER_AgeCommitmentHashP phac; 326 struct TALER_CoinSpendSignatureP coin_sig; 327 struct TALER_CoinSpendSignatureP expect_sig; 328 char *partner_url = NULL; 329 bool no_age_commitment = false; 330 331 TDB_purse (pg, 332 10, 333 "5", 334 ts (1700000000), 335 &purse); 336 TDB_coin (pg, 337 &denom, 338 20, 339 &coin, 340 NULL); 341 FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != 342 run_deposit (pg, 343 &purse, 344 &coin, 345 20, 346 "1", 347 "0", 348 &st), 349 TDB_coin_free (&coin)); 350 FAILIF_C (! st.balance_ok, 351 TDB_coin_free (&coin)); 352 FAILIF_C (st.too_late, 353 TDB_coin_free (&coin)); 354 FAILIF_C (st.conflict, 355 TDB_coin_free (&coin)); 356 FAILIF_C (1 != TDB_count (pg, 357 "FROM purse_deposits"), 358 TDB_coin_free (&coin)); 359 360 /* the coin was debited... */ 361 { 362 char *hex = TDB_hex (&coin.coin_pub, 363 sizeof (coin.coin_pub)); 364 365 FAILIF_C (1 != TDB_count (pg, 366 "FROM known_coins" 367 " WHERE coin_pub=decode('%s','hex')" 368 " AND remaining=ROW(4,0)::taler_amount", 369 hex), 370 GNUNET_free (hex); TDB_coin_free (&coin)); 371 GNUNET_free (hex); 372 } 373 /* ...and the purse credited */ 374 { 375 char *hex = TDB_hex (&purse.purse_pub, 376 sizeof (purse.purse_pub)); 377 378 FAILIF_C (1 != TDB_count (pg, 379 "FROM purse_requests" 380 " WHERE purse_pub=decode('%s','hex')" 381 " AND balance=ROW(1,0)::taler_amount", 382 hex), 383 GNUNET_free (hex); TDB_coin_free (&coin)); 384 GNUNET_free (hex); 385 } 386 387 TDB_FILL (expect_sig, 388 20); 389 FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != 390 TALER_EXCHANGEDB_get_purse_deposit (pg, 391 &purse.purse_pub, 392 &coin.coin_pub, 393 &amount, 394 &h_denom_pub, 395 &phac, 396 &no_age_commitment, 397 &coin_sig, 398 &partner_url), 399 TDB_coin_free (&coin)); 400 FAILIF_C (0 != TALER_amount_cmp (&amount, 401 &expect), 402 GNUNET_free (partner_url); TDB_coin_free (&coin)); 403 FAILIF_C (0 != GNUNET_memcmp (&h_denom_pub, 404 &denom.h_denom_pub), 405 GNUNET_free (partner_url); TDB_coin_free (&coin)); 406 FAILIF_C (0 != GNUNET_memcmp (&coin_sig, 407 &expect_sig), 408 GNUNET_free (partner_url); TDB_coin_free (&coin)); 409 FAILIF_C (! no_age_commitment, 410 GNUNET_free (partner_url); TDB_coin_free (&coin)); 411 /* the purse is ours, so there is no partner exchange */ 412 FAILIF_C (NULL != partner_url, 413 GNUNET_free (partner_url); TDB_coin_free (&coin)); 414 415 /* re-depositing the same coin with the same amount is idempotent */ 416 FAILIF_C (0 > 417 run_deposit (pg, 418 &purse, 419 &coin, 420 20, 421 "1", 422 "0", 423 &st), 424 TDB_coin_free (&coin)); 425 FAILIF_C (1 != TDB_count (pg, 426 "FROM purse_deposits"), 427 TDB_coin_free (&coin)); 428 429 /* ...but a second attempt with a different coin signature is a conflict: 430 the idempotency check compares the signature, not the amount */ 431 FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != 432 run_deposit (pg, 433 &purse, 434 &coin, 435 29, 436 "1", 437 "0", 438 &st), 439 TDB_coin_free (&coin)); 440 FAILIF_C (! st.conflict, 441 TDB_coin_free (&coin)); 442 FAILIF_C (1 != TDB_count (pg, 443 "FROM purse_deposits"), 444 TDB_coin_free (&coin)); 445 TDB_coin_free (&coin); 446 return 0; 447 } 448 449 450 /** 451 * A coin without enough left cannot be paid into a purse. 452 * 453 * @param pg the database context 454 * @return 0 on success 455 */ 456 static int 457 check_insufficient_balance (struct TALER_EXCHANGEDB_PostgresContext *pg) 458 { 459 struct TDB_Purse purse; 460 struct TALER_CoinPublicInfo coin; 461 struct DepositStatus st; 462 463 TDB_purse (pg, 464 10, 465 "5", 466 ts (1700000000), 467 &purse); 468 TDB_coin (pg, 469 &denom, 470 21, 471 &coin, 472 NULL); 473 /* The `purse_deposits` row goes in before the coin balance is checked, 474 so the caller has to roll back -- which is what the /purses/.../deposit 475 handler does. */ 476 FAILIF_C (GNUNET_OK != 477 TALER_EXCHANGEDB_start (pg, 478 "test-purse-deposit-insufficient"), 479 TDB_coin_free (&coin)); 480 FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != 481 run_deposit (pg, 482 &purse, 483 &coin, 484 21, 485 "6", 486 "0", 487 &st), 488 TALER_EXCHANGEDB_rollback (pg); TDB_coin_free (&coin)); 489 TALER_EXCHANGEDB_rollback (pg); 490 TDB_coin_free (&coin); 491 FAILIF (st.balance_ok); 492 FAILIF (st.conflict); 493 FAILIF (1 != TDB_count (pg, 494 "FROM purse_deposits")); 495 return 0; 496 } 497 498 499 /** 500 * Once a merged purse has expired and been decided, the deposit that 501 * would complete it is refused as too late. 502 * 503 * Note what this shows about the shape of the function: the "too late" 504 * answer only comes out of the attempt to record the decision, which is 505 * only reached for a purse that has been merged *and* is completed by this 506 * deposit. A deposit into an expired purse that neither completes it nor 507 * has been merged is accepted -- see EDBT-9 in bugs.txt. 508 * 509 * @param pg the database context 510 * @return 0 on success 511 */ 512 static int 513 check_too_late (struct TALER_EXCHANGEDB_PostgresContext *pg) 514 { 515 struct TDB_Purse purse; 516 struct TALER_CoinPublicInfo coin; 517 struct TALER_ReservePublicKeyP reserve_pub; 518 struct DepositStatus st; 519 struct GNUNET_TIME_Absolute expiration; 520 521 /* a purse that expired in 2020, merged into a reserve */ 522 TDB_purse (pg, 523 11, 524 "5", 525 ts (1600000000), 526 &purse); 527 TDB_reserve (pg, 528 11, 529 "0", 530 &reserve_pub); 531 { 532 char *phex = TDB_hex (&purse.purse_pub, 533 sizeof (purse.purse_pub)); 534 char *rhex = TDB_hex (&reserve_pub, 535 sizeof (reserve_pub)); 536 537 FAILIF_C (GNUNET_OK != 538 TDB_exec (pg, 539 "INSERT INTO purse_merges" 540 " (reserve_pub,purse_pub,merge_sig,merge_timestamp)" 541 " VALUES (decode('%s','hex'),decode('%s','hex')" 542 " ,decode(repeat('11',64),'hex'),1000000);", 543 rhex, 544 phex), 545 GNUNET_free (phex); GNUNET_free (rhex)); 546 GNUNET_free (phex); 547 GNUNET_free (rhex); 548 } 549 FAILIF (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != 550 TALER_EXCHANGEDB_do_expire_purse (pg, 551 GNUNET_TIME_UNIT_ZERO_ABS, 552 GNUNET_TIME_absolute_get (), 553 &expiration)); 554 TDB_coin (pg, 555 &denom, 556 22, 557 &coin, 558 NULL); 559 /* Pay in the full target amount: the "too late" answer only comes out 560 when the purse would be decided by this deposit, which is when the 561 function tries to insert the `purse_decision` row that already exists. 562 As with the balance check, the row is written first, so roll back. */ 563 FAILIF_C (GNUNET_OK != 564 TALER_EXCHANGEDB_start (pg, 565 "test-purse-deposit-too-late"), 566 TDB_coin_free (&coin)); 567 FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != 568 run_deposit (pg, 569 &purse, 570 &coin, 571 22, 572 "5", 573 "0", 574 &st), 575 TALER_EXCHANGEDB_rollback (pg); TDB_coin_free (&coin)); 576 TALER_EXCHANGEDB_rollback (pg); 577 TDB_coin_free (&coin); 578 FAILIF (! st.too_late); 579 FAILIF (1 != TDB_count (pg, 580 "FROM purse_deposits")); 581 return 0; 582 } 583 584 585 /** 586 * The iterators see the deposit; the by-purse one only for its own purse. 587 * 588 * @param pg the database context 589 * @return 0 on success 590 */ 591 static int 592 check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg) 593 { 594 struct TALER_CoinSpendPublicKeyP coin_pub; 595 struct TALER_PurseContractPublicKeyP purse_pub; 596 struct DepositContext ctx; 597 struct TALER_Amount expect = TDB_amount ("1"); 598 599 TDB_FILL (coin_pub, 600 20); 601 TDB_FILL (purse_pub, 602 10); 603 memset (&ctx, 604 0, 605 sizeof (ctx)); 606 ctx.coin_pub = &coin_pub; 607 FAILIF (1 != 608 TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id ( 609 pg, 610 0, 611 &deposit_cb, 612 &ctx)); 613 FAILIF (1 != ctx.matched); 614 FAILIF (0 != TALER_amount_cmp (&ctx.amount, 615 &expect)); 616 /* the purse was never merged, so there is no reserve */ 617 FAILIF (ctx.have_reserve); 618 619 memset (&ctx, 620 0, 621 sizeof (ctx)); 622 FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != 623 TALER_EXCHANGEDB_iterate_purse_deposits_above_serial_id ( 624 pg, 625 1000, 626 &deposit_cb, 627 &ctx)); 628 FAILIF (0 != ctx.total); 629 630 memset (&ctx, 631 0, 632 sizeof (ctx)); 633 ctx.coin_pub = &coin_pub; 634 FAILIF (1 != 635 TALER_EXCHANGEDB_iterate_purse_deposits_by_purse (pg, 636 &purse_pub, 637 &refund_cb, 638 &ctx)); 639 FAILIF (1 != ctx.matched); 640 FAILIF (0 != TALER_amount_cmp (&ctx.amount, 641 &expect)); 642 643 /* a purse nobody paid into has no coins */ 644 { 645 struct TALER_PurseContractPublicKeyP other; 646 647 TDB_FILL (other, 648 11); 649 memset (&ctx, 650 0, 651 sizeof (ctx)); 652 FAILIF (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS != 653 TALER_EXCHANGEDB_iterate_purse_deposits_by_purse (pg, 654 &other, 655 &refund_cb, 656 &ctx)); 657 FAILIF (0 != ctx.total); 658 } 659 return 0; 660 } 661 662 663 /** 664 * The checks to run, in order. 665 */ 666 static const struct TDB_Test tests[] = { 667 { "purse-deposits-empty", 668 &check_empty }, 669 { "purse-deposits-deposit", 670 &check_deposit }, 671 { "purse-deposits-insufficient-balance", 672 &check_insufficient_balance }, 673 { "purse-deposits-too-late", 674 &check_too_late }, 675 { "purse-deposits-iterate", 676 &check_iterate }, 677 { NULL, NULL } 678 }; 679 680 681 int 682 main (int argc, 683 char *const *argv) 684 { 685 int ret; 686 687 ret = TDB_main (argc, 688 argv, 689 "test-purse-deposits", 690 "Tests for the exchangedb `purse_deposits' table", 691 tests); 692 TDB_denom_free (&denom); 693 return ret; 694 } 695 696 697 /* end of test_purse_deposits.c */