commit 1be40676df72cef771c58b156f9e7bd7c244e004
parent ba515b48f858ab79867a08369ad2fc8aaf31e93a
Author: Özgür Kesim <oec@codeblau.de>
Date: Mon, 21 Sep 2026 16:09:51 +0200
exchangedb: report the latest recoup when a recoup request is replayed
A recouped coin can be credited again (a refund does that) and then be
recouped a second time for the same withdraw or refresh position, so the
recoup tables may hold several rows for one (coin, operation, index).
The idempotency lookup in exchange_do_recoup_to_reserve() and
exchange_do_recoup_to_coin() selected one of them without any order and
so reported the amount of an arbitrary, in practice the first, recoup.
Order by the row identity and take the latest.
The new checks in test_recoup and test_recoup_refresh recoup EUR:5,
credit EUR:2 back, recoup again and expect the replay to report EUR:2.
Diffstat:
4 files changed, 253 insertions(+), 2 deletions(-)
diff --git a/src/exchangedb/do_recoup.sql b/src/exchangedb/do_recoup.sql
@@ -73,6 +73,8 @@ THEN
-- Check for idempotency: only a recoup for this very
-- withdraw operation and position counts, a recoup of the same
-- coin for another operation means the coin has nothing left.
+ -- The coin may have been credited again (by a refund) and
+ -- recouped again since, so report the latest recoup.
SELECT
recoup_timestamp
,amount
@@ -81,7 +83,9 @@ THEN
FROM exchange.recoup
WHERE coin_pub=in_coin_pub
AND withdraw_id=in_withdraw_id
- AND coin_index=in_coin_index;
+ AND coin_index=in_coin_index
+ ORDER BY recoup_uuid DESC
+ LIMIT 1;
out_recoup_ok=FOUND;
IF FOUND
diff --git a/src/exchangedb/do_recoup_refresh.sql b/src/exchangedb/do_recoup_refresh.sql
@@ -65,6 +65,8 @@ THEN
-- Check for idempotency: only a recoup for this very
-- refresh operation and position counts, a recoup of the same
-- coin for another operation means the coin has nothing left.
+ -- The coin may have been credited again (by a refund) and
+ -- recouped again since, so report the latest recoup.
SELECT
recoup_timestamp
,amount
@@ -73,7 +75,9 @@ THEN
FROM recoup_refresh
WHERE coin_pub=in_coin_pub
AND refresh_id=in_refresh_id
- AND coin_index=in_coin_index;
+ AND coin_index=in_coin_index
+ ORDER BY recoup_refresh_uuid DESC
+ LIMIT 1;
out_recoup_ok=FOUND;
IF FOUND
THEN
diff --git a/src/exchangedb/test_recoup.c b/src/exchangedb/test_recoup.c
@@ -659,6 +659,130 @@ check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
/**
+ * A recouped coin that was credited again (a refund does that) can be
+ * recouped a second time. Replaying the request afterwards must report
+ * the latest recoup, not an arbitrary earlier one.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_replay_latest (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_ReservePublicKeyP reserve_pub;
+ struct TALER_CoinPublicInfo coin;
+ struct TALER_EXCHANGEDB_Reserve reserve;
+ struct RecoupStatus st;
+ struct TALER_Amount expect_first = TDB_amount ("5");
+ struct TALER_Amount expect_second = TDB_amount ("2");
+ struct TALER_Amount expect_reserve = TDB_amount ("12");
+ uint64_t known_coin_id;
+ uint64_t withdraw_id;
+ char *hex;
+
+ TDB_reserve_in (pg,
+ &account,
+ 15,
+ "10",
+ &reserve_pub);
+ withdraw_id = TDB_withdraw (pg,
+ &denom,
+ &reserve_pub,
+ 15,
+ "5");
+ TDB_coin (pg,
+ &denom,
+ 22,
+ &coin,
+ &known_coin_id);
+ hex = TDB_hex (&coin.coin_pub,
+ sizeof (coin.coin_pub));
+
+ /* first recoup drains the coin's EUR:5 */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &reserve_pub,
+ withdraw_id,
+ &coin.coin_pub,
+ known_coin_id,
+ 22,
+ &st),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ FAILIF_C ( (! st.recoup_ok) ||
+ (st.internal_failure) ||
+ (0 != TALER_amount_cmp (&st.recoup_amount,
+ &expect_first)),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+
+ /* the coin gets EUR:2 back, as a refund would do */
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE known_coins"
+ " SET remaining=ROW(2,0)::taler_amount"
+ " WHERE coin_pub=decode('%s','hex');",
+ hex),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+
+ /* second recoup for the same withdraw drains the EUR:2 */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &reserve_pub,
+ withdraw_id,
+ &coin.coin_pub,
+ known_coin_id,
+ 22,
+ &st),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ FAILIF_C ( (! st.recoup_ok) ||
+ (st.internal_failure) ||
+ (0 != TALER_amount_cmp (&st.recoup_amount,
+ &expect_second)),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ FAILIF_C (2 != TDB_count (pg,
+ "FROM recoup"
+ " WHERE coin_pub=decode('%s','hex')",
+ hex),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+
+ /* replaying the request reports the latest recoup... */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &reserve_pub,
+ withdraw_id,
+ &coin.coin_pub,
+ known_coin_id,
+ 22,
+ &st),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ FAILIF_C ( (! st.recoup_ok) ||
+ (st.internal_failure),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ FAILIF_C (0 != TALER_amount_cmp (&st.recoup_amount,
+ &expect_second),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ /* ...and does not touch the tables again */
+ FAILIF_C (2 != TDB_count (pg,
+ "FROM recoup"
+ " WHERE coin_pub=decode('%s','hex')",
+ hex),
+ GNUNET_free (hex); TDB_coin_free (&coin));
+ GNUNET_free (hex);
+ TDB_coin_free (&coin);
+ /* EUR:10 in, EUR:5 withdrawn, EUR:5 and EUR:2 recouped */
+ 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_reserve));
+ return 0;
+}
+
+
+/**
* The checks to run, in order.
*/
static const struct TDB_Test tests[] = {
@@ -672,6 +796,8 @@ static const struct TDB_Test tests[] = {
&check_iterate },
{ "recoup-replication",
&check_replication },
+ { "recoup-replay-latest",
+ &check_replay_latest },
{ NULL, NULL }
};
diff --git a/src/exchangedb/test_recoup_refresh.c b/src/exchangedb/test_recoup_refresh.c
@@ -655,6 +655,121 @@ check_iterate (struct TALER_EXCHANGEDB_PostgresContext *pg)
/**
+ * A recouped coin that was credited again (a refund does that) can be
+ * recouped a second time. Replaying the request afterwards must report
+ * the latest recoup, not an arbitrary earlier one.
+ *
+ * @param pg the database context
+ * @return 0 on success
+ */
+static int
+check_replay_latest (struct TALER_EXCHANGEDB_PostgresContext *pg)
+{
+ struct TALER_CoinPublicInfo old_coin;
+ struct TALER_CoinPublicInfo fresh;
+ struct RecoupStatus st;
+ struct TALER_Amount expect_first = TDB_amount ("5");
+ struct TALER_Amount expect_second = TDB_amount ("2");
+ uint64_t known_coin_id;
+ uint64_t refresh_id;
+ char *hex;
+ int ret = 1;
+
+ TDB_coin (pg,
+ &denom,
+ 27,
+ &old_coin,
+ NULL);
+ refresh_id = melt (pg,
+ &old_coin,
+ 7,
+ "1");
+ TDB_coin (pg,
+ &denom,
+ 28,
+ &fresh,
+ &known_coin_id);
+ hex = TDB_hex (&fresh.coin_pub,
+ sizeof (fresh.coin_pub));
+
+ /* first recoup drains the fresh coin's EUR:5 */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &old_coin.coin_pub,
+ refresh_id,
+ &fresh.coin_pub,
+ known_coin_id,
+ 28,
+ &st),
+ goto cleanup);
+ FAILIF_C ( (! st.recoup_ok) ||
+ (st.internal_failure) ||
+ (0 != TALER_amount_cmp (&st.recoup_amount,
+ &expect_first)),
+ goto cleanup);
+
+ /* the fresh coin gets EUR:2 back, as a refund would do */
+ FAILIF_C (GNUNET_OK !=
+ TDB_exec (pg,
+ "UPDATE known_coins"
+ " SET remaining=ROW(2,0)::taler_amount"
+ " WHERE coin_pub=decode('%s','hex');",
+ hex),
+ goto cleanup);
+
+ /* second recoup for the same refresh drains the EUR:2 */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &old_coin.coin_pub,
+ refresh_id,
+ &fresh.coin_pub,
+ known_coin_id,
+ 28,
+ &st),
+ goto cleanup);
+ FAILIF_C ( (! st.recoup_ok) ||
+ (st.internal_failure) ||
+ (0 != TALER_amount_cmp (&st.recoup_amount,
+ &expect_second)),
+ goto cleanup);
+ FAILIF_C (2 != TDB_count (pg,
+ "FROM recoup_refresh"
+ " WHERE coin_pub=decode('%s','hex')",
+ hex),
+ goto cleanup);
+
+ /* replaying the request reports the latest recoup... */
+ FAILIF_C (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT !=
+ run_recoup (pg,
+ &old_coin.coin_pub,
+ refresh_id,
+ &fresh.coin_pub,
+ known_coin_id,
+ 28,
+ &st),
+ goto cleanup);
+ FAILIF_C ( (! st.recoup_ok) ||
+ (st.internal_failure),
+ goto cleanup);
+ FAILIF_C (0 != TALER_amount_cmp (&st.recoup_amount,
+ &expect_second),
+ goto cleanup);
+ /* ...and does not touch the tables again */
+ FAILIF_C (2 != TDB_count (pg,
+ "FROM recoup_refresh"
+ " WHERE coin_pub=decode('%s','hex')",
+ hex),
+ goto cleanup);
+ ret = 0;
+cleanup:
+ GNUNET_free (hex);
+ TDB_coin_free (&fresh);
+ TDB_coin_free (&old_coin);
+ return ret;
+}
+
+
+/**
* The checks to run, in order.
*/
static const struct TDB_Test tests[] = {
@@ -668,6 +783,8 @@ static const struct TDB_Test tests[] = {
&check_iterate },
{ "recoup-refresh-replication",
&check_replication },
+ { "recoup-refresh-replay-latest",
+ &check_replay_latest },
{ NULL, NULL }
};