#!/bin/bash # Auto-confirm bank withdrawals for the community demo pool ONLY (explorer). # # Discovery: (1) landing watch files (withdraw.uri / withdraw-watch.ids), # (2) libeufin DB via local psql (selected && !confirmed && username=explorer). # Watch-file-only misses webui/wallet-selected ops not in the last WATCH_MAX # IDs — DB scan is required (same idea as testpaysan; here in-CTR, no podman). # # Run once: auto-confirm-withdrawals.sh # Loop: auto-confirm-withdrawals.sh --loop [SECS] # # Env: # BANK_URL (default http://127.0.0.1:9012) # BANK_USER (default explorer) # BANK_PASS or /root/bank-explorer-password.txt # LANDING_DIR (default /var/www/bank-landing) # DB_NAME (default libeufin) # DB_LIMIT max selected IDs from DB per tick (default 40) # ALLOW_NON_EXPLORER=1 # WATCH_MAX max IDs kept in withdraw-watch.ids after prune (default 80) # QUIET=1 less skip noise (default 1 in --loop) # LOCK_FILE default /var/run/auto-confirm-withdrawals.lock set -euo pipefail BANK="${BANK_URL:-http://127.0.0.1:9012}" BANK="${BANK%/}" USER="${BANK_USER:-explorer}" LANDING_DIR="${LANDING_DIR:-/var/www/bank-landing}" WATCH_FILE="${LANDING_DIR}/withdraw-watch.ids" URI_FILE="${LANDING_DIR}/withdraw.uri" WATCH_MAX="${WATCH_MAX:-80}" DB_NAME="${DB_NAME:-libeufin}" DB_LIMIT="${DB_LIMIT:-40}" LOCK_FILE="${LOCK_FILE:-/var/run/auto-confirm-withdrawals.lock}" LOOP=0 SLEEP=2 if [ "${1:-}" = "--loop" ]; then LOOP=1 SLEEP="${2:-2}" fi # Quiet by default in loop mode if [ -z "${QUIET+x}" ]; then if [ "$LOOP" -eq 1 ]; then QUIET=1; else QUIET=0; fi fi if [ "$USER" != "explorer" ] && [ "${ALLOW_NON_EXPLORER:-0}" != "1" ]; then echo "refusing BANK_USER=$USER — auto-confirm is for explorer only (set ALLOW_NON_EXPLORER=1 to override)" >&2 exit 1 fi PASS="${BANK_PASS:-}" if [ -z "$PASS" ]; then for f in "/root/bank-${USER}-password.txt" /root/bank-explorer-password.txt; do if [ -f "$f" ]; then PASS=$(tr -d '\n' <"$f"); break; fi done fi [ -n "$PASS" ] || { echo "no password for $USER" >&2; exit 1; } # Single instance (loop mode) if [ "$LOOP" -eq 1 ]; then mkdir -p "$(dirname "$LOCK_FILE")" 2>/dev/null || true exec 9>"$LOCK_FILE" if command -v flock >/dev/null 2>&1; then if ! flock -n 9; then echo "auto-confirm already running (lock $LOCK_FILE) — exit" # Under systemd Restart=always, exit 0 on lock contention flaps the unit # (activating/auto-restart) while the holder is outside the cgroup. if [ -n "${INVOCATION_ID:-}" ]; then exit 75 fi exit 0 fi fi fi log() { printf '%s\n' "$*"; } qlog() { [ "${QUIET}" = "1" ] || log "$@"; } token() { curl -sS -m 12 -u "${USER}:${PASS}" \ -H 'Content-Type: application/json' \ -d '{"scope":"readwrite","refreshable":true}' \ "${BANK}/accounts/${USER}/token" } # Prefer taler-integration status (has selection_done / transfer_done) status_json() { local wid="$1" local j j=$(curl -sS -m 8 "${BANK}/taler-integration/withdrawal-operation/${wid}" 2>/dev/null || true) if [ -z "$j" ] || ! printf '%s' "$j" | grep -q '"status"'; then j=$(curl -sS -m 8 "${BANK}/withdrawals/${wid}" 2>/dev/null || true) fi printf '%s' "$j" } field() { # field name from json on stdin/arg local name="$1" data="${2:-}" if [ -z "$data" ]; then data=$(cat); fi printf '%s' "$data" | sed -n "s/.*\"${name}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -1 } known_ids() { if [ -f "$URI_FILE" ]; then basename "$(tr -d '\n' <"$URI_FILE")" fi if [ -f "$WATCH_FILE" ]; then grep -E '^[0-9a-fA-F-]{36}$' "$WATCH_FILE" || true fi } # True if WD amount fits explorer debt headroom (or psql unavailable → allow). id_affordable() { local wid="$1" if ! command -v psql >/dev/null 2>&1 || ! id -u postgres >/dev/null 2>&1; then return 0 fi local ok ok=$(su -s /bin/bash postgres -c "psql -d ${DB_NAME} -At -c \" WITH explorer AS ( SELECT a.bank_account_id, a.has_debt, a.balance, a.max_debt FROM libeufin_bank.bank_accounts a JOIN libeufin_bank.customers c ON c.customer_id = a.owning_customer_id WHERE c.username = '${USER}' LIMIT 1 ), headroom AS ( SELECT CASE WHEN has_debt THEN (max_debt).val - (balance).val ELSE (max_debt).val + (balance).val END AS val FROM explorer ) SELECT 1 FROM libeufin_bank.taler_withdrawal_operations w JOIN explorer e ON e.bank_account_id = w.wallet_bank_account CROSS JOIN headroom h WHERE w.withdrawal_uuid = '${wid}'::uuid AND (w.amount).val <= h.val LIMIT 1; \"" 2>/dev/null | tr -d '[:space:]') [ "$ok" = "1" ] } # Selected, not yet confirmed, owner = explorer only (runs inside goa CTR). db_selected_explorer_ids() { if ! command -v psql >/dev/null 2>&1; then qlog "psql missing — skip DB discovery" return 0 fi if ! id -u postgres >/dev/null 2>&1; then qlog "postgres user missing — skip DB discovery" return 0 fi # Prefer affordable amounts (newest-first often hits oversized junk that # exceeds explorer debt headroom → HTTP 409 Insufficient funds forever). su -s /bin/bash postgres -c "psql -d ${DB_NAME} -At -c \" WITH explorer AS ( SELECT a.bank_account_id, a.has_debt, a.balance, a.max_debt FROM libeufin_bank.bank_accounts a JOIN libeufin_bank.customers c ON c.customer_id = a.owning_customer_id WHERE c.username = '${USER}' LIMIT 1 ), headroom AS ( SELECT CASE WHEN has_debt THEN (max_debt).val - (balance).val ELSE (max_debt).val + (balance).val END AS val FROM explorer ) SELECT w.withdrawal_uuid::text FROM libeufin_bank.taler_withdrawal_operations w JOIN explorer e ON e.bank_account_id = w.wallet_bank_account CROSS JOIN headroom h WHERE w.selection_done = true AND w.confirmation_done = false AND w.aborted = false AND (w.amount).val <= h.val ORDER BY (w.amount).val ASC, (w.amount).frac ASC, w.creation_date DESC LIMIT ${DB_LIMIT}; \"" 2>/dev/null \ | grep -E '^[0-9a-fA-F-]{36}$' || true } # Keep file small: drop confirmed/aborted; keep pending/selected + tail prune_watch() { [ -f "$WATCH_FILE" ] || return 0 local tmp keep=0 tmp=$(mktemp) # Prefer newest IDs; re-check status for tail of file only would be slow — # keep last WATCH_MAX unique lines and re-append selected ones found this round. if [ -f "${LANDING_DIR}/.auto-confirm-selected" ]; then cat "${LANDING_DIR}/.auto-confirm-selected" >>"$tmp" 2>/dev/null || true fi tail -n "$((WATCH_MAX * 3))" "$WATCH_FILE" 2>/dev/null \ | grep -E '^[0-9a-fA-F-]{36}$' \ | awk 'NF && !seen[$0]++' \ | tail -n "$WATCH_MAX" >>"$tmp" || true # unique preserve order awk 'NF && !seen[$0]++' "$tmp" >"${tmp}.2" mv "${tmp}.2" "$WATCH_FILE" rm -f "$tmp" keep=$(wc -l <"$WATCH_FILE" | tr -d ' ') qlog "prune watch list → ${keep} ids" } confirm_one() { local wid="$1" local tok="$2" local info st uname conf info=$(status_json "$wid") [ -n "$info" ] || return 0 st=$(field status "$info") uname=$(field username "$info") if [ -n "$uname" ] && [ "$uname" != "$USER" ]; then qlog "skip $wid owner=$uname (only confirm $USER)" return 0 fi case "$st" in selected) # Watch-list IDs bypass DB headroom filter — skip oversized (HTTP 409 forever). if ! id_affordable "$wid"; then qlog "skip $wid selected but over explorer headroom" return 0 fi log "confirming $wid as $USER (status=selected) ..." conf=$(curl -sS -m 15 -o /tmp/acw-conf.out -w '%{http_code}' \ -X POST \ -H "Authorization: Bearer ${tok}" \ -H 'Content-Type: application/json' \ -d '{}' \ "${BANK}/accounts/${USER}/withdrawals/${wid}/confirm") log " HTTP $conf $(head -c 160 /tmp/acw-conf.out 2>/dev/null || true)" # remember for prune keep echo "$wid" >>"${LANDING_DIR}/.auto-confirm-selected" # verify info=$(status_json "$wid") st=$(field status "$info") log " now status=${st:-?}" ;; confirmed|aborted) qlog "skip $wid status=$st" ;; pending|"") qlog "skip $wid status=${st:-?} (waiting wallet select)" ;; *) qlog "skip $wid status=${st:-?}" ;; esac } once() { local tjson tok ids n_sel=0 n_conf=0 n_pend=0 n_other=0 n=0 tjson=$(token) tok=$(printf '%s' "$tjson" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) if [ -z "$tok" ]; then echo "token fail for $USER: $tjson" >&2 return 1 fi # Watch files (landing) + DB selected(!confirmed) explorer ops ids=$( { [ -f "$URI_FILE" ] && basename "$(tr -d '\n' <"$URI_FILE")" if [ -f "$WATCH_FILE" ]; then grep -E '^[0-9a-fA-F-]{36}$' "$WATCH_FILE" | awk 'NF && !seen[$0]++' | tail -n "$WATCH_MAX" fi db_selected_explorer_ids } | awk 'NF && !seen[$0]++' ) if [ -z "$ids" ]; then qlog "no withdrawal ids (selected or watched)" return 0 fi : >"${LANDING_DIR}/.auto-confirm-selected.tmp" while read -r wid; do [ -n "$wid" ] || continue n=$((n + 1)) info=$(status_json "$wid") st=$(field status "$info") case "$st" in selected) n_sel=$((n_sel + 1)) echo "$wid" >>"${LANDING_DIR}/.auto-confirm-selected.tmp" confirm_one "$wid" "$tok" ;; confirmed) n_conf=$((n_conf + 1)); qlog "skip $wid status=confirmed" ;; pending) n_pend=$((n_pend + 1)); qlog "skip $wid status=pending" ;; aborted) n_other=$((n_other + 1)); qlog "skip $wid status=aborted" ;; *) n_other=$((n_other + 1)); qlog "skip $wid status=${st:-?}" ;; esac done <<<"$ids" if [ -s "${LANDING_DIR}/.auto-confirm-selected.tmp" ]; then mv "${LANDING_DIR}/.auto-confirm-selected.tmp" "${LANDING_DIR}/.auto-confirm-selected" else rm -f "${LANDING_DIR}/.auto-confirm-selected.tmp" fi # Always one summary line per loop (monitoring-friendly) log "tick checked=$n selected=$n_sel confirmed=$n_conf pending=$n_pend other=$n_other" # Periodic prune (every loop is ok now that we only scan tail) if [ "$n" -gt "$((WATCH_MAX / 2))" ] || [ -f "$WATCH_FILE" ]; then wc=$(wc -l <"$WATCH_FILE" 2>/dev/null | tr -d ' ' || echo 0) if [ "${wc:-0}" -gt "$((WATCH_MAX * 2))" ]; then prune_watch fi fi } if [ "$LOOP" -eq 1 ]; then log "auto-confirm loop every ${SLEEP}s user=$USER watch_max=$WATCH_MAX quiet=$QUIET" # initial prune if bloated if [ -f "$WATCH_FILE" ]; then wc=$(wc -l <"$WATCH_FILE" | tr -d ' ') if [ "$wc" -gt "$((WATCH_MAX * 2))" ]; then log "watch list bloated ($wc) — pruning" prune_watch fi fi while true; do once || true sleep "$SLEEP" done else once fi