exchange

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

commit ad2405b87647754aed6a26532ca457fcaea0ed2a
parent 40767e3e2a0cb9f36570bac55e92886f3cbc1009
Author: Christian Grothoff <christian@grothoff.org>
Date:   Mon, 12 Oct 2026 17:29:31 +0200

do not accept deposits into purses that were already decided

Diffstat:
Msrc/exchange/taler-exchange-httpd_post-purses-PURSE_PUB-deposit.c | 22++++++++++++----------
Msrc/exchangedb/do_purse_deposit.sql | 13+++++++++++++
Msrc/exchangedb/test_purse_deposits.c | 110++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/include/exchange-database/do_purse_deposit.h | 2++
4 files changed, 125 insertions(+), 22 deletions(-)

diff --git a/src/exchange/taler-exchange-httpd_post-purses-PURSE_PUB-deposit.c b/src/exchange/taler-exchange-httpd_post-purses-PURSE_PUB-deposit.c @@ -214,16 +214,8 @@ deposit_transaction (void *cls, "do purse deposit"); return GNUNET_DB_STATUS_HARD_ERROR; } - if (! balance_ok) - { - *mhd_ret - = TEH_RESPONSE_reply_coin_insufficient_funds ( - connection, - TALER_EC_EXCHANGE_GENERIC_INSUFFICIENT_FUNDS, - &coin->cpi.denom_pub_hash, - &coin->cpi.coin_pub); - return GNUNET_DB_STATUS_HARD_ERROR; - } + /* check too_late first: a deleted or decided purse also + reports balance_ok=false */ if (too_late) { TALER_EXCHANGEDB_rollback (TEH_pg); @@ -234,6 +226,16 @@ deposit_transaction (void *cls, NULL); return GNUNET_DB_STATUS_HARD_ERROR; } + if (! balance_ok) + { + *mhd_ret + = TEH_RESPONSE_reply_coin_insufficient_funds ( + connection, + TALER_EC_EXCHANGE_GENERIC_INSUFFICIENT_FUNDS, + &coin->cpi.denom_pub_hash, + &coin->cpi.coin_pub); + return GNUNET_DB_STATUS_HARD_ERROR; + } if (conflict) { struct TALER_Amount amount; diff --git a/src/exchangedb/do_purse_deposit.sql b/src/exchangedb/do_purse_deposit.sql @@ -98,6 +98,19 @@ THEN RETURN; END IF; +-- Check if purse was already decided (merged and paid, or expired +-- and refunded), if so, abort and prevent deposit. +PERFORM + FROM exchange.purse_decision + WHERE purse_pub = in_purse_pub; +IF FOUND +THEN + out_late=TRUE; + out_balance_ok=FALSE; + out_conflict=FALSE; + RETURN; +END IF; + -- Debit the coin -- Check and update balance of the coin. diff --git a/src/exchangedb/test_purse_deposits.c b/src/exchangedb/test_purse_deposits.c @@ -497,14 +497,10 @@ check_insufficient_balance (struct TALER_EXCHANGEDB_PostgresContext *pg) /** - * 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. + * Once a purse has been decided, a new deposit into it is refused as too + * late, whether or not the purse was merged and whether or not the + * deposit would complete it. An idempotent replay of a deposit made + * before the decision still succeeds. * * @param pg the database context * @return 0 on success @@ -556,10 +552,8 @@ check_too_late (struct TALER_EXCHANGEDB_PostgresContext *pg) 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. */ + /* Pay in the full target amount. As with the balance check, the + `purse_deposits` row is written first, so roll back. */ FAILIF_C (GNUNET_OK != TALER_EXCHANGEDB_start (pg, "test-purse-deposit-too-late"), @@ -576,8 +570,100 @@ check_too_late (struct TALER_EXCHANGEDB_PostgresContext *pg) TALER_EXCHANGEDB_rollback (pg); TDB_coin_free (&coin); FAILIF (! st.too_late); + FAILIF (st.balance_ok); + FAILIF (st.conflict); FAILIF (1 != TDB_count (pg, "FROM purse_deposits")); + + /* A purse that was never merged and has been decided (refunded): + a deposit that would not complete it is refused as well, but a + replay of a deposit made before the decision is still idempotent. + All of this runs in one transaction that is rolled back, so the + later checks see the table unchanged. */ + { + struct TDB_Purse purse2; + struct TALER_CoinPublicInfo coin2; + struct TALER_CoinPublicInfo coin3; + char *phex; + + TDB_purse (pg, + 12, + "5", + ts (1600000000), + &purse2); + TDB_coin (pg, + &denom, + 23, + &coin2, + NULL); + TDB_coin (pg, + &denom, + 24, + &coin3, + NULL); + phex = TDB_hex (&purse2.purse_pub, + sizeof (purse2.purse_pub)); + FAILIF_C (GNUNET_OK != + TALER_EXCHANGEDB_start (pg, + "test-purse-deposit-decided"), + GNUNET_free (phex); + TDB_coin_free (&coin2); TDB_coin_free (&coin3)); + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_deposit (pg, + &purse2, + &coin2, + 23, + "1", + "0", + &st), + TALER_EXCHANGEDB_rollback (pg); GNUNET_free (phex); + TDB_coin_free (&coin2); TDB_coin_free (&coin3)); + FAILIF_C ( (! st.balance_ok) || st.too_late || st.conflict, + TALER_EXCHANGEDB_rollback (pg); GNUNET_free (phex); + TDB_coin_free (&coin2); TDB_coin_free (&coin3)); + FAILIF_C (GNUNET_OK != + TDB_exec (pg, + "INSERT INTO purse_decision" + " (purse_pub,action_timestamp,refunded)" + " VALUES (decode('%s','hex'),1000000,TRUE);", + phex), + TALER_EXCHANGEDB_rollback (pg); GNUNET_free (phex); + TDB_coin_free (&coin2); TDB_coin_free (&coin3)); + GNUNET_free (phex); + /* replay of the deposit made before the decision */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_deposit (pg, + &purse2, + &coin2, + 23, + "1", + "0", + &st), + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin2); TDB_coin_free (&coin3)); + FAILIF_C ( (! st.balance_ok) || st.too_late || st.conflict, + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin2); TDB_coin_free (&coin3)); + /* a new coin, not completing the purse */ + FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT != + run_deposit (pg, + &purse2, + &coin3, + 24, + "1", + "0", + &st), + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin2); TDB_coin_free (&coin3)); + TALER_EXCHANGEDB_rollback (pg); + TDB_coin_free (&coin2); + TDB_coin_free (&coin3); + FAILIF (! st.too_late); + FAILIF (st.balance_ok); + FAILIF (st.conflict); + FAILIF (1 != TDB_count (pg, + "FROM purse_deposits")); + } return 0; } diff --git a/src/include/exchange-database/do_purse_deposit.h b/src/include/exchange-database/do_purse_deposit.h @@ -47,6 +47,8 @@ * in this case, the return value will be * #GNUNET_DB_STATUS_SUCCESS_ONE_RESULT despite the failure * @param[out] too_late set to true if it is too late to deposit into the purse + * (the purse was deleted or already decided); in this case + * @a balance_ok is false as well, and the caller must roll back * @param[out] conflict set to true if the deposit failed due to a conflict (coin already spent, * or deposited into this purse with a different amount) * @return transaction status code