commit 6a134493fe4b5f86aa0119580953a6565673a437
parent 007ac6f559d566b906a1d092a0bba3bbc7cfccf9
Author: Sebastian <sebasjm@taler-systems.com>
Date: Sun, 25 Oct 2026 16:39:04 -0300
fix forever pending wiretransfer
transfer stay unconfirmed if the expected amount is not present on
INSERT
Diffstat:
6 files changed, 466 insertions(+), 1 deletion(-)
diff --git a/src/backenddb/pg_triggers.sql b/src/backenddb/pg_triggers.sql
@@ -223,6 +223,32 @@ BEGIN
END $$;
+-- Re-check confirmation when taler-merchant-reconciliation fills in the
+-- expected_credit_amount that taler-merchant-depositcheck could not set.
+CREATE OR REPLACE FUNCTION merchant_expected_transfers_update_trigger()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ IF ( (OLD.expected_credit_amount IS DISTINCT FROM NEW.expected_credit_amount)
+ AND (NEW.expected_credit_amount IS NOT NULL)
+ AND (NOT NEW.confirmed) )
+ THEN
+ UPDATE merchant_transfers
+ SET expected = TRUE
+ WHERE wtid = NEW.wtid
+ AND exchange_url = NEW.exchange_url
+ AND credit_amount = NEW.expected_credit_amount;
+ IF FOUND THEN
+ NEW.confirmed = TRUE;
+ END IF;
+ END IF;
+ RETURN NEW;
+END $$;
+COMMENT ON FUNCTION merchant_expected_transfers_update_trigger
+ IS 'Sets "confirmed" to TRUE for the updated record if a matching merchant_transfers row appears once the expected_credit_amount becomes known.';
+
+
CREATE OR REPLACE FUNCTION merchant_kyc_insert_trigger()
RETURNS TRIGGER
LANGUAGE plpgsql
diff --git a/src/backenddb/sql-schema/merchant-0036-init.sql.fragment b/src/backenddb/sql-schema/merchant-0036-init.sql.fragment
@@ -535,7 +535,7 @@ BEGIN
COMMENT ON COLUMN merchant_expected_transfers.signkey_serial IS 'Identifies the online signing key of the exchange used to make the exchange_sig';
COMMENT ON COLUMN merchant_expected_transfers.exchange_sig IS 'Signature over the aggregation response from the exchange, or NULL on error or if we did not yet make that request';
COMMENT ON COLUMN merchant_expected_transfers.h_details IS 'Hash over the aggregation details returned by the exchange, provided here for fast exchange_sig validation';
- COMMENT ON COLUMN merchant_expected_transfers.confirmed IS 'true once the merchant confirmed that this transfer was received and a matching transfer exists in the merchant_transfers table; set automatically via INSERT TRIGGER merchant_expected_transfers_insert_trigger';
+ COMMENT ON COLUMN merchant_expected_transfers.confirmed IS 'true once the merchant confirmed that this transfer was received and a matching transfer exists in the merchant_transfers table; set automatically via INSERT TRIGGER merchant_expected_transfers_insert_trigger or UPDATE TRIGGER merchant_expected_transfers_update_trigger when the amount becomes known later';
CREATE INDEX merchant_expected_transfers_by_open
ON merchant_expected_transfers (retry_time)
WHERE ((NOT confirmed) OR retry_needed);
diff --git a/src/backenddb/sql-schema/merchant-0050.sql b/src/backenddb/sql-schema/merchant-0050.sql
@@ -0,0 +1,79 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2026 Taler Systems SA
+--
+-- TALER is free software; you can redistribute it and/or modify it under the
+-- terms of the GNU General Public License as published by the Free Software
+-- Foundation; either version 3, or (at your option) any later version.
+--
+-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+-- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License along with
+-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+-- @file merchant-0050.sql
+-- @brief Re-check confirmation of expected transfers when expected_credit_amount is filled in
+-- @author Kimi Code
+
+-- taler-merchant-depositcheck inserts merchant_expected_transfers with
+-- expected_credit_amount = NULL because /deposits only returns the coin's
+-- contribution. taler-merchant-reconciliation later fills in the actual
+-- total amount via /transfers/$WTID. The INSERT triggers only fire when the
+-- expected-transfer row is first created, so transfers that already existed
+-- in merchant_transfers were never linked. This migration installs the body
+-- of the UPDATE trigger that merchant-0037 already created as a placeholder.
+
+BEGIN;
+
+SELECT _v.register_patch('merchant-0050', NULL, NULL);
+
+SET search_path TO merchant;
+
+CREATE PROCEDURE merchant.merchant_0050_init(s TEXT)
+ LANGUAGE plpgsql
+ AS $OUTER$
+BEGIN
+
+ EXECUTE format('SET LOCAL search_path TO %I', s);
+
+ CREATE OR REPLACE FUNCTION merchant_expected_transfers_update_trigger()
+ RETURNS trigger
+ LANGUAGE plpgsql
+ AS $$
+ BEGIN
+ IF ( (OLD.expected_credit_amount IS DISTINCT FROM NEW.expected_credit_amount)
+ AND (NEW.expected_credit_amount IS NOT NULL)
+ AND (NOT NEW.confirmed) )
+ THEN
+ UPDATE merchant_transfers
+ SET expected = TRUE
+ WHERE wtid = NEW.wtid
+ AND exchange_url = NEW.exchange_url
+ AND credit_amount = NEW.expected_credit_amount;
+ IF FOUND THEN
+ NEW.confirmed = TRUE;
+ END IF;
+ END IF;
+ RETURN NEW;
+ END $$;
+
+ COMMENT ON FUNCTION merchant_expected_transfers_update_trigger IS
+ 'Sets "confirmed" to TRUE for the updated record if a matching merchant_transfers row appears once the expected_credit_amount becomes known.';
+
+ SET LOCAL search_path TO merchant;
+
+END
+$OUTER$;
+
+INSERT INTO merchant.instance_fixups
+ (migration_name
+ ,version)
+ VALUES
+ ('merchant_0050_init'
+ ,50);
+-- Apply new fix-up to existing instances
+CALL merchant.fixup_instance_schema (50::INT8);
+
+COMMIT;
diff --git a/src/backenddb/sql-schema/meson.build b/src/backenddb/sql-schema/meson.build
@@ -149,6 +149,7 @@ generated_sql = [
['merchant-0047.sql'],
['merchant-0048.sql'],
['merchant-0049.sql'],
+ ['merchant-0050.sql'],
]
migration_sql = []
diff --git a/src/testing/meson.build b/src/testing/meson.build
@@ -17,6 +17,7 @@ check_SCRIPTS = [
'test_merchant_signup_email',
'test_merchant_statistics',
'test_merchant_templates',
+ 'test_merchant_depositcheck_confirmation',
'test_merchant_transfer_tracking',
'test_merchant_units',
'test-merchant-walletharness',
diff --git a/src/testing/test_merchant_depositcheck_confirmation.sh b/src/testing/test_merchant_depositcheck_confirmation.sh
@@ -0,0 +1,358 @@
+#!/usr/bin/env bash
+# This file is part of TALER
+# Copyright (C) 2026 Taler Systems SA
+#
+# TALER is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 3, or
+# (at your option) any later version.
+#
+# TALER is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public
+# License along with TALER; see the file COPYING. If not, see
+# <http://www.gnu.org/licenses/>
+#
+
+
+
+
+# Testcase for the depositcheck/reconciliation confirmation race:
+# taler-merchant-depositcheck creates merchant_expected_transfers with
+# expected_credit_amount = NULL because /deposits only knows the coin's
+# contribution. taler-merchant-reconciliation later fills in the total
+# amount via /transfers/$WTID. The confirmation linking must also happen
+# on that UPDATE, not only on INSERT.
+
+# FIXME: we need to make this tests simpler
+#
+# goto REPRODUCE THE RACE
+# I was unable to test by endpoint because of src/backend/taler-merchant-httpd_get-private-transfers.c:84 is hardcoded with true
+
+set -eu
+
+. setup.sh
+
+# Exit, with status code "skip" (no 'real' failure)
+function exit_skip() {
+ echo "SKIPPING: $1"
+ exit 77
+}
+
+# Use fakebank (instant) for this test.
+USE_FAKEBANK=1
+ACCOUNT="exchange-account-2"
+WIRE_METHOD="x-taler-bank"
+BANK_FLAGS="-f -d $WIRE_METHOD -u $ACCOUNT"
+BANK_URL="http://localhost:8082/"
+
+
+echo -n "Testing for taler-harness"
+taler-harness --help >/dev/null </dev/null || exit_skip " MISSING"
+echo " FOUND"
+
+# Launch exchange, merchant and bank.
+setup -c "test_template.conf" \
+ -em \
+ -r "merchant-exchange-default" \
+ $BANK_FLAGS
+LAST_RESPONSE=$(mktemp -p "${TMPDIR:-/tmp}" test_response.conf-XXXXXX)
+CONF="test_template.conf.edited"
+WALLET_DB=$(mktemp -p "${TMPDIR:-/tmp}" test_wallet.json-XXXXXX)
+EXCHANGE_URL="http://localhost:8081/"
+
+FACADE_URL="http://localhost:8082/accounts/gnunet/taler-revenue/"
+FACADE_USERNAME="gnunet"
+FACADE_PASSWORD="password"
+
+# Extract merchant DB connection for direct DB assertions.
+echo -n "Checking for psql ..."
+psql --version >/dev/null 2>&1 || exit_skip " no psql"
+echo " OK"
+
+DB_URI=$(sed -n '/^\[merchantdb-postgres\]/,/^\[/p' "$CONF" \
+ | awk -F= '/^CONFIG[[:space:]]*=/ { gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2); print $2; exit }')
+if [ -z "$DB_URI" ]
+then
+ exit_fail "Could not extract merchant DB URI from $CONF"
+fi
+
+# Helper to run a query against a merchant instance schema.
+function merchant_psql() {
+ psql -At -q -d "$DB_URI" -v ON_ERROR_STOP=1 \
+ -c "SET search_path TO ${MERCHANT_SCHEMA}; $1"
+}
+
+echo -n "First prepare wallet with coins..."
+rm -f "${WALLET_DB}"
+taler-wallet-cli \
+ --no-throttle \
+ --wallet-db="$WALLET_DB" \
+ api \
+ --expect-success 'withdrawTestBalance' \
+ "$(jq -n '
+ {
+ amount: "TESTKUDOS:99",
+ corebankApiBaseUrl: $BANK_URL,
+ exchangeBaseUrl: $EXCHANGE_URL
+ }' \
+ --arg BANK_URL "${BANK_URL}" \
+ --arg EXCHANGE_URL "$EXCHANGE_URL"
+ )" 2>wallet-withdraw-1.err >wallet-withdraw-1.out
+echo -n "."
+taler-exchange-wirewatch \
+ -L "INFO" \
+ -c "$CONF" \
+ -t \
+ &> taler-exchange-wirewatch.out
+echo -n "."
+timeout 60 taler-wallet-cli \
+ --wallet-db="$WALLET_DB" \
+ run-until-done \
+ 2>wallet-withdraw-finish-1.err \
+ >wallet-withdraw-finish-1.out
+echo " OK"
+
+#
+# CREATE INSTANCE FOR TESTING
+#
+
+echo -n "Configuring merchant admin instance ..."
+GNUNET_PAYTO="payto://x-taler-bank/localhost/gnunet?receiver-name=gnunet"
+STATUS=$(curl -H "Content-Type: application/json" -X POST \
+ http://localhost:9966/management/instances \
+ -d '{"auth":{"method":"token","password":"super_secret"},"id":"admin","name":"default","user_type":"business","address":{},"jurisdiction":{},"use_stefan":true,"default_wire_transfer_delay":{"d_us" : 50000000},"default_pay_delay":{"d_us": 60000000}}' \
+ -w "%{http_code}" -s -o /dev/null)
+
+if [ "$STATUS" != "204" ]
+then
+ exit_fail "Expected 204 no content. Got: $STATUS"
+fi
+ADMIN_TOKEN=$(merchant_login admin super_secret)
+echo "OK"
+
+# Resolve the per-instance schema name for direct DB inspection.
+MERCHANT_SERIAL=$(psql -At -q -d "$DB_URI" -v ON_ERROR_STOP=1 \
+ -c "SELECT merchant_serial FROM merchant.merchant_instances WHERE merchant_id='admin';")
+MERCHANT_SCHEMA="merchant_instance_${MERCHANT_SERIAL}"
+
+echo -n "Configuring bank account..."
+STATUS=$(curl -H "Content-Type: application/json" -X POST \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ http://localhost:9966/private/accounts \
+ -d '{"payto_uri":"'"$GNUNET_PAYTO"'","credit_facade_url":"'"${FACADE_URL}"'","credit_facade_credentials":{"type":"basic","username":"'"$FACADE_USERNAME"'","password":"'"$FACADE_PASSWORD"'"}}' \
+ -w "%{http_code}" -s -o /dev/null)
+
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 OK. Got: $STATUS"
+fi
+echo "OK"
+
+# CREATE ORDER AND SELL IT
+echo -n "Creating order to be paid..."
+STATUS=$(curl 'http://localhost:9966/private/orders' \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -d '{"refund_delay":{"d_us":0},"order":{"amount":"TESTKUDOS:1","summary":"payme"}}' \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 OK. Got: $STATUS " "$(cat "$LAST_RESPONSE")"
+fi
+
+ORDER_ID=$(jq -e -r .order_id < "$LAST_RESPONSE")
+STATUS=$(curl "http://localhost:9966/private/orders/${ORDER_ID}" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 ok. Got: $STATUS" "$(cat "$LAST_RESPONSE")"
+fi
+PAY_URL=$(jq -e -r .taler_pay_uri < "$LAST_RESPONSE")
+echo OK
+
+NOW=$(date +%s)
+echo -n "Pay order ..."
+taler-wallet-cli \
+ --no-throttle \
+ --wallet-db="$WALLET_DB" \
+ handle-uri "${PAY_URL}" \
+ -y 2> wallet-pay1.err > wallet-pay1.log
+NOW2=$(date +%s)
+echo "OK. Took $(( NOW2 - NOW))s."
+
+STATUS=$(curl "http://localhost:9966/private/orders/${ORDER_ID}" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected 200 Ok. Got: $STATUS" "$(cat "$LAST_RESPONSE")"
+fi
+
+ORDER_STATUS=$(jq -r .order_status < "$LAST_RESPONSE")
+if [ "$ORDER_STATUS" != "paid" ]
+then
+ exit_fail "Expected order status 'paid'. Got: $ORDER_STATUS" "$(cat "$LAST_RESPONSE")"
+fi
+
+#
+# WIRE TRANSFER TO MERCHANT
+#
+
+WIRE_DEADLINE=$(jq -r .contract_terms.wire_transfer_deadline.t_s < "$LAST_RESPONSE")
+NOW=$(date +%s)
+
+TO_SLEEP="$(( 3600 + WIRE_DEADLINE - NOW ))"
+echo -n "Perform wire transfers (with ${TO_SLEEP}s timeshift) ..."
+taler-exchange-aggregator \
+ -y \
+ -c "$CONF" \
+ -T "${TO_SLEEP}000000" \
+ -t \
+ -L INFO &> aggregator.log
+taler-exchange-transfer \
+ -c "$CONF" \
+ -t \
+ -L INFO &> transfer.log
+echo " DONE"
+
+#
+# REPRODUCE THE RACE: import the bank transfer first, then run depositcheck,
+# then reconciliation. With the bug, the transfer stays unconfirmed because
+# the expected_credit_amount only becomes known on UPDATE.
+#
+
+echo -n "Running taler-merchant-wirewatch to import transfer ..."
+taler-merchant-wirewatch \
+ -c "$CONF" \
+ -t \
+ -L INFO &> merchant-wirewatch.log
+echo " OK"
+
+# DB sanity check: we have the bank-side row but no expected transfer yet.
+echo -n "Checking DB state after wirewatch ..."
+DB_STATE=$(merchant_psql "SELECT (SELECT COUNT(*) FROM merchant_transfers), (SELECT COUNT(*) FROM merchant_expected_transfers);")
+IFS='|' read -r TRANSFER_COUNT EXPECTED_COUNT <<< "$DB_STATE"
+if [ "$TRANSFER_COUNT" != "1" ]
+then
+ exit_fail "Expected 1 merchant_transfers row, got $TRANSFER_COUNT"
+fi
+if [ "$EXPECTED_COUNT" != "0" ]
+then
+ exit_fail "Expected 0 merchant_expected_transfers rows before depositcheck, got $EXPECTED_COUNT"
+fi
+echo " OK"
+
+echo -n "Running taler-merchant-depositcheck to create expected transfer ..."
+taler-merchant-depositcheck \
+ -c "$CONF" \
+ -e "http://localhost:8081/" \
+ -T "${TO_SLEEP}000000" \
+ -t \
+ -L INFO &> merchant-depositcheck.log
+echo " OK"
+
+# DB sanity check: depositcheck created an unconfirmed expected transfer
+# without knowing the final credit amount.
+echo -n "Checking DB state after depositcheck ..."
+DB_STATE=$(merchant_psql "SELECT expected_credit_amount IS NULL, confirmed, retry_needed FROM merchant_expected_transfers;")
+IFS='|' read -r AMOUNT_NULL CONFIRMED RETRY <<< "$DB_STATE"
+if [ "$AMOUNT_NULL" != "t" ]
+then
+ exit_fail "Expected expected_credit_amount to be NULL after depositcheck"
+fi
+if [ "$CONFIRMED" != "f" ]
+then
+ exit_fail "Expected confirmed=false after depositcheck, got $CONFIRMED"
+fi
+if [ "$RETRY" != "t" ]
+then
+ exit_fail "Expected retry_needed=true after depositcheck, got $RETRY"
+fi
+echo " OK"
+
+echo -n "Running taler-merchant-reconciliation to finalize transfer amount ..."
+taler-merchant-reconciliation \
+ -c "$CONF" \
+ -T "${TO_SLEEP}000000" \
+ -t \
+ -L INFO &> merchant-reconciliation.log
+echo " OK"
+
+# Primary regression check: reconciliation must have linked the expected
+# transfer to the imported bank transfer now that the amount is known.
+echo -n "Checking DB state after reconciliation ..."
+DB_STATE=$(merchant_psql "SELECT expected_credit_amount IS NULL, confirmed FROM merchant_expected_transfers;")
+IFS='|' read -r AMOUNT_NULL CONFIRMED <<< "$DB_STATE"
+if [ "$AMOUNT_NULL" != "f" ]
+then
+ exit_fail "Expected expected_credit_amount to be set after reconciliation"
+fi
+if [ "$CONFIRMED" != "t" ]
+then
+ exit_fail "Expected confirmed=true after reconciliation, got $CONFIRMED (UPDATE trigger likely missing)"
+fi
+echo " OK"
+
+echo -n "Checking incoming transfer is confirmed via API ..."
+STATUS=$(curl 'http://localhost:9966/private/incoming' \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+if [ "$STATUS" != "200" ]
+then
+ exit_fail "Expected response 200 Ok. Got: $STATUS" "$(jq . < "$LAST_RESPONSE")"
+fi
+
+INCOMING_LIST_SIZE=$(jq -r '.incoming | length' < "$LAST_RESPONSE")
+if [ "$INCOMING_LIST_SIZE" != "1" ]
+then
+ exit_fail "Expected one incoming transfer. Got: $INCOMING_LIST_SIZE" "$(jq . < "$LAST_RESPONSE")"
+fi
+
+CONFIRMED=$(jq -r '.incoming[0].confirmed' < "$LAST_RESPONSE")
+if [ "$CONFIRMED" != "true" ]
+then
+ jq . < "$LAST_RESPONSE"
+ exit_fail "Expected incoming[0].confirmed 'true'. Got: $CONFIRMED"
+fi
+echo " OK"
+
+# Also verify the order is wired.
+echo -n "Checking order status ..."
+STATUS=$(curl "http://localhost:9966/private/orders/${ORDER_ID}?transfer=YES" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -w "%{http_code}" \
+ -s \
+ -o "$LAST_RESPONSE")
+
+if [ "$STATUS" != "200" ]
+then
+ jq . < "$LAST_RESPONSE"
+ exit_fail "Expected 200 ok. got: $STATUS" "$(cat "$LAST_RESPONSE")"
+fi
+
+WAS_WIRED=$(jq -r .wired < "$LAST_RESPONSE")
+if [ "$WAS_WIRED" != "true" ]
+then
+ jq . < "$LAST_RESPONSE"
+ exit_fail "Got .wired 'false', expected 'true'"
+fi
+echo " OK"
+
+echo "TEST PASSED"
+
+exit 0