commit 3bca078e5fe5730303f4bb32ab1b886c912748c2
parent fcbbda7508f61edf6588aca86f5bd0a6a2bee24b
Author: Christian Grothoff <christian@grothoff.org>
Date: Wed, 5 Aug 2026 23:51:01 +0200
exchange settling to unknown account is not settled
Diffstat:
7 files changed, 264 insertions(+), 26 deletions(-)
diff --git a/src/backend/taler-merchant-depositcheck.c b/src/backend/taler-merchant-depositcheck.c
@@ -448,18 +448,37 @@ deposit_get_cb (
"Exchange returned wire transfer over %s for deposited coin %s\n",
TALER_amount2s (&dr->details.ok.coin_contribution),
TALER_B2S (&w->coin_pub));
- qs = TALER_MERCHANTDB_insert_deposit_to_transfer (
- pg,
- w->deposit_serial,
- &w->h_wire,
- exchange_url,
- &dr->details.ok);
- if (qs <= 0)
{
- GNUNET_break (0);
- global_ret = EXIT_FAILURE;
- GNUNET_SCHEDULER_shutdown ();
- return;
+ enum TALER_MERCHANTDB_DepositToTransferStatus dtts;
+
+ dtts = TALER_MERCHANTDB_insert_deposit_to_transfer (
+ pg,
+ w->deposit_serial,
+ &w->h_wire,
+ exchange_url,
+ &dr->details.ok);
+ switch (dtts)
+ {
+ case TALER_MERCHANTDB_DTTS_HARD_ERROR:
+ case TALER_MERCHANTDB_DTTS_SOFT_ERROR:
+ case TALER_MERCHANTDB_DTTS_NO_RESULTS:
+ GNUNET_break (0);
+ global_ret = EXIT_FAILURE;
+ GNUNET_SCHEDULER_shutdown ();
+ return;
+ case TALER_MERCHANTDB_DTTS_SETTLED:
+ break;
+ case TALER_MERCHANTDB_DTTS_SIGNKEY_UNKNOWN:
+ /* transient, the DB scheduled a retry for us */
+ break;
+ case TALER_MERCHANTDB_DTTS_ACCOUNT_UNKNOWN:
+ /* permanent failure, the operator has to look into this */
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Exchange `%s' claims to have wired coin %s to an account we do not know; deposit will not settle\n",
+ exchange_url,
+ TALER_B2S (&w->coin_pub));
+ break;
+ }
}
break;
case MHD_HTTP_ACCEPTED:
diff --git a/src/backenddb/insert_deposit_to_transfer.c b/src/backenddb/insert_deposit_to_transfer.c
@@ -24,7 +24,7 @@
#include "helper.h"
-enum GNUNET_DB_QueryStatus
+enum TALER_MERCHANTDB_DepositToTransferStatus
TALER_MERCHANTDB_insert_deposit_to_transfer (
struct TALER_MERCHANTDB_PostgresContext *pg,
uint64_t deposit_serial,
@@ -44,22 +44,58 @@ TALER_MERCHANTDB_insert_deposit_to_transfer (
GNUNET_PQ_query_param_auto_from_type (&dd->wtid),
GNUNET_PQ_query_param_end
};
- bool dummy;
+ bool no_signkey;
+ bool no_account;
struct GNUNET_PQ_ResultSpec rs[] = {
- GNUNET_PQ_result_spec_bool ("out_dummy",
- &dummy),
+ GNUNET_PQ_result_spec_bool ("out_no_signkey",
+ &no_signkey),
+ GNUNET_PQ_result_spec_bool ("out_no_account",
+ &no_account),
GNUNET_PQ_result_spec_end
};
+ enum GNUNET_DB_QueryStatus qs;
GNUNET_assert (NULL != pg->current_merchant_id);
TMH_PQ_prepare_anon (pg,
"SELECT"
- " out_dummy"
+ " out_no_signkey"
+ " ,out_no_account"
" FROM merchant_insert_deposit_to_transfer"
" ($1,$2,$3,$4,$5,$6,$7,$8);");
- return GNUNET_PQ_eval_prepared_singleton_select (
+ qs = GNUNET_PQ_eval_prepared_singleton_select (
pg->conn,
"",
params,
rs);
+ switch (qs)
+ {
+ case GNUNET_DB_STATUS_HARD_ERROR:
+ return TALER_MERCHANTDB_DTTS_HARD_ERROR;
+ case GNUNET_DB_STATUS_SOFT_ERROR:
+ return TALER_MERCHANTDB_DTTS_SOFT_ERROR;
+ case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
+ /* stored procedure always returns exactly one row */
+ GNUNET_break (0);
+ return TALER_MERCHANTDB_DTTS_NO_RESULTS;
+ case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
+ break;
+ }
+ if (no_signkey)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
+ "Exchange `%s' signed transfer for deposit %llu with an unknown key, will retry later\n",
+ exchange_url,
+ (unsigned long long) deposit_serial);
+ return TALER_MERCHANTDB_DTTS_SIGNKEY_UNKNOWN;
+ }
+ if (no_account)
+ {
+ GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
+ "Exchange `%s' wired deposit %llu to account `%s' which is unknown to us; deposit will never settle\n",
+ exchange_url,
+ (unsigned long long) deposit_serial,
+ TALER_B2S (h_wire));
+ return TALER_MERCHANTDB_DTTS_ACCOUNT_UNKNOWN;
+ }
+ return TALER_MERCHANTDB_DTTS_SETTLED;
}
diff --git a/src/backenddb/insert_deposit_to_transfer.sql b/src/backenddb/insert_deposit_to_transfer.sql
@@ -25,7 +25,11 @@ CREATE FUNCTION merchant_insert_deposit_to_transfer (
IN in_exchange_sig BYTEA,
IN in_exchange_pub BYTEA,
IN in_wtid BYTEA,
- OUT out_dummy BOOL)
+ -- Exchange signing key unknown: transient, we will try again.
+ OUT out_no_signkey BOOL,
+ -- Target account unknown: permanent failure, the money went
+ -- somewhere we do not recognize.
+ OUT out_no_account BOOL)
LANGUAGE plpgsql
AS $$
DECLARE
@@ -35,8 +39,8 @@ DECLARE
my_expected_credit_serial INT8;
my_wire_pending_cleared BOOL;
BEGIN
- -- Just to return something (for now).
- out_dummy=FALSE;
+ out_no_signkey=FALSE;
+ out_no_account=FALSE;
-- Find exchange sign key
SELECT signkey_serial
@@ -57,6 +61,7 @@ THEN
,settlement_retry_needed=TRUE
,settlement_retry_time=(EXTRACT(epoch FROM (CURRENT_TIMESTAMP + interval '8 hours')) * 1000000)::INT8
WHERE deposit_serial=in_deposit_serial;
+ out_no_signkey=TRUE;
RETURN;
END IF;
@@ -78,7 +83,10 @@ SELECT account_serial
IF NOT FOUND
THEN
-- Merchant account referenced in exchange response is unknown to us.
- -- Remember fatal error and do not try again.
+ -- Remember fatal error and do not try again. Note that
+ -- settlement_last_ec stays non-zero, which is what keeps this
+ -- deposit from counting as settled in
+ -- merchant_do_insert_transfer_details.
UPDATE merchant_deposits
SET settlement_last_ec=2558 -- MERCHANT_EXCHANGE_TRANSFERS_TARGET_ACCOUNT_UNKNOWN
,settlement_last_http_status=200
@@ -89,6 +97,7 @@ THEN
,signkey_serial=my_signkey_serial
,settlement_exchange_sig=in_exchange_sig
WHERE deposit_serial=in_deposit_serial;
+ out_no_account=TRUE;
RETURN;
END IF;
diff --git a/src/backenddb/insert_transfer_details.sql b/src/backenddb/insert_transfer_details.sql
@@ -189,10 +189,15 @@ LOOP
my_decose=my_affected_orders.deposit_confirmation_serial;
+ -- A deposit that failed permanently (say because the exchange
+ -- wired the money to an account we do not know, EC 2558) has
+ -- settlement_retry_needed=FALSE and a settlement_wtid, but is
+ -- NOT settled: it must not make the order count as wired.
PERFORM FROM merchant_deposits md
WHERE md.deposit_confirmation_serial=my_decose
AND (settlement_retry_needed
- OR (settlement_wtid IS NULL) );
+ OR (settlement_wtid IS NULL)
+ OR (COALESCE(settlement_last_ec,0) <> 0) );
IF NOT FOUND
THEN
-- must be all done, clear flag
diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c
@@ -6017,7 +6017,7 @@ test_insert_deposit_to_transfer (const struct InstanceData *instance,
order,
deposit);
- TEST_COND_RET_ON_FAIL (expected_result ==
+ TEST_COND_RET_ON_FAIL (TALER_MERCHANTDB_DTTS_SETTLED ==
TALER_MERCHANTDB_insert_deposit_to_transfer (
pg,
deposit_serial,
@@ -6030,6 +6030,56 @@ test_insert_deposit_to_transfer (const struct InstanceData *instance,
/**
+ * Tests linking a deposit to a transfer that the exchange claims to
+ * have wired to an account we do not know. This is a *permanent*
+ * failure: the caller must be able to tell it apart from a successful
+ * settlement, and the deposit must not make its order count as wired.
+ *
+ * @param instance the instance that the deposit and transfer are for.
+ * @param signkey the signing key used on the deposit.
+ * @param order the order the deposit was made for.
+ * @param deposit the deposit.
+ * @param transfer the transfer the exchange claims to have made.
+ * @return 0 on success, 1 otherwise.
+ */
+static int
+test_insert_deposit_to_unknown_account (
+ const struct InstanceData *instance,
+ const struct ExchangeSignkeyData *signkey,
+ const struct OrderData *order,
+ const struct DepositData *deposit,
+ const struct TransferData *transfer)
+{
+ const struct TALER_EXCHANGE_DepositData deposit_data = {
+ .exchange_pub = signkey->exchange_pub,
+ .exchange_sig = deposit->exchange_sig,
+ .wtid = transfer->wtid,
+ .execution_time = transfer->data.execution_time,
+ .coin_contribution = deposit->amount_with_fee
+ };
+ struct TALER_MerchantWireHashP bogus_h_wire;
+ uint64_t deposit_serial;
+
+ GNUNET_CRYPTO_random_block (&bogus_h_wire,
+ sizeof (bogus_h_wire));
+ TEST_SET_INSTANCE (instance->instance.id,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT);
+ deposit_serial = get_deposit_serial (instance,
+ order,
+ deposit);
+ TEST_COND_RET_ON_FAIL (TALER_MERCHANTDB_DTTS_ACCOUNT_UNKNOWN ==
+ TALER_MERCHANTDB_insert_deposit_to_transfer (
+ pg,
+ deposit_serial,
+ &bogus_h_wire,
+ deposit->exchange_url,
+ &deposit_data),
+ "insert deposit to transfer with an unknown target account must report ACCOUNT_UNKNOWN\n");
+ return 0;
+}
+
+
+/**
* Inserts details for a transfer into the database.
*
* @param instance the instance the transfer is in.
@@ -6132,6 +6182,12 @@ struct TestTransfers_Closure
struct OrderData orders_agg[2];
/**
+ * Order with one deposit that settles and one that fails
+ * permanently.
+ */
+ struct OrderData order_perm;
+
+ /**
* The deposit data.
*/
struct DepositData deposit;
@@ -6148,6 +6204,12 @@ struct TestTransfers_Closure
struct DepositData deposits_agg[2];
/**
+ * Two deposits for @e order_perm: the first one settles, the
+ * second one is wired to an account we do not know.
+ */
+ struct DepositData deposits_perm[2];
+
+ /**
* Wire fee data.
*/
struct WireFeeData wire_fee[2];
@@ -6155,7 +6217,7 @@ struct TestTransfers_Closure
/**
* The transfers.
*/
- struct TransferData transfers[4];
+ struct TransferData transfers[5];
};
@@ -6243,6 +6305,21 @@ pre_test_transfers (struct TestTransfers_Closure *cls)
cls->deposits_agg,
&cls->transfers[3]);
cls->transfers[3].confirmed = true;
+ /* An order with two deposits, one of which the exchange wires to an
+ account we do not know (permanent failure). */
+ make_order ("test_transfers_od_perm",
+ &cls->order_perm);
+ for (unsigned int i = 0; i < 2; i++)
+ make_deposit (&cls->instance,
+ &cls->account,
+ &cls->order_perm,
+ &cls->signkey,
+ &cls->deposits_perm[i]);
+ make_transfer (&cls->signkey,
+ 1,
+ cls->deposits_perm,
+ &cls->transfers[4]);
+ cls->transfers[4].confirmed = true;
}
@@ -6263,10 +6340,14 @@ post_test_transfers (struct TestTransfers_Closure *cls)
GNUNET_array_grow (cls->transfers[3].data.details,
cls->transfers[3].data.details_length,
0);
+ GNUNET_array_grow (cls->transfers[4].data.details,
+ cls->transfers[4].data.details_length,
+ 0);
free_instance_data (&cls->instance);
free_order_data (&cls->order);
free_order_data (&cls->orders_agg[0]);
free_order_data (&cls->orders_agg[1]);
+ free_order_data (&cls->order_perm);
}
@@ -6497,6 +6578,47 @@ run_test_transfers (struct TestTransfers_Closure *cls)
&cls->transfers[3],
GNUNET_DB_STATUS_SUCCESS_ONE_RESULT,
2));
+ /* A deposit that failed permanently (exchange wired the money to an
+ account we do not know) must NOT make the order count as wired,
+ and no order_settled notification may be generated for it. */
+ TEST_RET_ON_FAIL (test_insert_order (&cls->instance,
+ &cls->order_perm,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT));
+ TEST_RET_ON_FAIL (test_insert_contract_terms (&cls->instance,
+ &cls->order_perm,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT));
+ for (unsigned int i = 0; i < 2; i++)
+ TEST_RET_ON_FAIL (test_insert_deposit (&cls->instance,
+ &cls->signkey,
+ &cls->deposits_perm[i],
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT));
+ TEST_RET_ON_FAIL (test_mark_contract_paid (&cls->instance,
+ &cls->order_perm,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT));
+ TEST_RET_ON_FAIL (test_insert_deposit_to_transfer (&cls->instance,
+ &cls->signkey,
+ &cls->order_perm,
+ &cls->deposits_perm[0],
+ &cls->transfers[4],
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT,
+ false));
+ TEST_RET_ON_FAIL (test_insert_deposit_to_unknown_account (
+ &cls->instance,
+ &cls->signkey,
+ &cls->order_perm,
+ &cls->deposits_perm[1],
+ &cls->transfers[4]));
+ TEST_RET_ON_FAIL (test_insert_transfer_details_settling (
+ &cls->instance,
+ &cls->account,
+ &cls->transfers[4],
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT,
+ 0));
+ TEST_RET_ON_FAIL (test_lookup_payment_status (cls->instance.instance.id,
+ cls->order_perm.id,
+ NULL,
+ true,
+ false));
return 0;
}
diff --git a/src/include/merchant-database/insert_deposit_to_transfer.h b/src/include/merchant-database/insert_deposit_to_transfer.h
@@ -35,9 +35,12 @@
* @param h_wire hash of the merchant's account that should receive the deposit
* @param exchange_url URL of the exchange that is making the deposit
* @param dd deposit transfer data from the exchange to store
- * @return transaction status
+ * @return detailed outcome; note that #TALER_MERCHANTDB_DTTS_SETTLED
+ * is the only outcome where the deposit actually settled,
+ * the other non-negative values report a (possibly
+ * permanent) failure that was persisted
*/
-enum GNUNET_DB_QueryStatus
+enum TALER_MERCHANTDB_DepositToTransferStatus
TALER_MERCHANTDB_insert_deposit_to_transfer (
struct TALER_MERCHANTDB_PostgresContext *pg,
uint64_t deposit_serial,
diff --git a/src/include/merchantdb_lib.h b/src/include/merchantdb_lib.h
@@ -233,6 +233,50 @@ enum TALER_MERCHANTDB_DepositConfirmationStatus
};
/**
+ * Results from associating a deposit with a wire transfer.
+ * Values that also exist in `enum GNUNET_DB_QueryStatus`
+ * intentionally use the same numeric value.
+ */
+enum TALER_MERCHANTDB_DepositToTransferStatus
+{
+
+ /**
+ * Hard database failure.
+ */
+ TALER_MERCHANTDB_DTTS_HARD_ERROR = -2,
+
+ /**
+ * Soft database failure, retry.
+ */
+ TALER_MERCHANTDB_DTTS_SOFT_ERROR = -1,
+
+ /**
+ * The stored procedure did not return a row at all.
+ * Should be impossible.
+ */
+ TALER_MERCHANTDB_DTTS_NO_RESULTS = 0,
+
+ /**
+ * The deposit was associated with the wire transfer.
+ */
+ TALER_MERCHANTDB_DTTS_SETTLED = 1,
+
+ /**
+ * The exchange signing key is (still) unknown to us; the
+ * deposit was scheduled for a retry.
+ */
+ TALER_MERCHANTDB_DTTS_SIGNKEY_UNKNOWN = 2,
+
+ /**
+ * The exchange wired the money to an account we do not know.
+ * This is a permanent failure: the deposit will never settle
+ * and the order must not be considered wired.
+ */
+ TALER_MERCHANTDB_DTTS_ACCOUNT_UNKNOWN = 3
+
+};
+
+/**
* Details about an OTP device.
*/
struct TALER_MERCHANTDB_OtpDeviceDetails