merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

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:
Msrc/backend/taler-merchant-depositcheck.c | 41++++++++++++++++++++++++++++++-----------
Msrc/backenddb/insert_deposit_to_transfer.c | 48++++++++++++++++++++++++++++++++++++++++++------
Msrc/backenddb/insert_deposit_to_transfer.sql | 17+++++++++++++----
Msrc/backenddb/insert_transfer_details.sql | 7++++++-
Msrc/backenddb/test_merchantdb.c | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/include/merchant-database/insert_deposit_to_transfer.h | 7+++++--
Msrc/include/merchantdb_lib.h | 44++++++++++++++++++++++++++++++++++++++++++++
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