monitoring: taler-monitoring www/inside/sanity/server/e2e scaffold

This commit is contained in:
Hernâni Marques 2026-07-09 19:54:42 +02:00
parent fed083d0d5
commit 95499a7f59
No known key found for this signature in database
11 changed files with 2828 additions and 0 deletions

View file

@ -0,0 +1,997 @@
#!/usr/bin/env bash
# Full withdraw + pay cycle; clear BLOCKERS when the path cannot finish.
# Skips remaining steps if overall budget exceeded (E2E_TIMEOUT, default 90s).
set -euo pipefail
ROOT=$(cd "$(dirname "$0")" && pwd)
# shellcheck source=lib.sh
source "$ROOT/lib.sh"
: "${E2E_TIMEOUT:=180}" # ATM ladder + wirewatch lag needs room (was 55 — too tight)
: "${E2E_PAY_SECS:=22}" # hard reserve for handle-uri + settle (do not starve pay)
: "${E2E_SETTLE_ROUNDS:=18}"
: "${E2E_SETTLE_SLEEP:=3}"
E2E_START=$(date +%s)
BAL_BEFORE="(not captured)"
BAL_AFTER="(not captured)"
E2E_REPORTED=0
e2e_left() { echo $(( E2E_TIMEOUT - ($(date +%s) - E2E_START) )); }
e2e_over() { [ "$(e2e_left)" -le 0 ]; }
# Fixed short timeout — used even when e2e budget is exhausted / on EXIT
# stdout only for JSON; logs go to stderr file so balance parse stays clean
wcli_bal_snap() {
local out="$1"
if [ -z "${WALLET_CLI:-}" ] || [ ! -f "${WDB:-}" ]; then
echo "(no wallet)" >"$out"
return 1
fi
local err="${out}.err"
if command -v perl >/dev/null 2>&1; then
perl -e 'alarm shift; exec @ARGV' 10 \
node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \
>"$out" 2>"$err" || true
else
node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \
>"$out" 2>"$err" || true
fi
[ -s "$out" ]
}
fmt_bal() {
python3 -c '
import json,re,sys
raw=open(sys.argv[1]).read() if sys.argv[1] else ""
if not raw.strip() or raw.strip().startswith("(no"):
print(raw.strip() or "(empty)"); sys.exit(0)
# extract first JSON object with "balances"
d=None
for m in re.finditer(r"\{", raw):
try:
d=json.loads(raw[m.start():])
if isinstance(d, dict) and "balances" in d:
break
except Exception:
d=None
if not isinstance(d, dict):
print(re.sub(r"\s+"," ", raw)[:200]); sys.exit(0)
parts=[]
for b in d.get("balances") or []:
cur=(b.get("scopeInfo") or {}).get("currency") or "?"
parts.append("%s avail=%s pendingIn=%s" % (cur, b.get("available"), b.get("pendingIncoming")))
print("; ".join(parts) if parts else "(no balances)")
' "${1:-/dev/null}" 2>/dev/null || echo "(unreadable)"
}
# Numeric available for currency CUR (from balance file or fresh wallet snap)
wallet_avail_num() {
local f="${1:-}"
if [ -z "$f" ] || [ ! -s "$f" ]; then
f="$SCRATCH/bal-live.out"
wcli_bal_snap "$f" || wcli balance >"$f" 2>&1 || true
fi
python3 -c '
import json,re,sys
cur=sys.argv[2]
raw=open(sys.argv[1]).read() if sys.argv[1] else ""
d=None
for m in re.finditer(r"\{", raw):
try:
d=json.loads(raw[m.start():])
if isinstance(d, dict) and "balances" in d: break
except Exception:
d=None
if isinstance(d, dict):
for b in d.get("balances") or []:
c=(b.get("scopeInfo") or {}).get("currency") or ""
av=b.get("available") or ""
if c==cur or av.startswith(cur+":"):
try:
print(float(av.split(":",1)[1])); sys.exit(0)
except Exception:
pass
# text fallback
m=re.search(r"%s:([0-9]+(?:\.[0-9]+)?)" % re.escape(cur), raw)
print(float(m.group(1)) if m else 0.0)
' "${f:-/dev/null}" "${CUR:-GOA}" 2>/dev/null || echo 0
}
# Wait for spendable balance (settlement / wirewatch lag). Returns 0 if avail > min.
# Emphasizes timing, not hard failure, while waiting.
wait_wallet_balance() {
local min_n="${1:-0}"
local rounds="${2:-15}"
local sleep_s="${3:-3}"
local r av
info "wallet settle wait" "up to ${rounds}×${sleep_s}s for avail>${min_n} ${CUR} (wirewatch lag is common)"
for r in $(seq 1 "$rounds"); do
wcli run-until-done >"$SCRATCH/settle-run.out" 2>&1 || true
wcli_bal_snap "$SCRATCH/bal-live.out" || wcli balance >"$SCRATCH/bal-live.out" 2>&1 || true
av=$(wallet_avail_num "$SCRATCH/bal-live.out")
if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > float(sys.argv[2]) else 1)" "$av" "$min_n" 2>/dev/null; then
ok "wallet balance ready" "avail=${CUR}:${av} after ~$((r * sleep_s))s settle wait"
info "balance" "$(fmt_bal "$SCRATCH/bal-live.out")"
return 0
fi
if [ "$r" = "1" ] || [ "$((r % 3))" = "0" ]; then
info "settlement lag" "round $r/${rounds}: avail=${CUR}:${av} (still waiting — not an error yet)"
fi
# gentle wirewatch nudge mid-wait (local only)
if [ "$r" = "4" ] || [ "$r" = "8" ]; then
if [ "${E2E_REMOTE:-0}" != "1" ] && koopa_ssh_ok; then
koopa_ssh_run 8 \
'podman exec taler-hacktivism-exchange-ansible systemctl restart taler-exchange-wirewatch 2>/dev/null || true' \
>/dev/null 2>&1 || true
fi
fi
sleep "$sleep_s"
done
av=$(wallet_avail_num "$SCRATCH/bal-live.out")
warn "settlement lag" "after ${rounds}×${sleep_s}s still avail=${CUR}:${av} — coins may still be in flight"
return 1
}
print_balances() {
section "e2e · wallet balances"
info "BALANCE before" "$BAL_BEFORE"
# refresh after if we can
if [ -n "${SCRATCH:-}" ]; then
wcli_bal_snap "$SCRATCH/bal-end.out" || true
if [ -s "$SCRATCH/bal-end.out" ]; then
BAL_AFTER=$(fmt_bal "$SCRATCH/bal-end.out")
fi
fi
info "BALANCE after " "$BAL_AFTER"
}
e2e_finish() {
local code="${1:-1}"
if [ "$E2E_REPORTED" = "1" ]; then
return "$code"
fi
E2E_REPORTED=1
print_balances
summary || true
return "$code"
}
e2e_skip_rest() {
local why="$1"
warn "e2e timeout" "budget ${E2E_TIMEOUT}s exceeded — $why"
blocker "e2e-timeout" "skipped remaining steps after ${E2E_TIMEOUT}s ($why)"
section "e2e · report"
info "WITHDRAW" "incomplete (timeout)"
info "PAY" "SKIPPED (timeout)"
# If we already started a withdrawal, dig inside for missing coins
if [ -n "${WID:-}" ]; then
dig_when_no_coins 2>/dev/null || true
fi
e2e_finish 1
exit 1
}
# Area e2e-### — withdraw + pay cycle
set_area e2e
section "e2e · prerequisites"
info "e2e budget" "${E2E_TIMEOUT}s (set E2E_TIMEOUT= to change)"
# Remote domains (not koopa): public path only, tiny amounts, abort on login/KYC
: "${E2E_REMOTE:=0}"
if [ "${LOCAL_STACK:-1}" != "1" ]; then
E2E_REMOTE=1
SKIP_SSH=1
E2E_FAKE_INCOMING=0
fi
# Currency for amounts / balance checks
CUR="${EXPECT_CURRENCY:-}"
if [ -z "$CUR" ]; then
CUR=$(curl -skS -m 8 "$BANK_PUBLIC/config" 2>/dev/null | python3 -c 'import sys,json
try: print(json.load(sys.stdin).get("currency") or "")
except Exception: print("")' 2>/dev/null || true)
fi
[ -z "$CUR" ] && CUR=$(curl -skS -m 8 "$EXCHANGE_PUBLIC/config" 2>/dev/null | python3 -c 'import sys,json
try: print(json.load(sys.stdin).get("currency") or "")
except Exception: print("")' 2>/dev/null || true)
[ -z "$CUR" ] && CUR="GOA"
# ---------------------------------------------------------------------------
# Variable amounts
# Withdraw = typical ATM notes only (Bankomat): 20 / 50 / 100 / 200 …
# Pay = several smaller purchase amounts
# Env:
# E2E_WITHDRAW_VALUES="20 50 100" # bare numbers → prefixed with CUR
# E2E_PAY_VALUES="0.01 0.05 0.5 1 2 5"
# E2E_VARIABLE=0 # single fixed WITHDRAW_AMT / PAY_AMT
# E2E_ATM_MAX=N # try at most N ATM withdraws (budget)
# ---------------------------------------------------------------------------
: "${E2E_VARIABLE:=1}"
: "${E2E_ATM_MAX:=4}"
if [ "$E2E_REMOTE" = "1" ]; then
# remote: still ATM-shaped but smaller notes; keep cheap
: "${E2E_WITHDRAW_VALUES:=10 20 50}"
: "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}"
else
# local GOA: classic ATM denominations
: "${E2E_WITHDRAW_VALUES:=20 50 100 200}"
: "${E2E_PAY_VALUES:=0.01 0.05 0.1 0.5 1 2 5 10}"
fi
# Build CUR:amount lists
build_amt_list() {
local cur="$1"; shift
local v out=""
for v in "$@"; do
case "$v" in
*:*) out="${out}${out:+ }$v" ;;
*) out="${out}${out:+ }${cur}:${v}" ;;
esac
done
printf '%s' "$out"
}
# shellcheck disable=SC2086
WITHDRAW_LIST=$(build_amt_list "$CUR" $E2E_WITHDRAW_VALUES)
# shellcheck disable=SC2086
PAY_LIST=$(build_amt_list "$CUR" $E2E_PAY_VALUES)
if [ "$E2E_VARIABLE" != "1" ]; then
if [ "$E2E_REMOTE" = "1" ]; then
WITHDRAW_LIST="${WITHDRAW_AMT:-${CUR}:20}"
PAY_LIST="${PAY_AMT:-${CUR}:0.01}"
else
WITHDRAW_LIST="${WITHDRAW_AMT:-${CUR}:20}"
PAY_LIST="${PAY_AMT:-${CUR}:0.01}"
fi
fi
# Credit = sum of planned ATM withdraws + small buffer for fees
CREDIT_AMT=$(python3 -c '
import sys
cur=sys.argv[1]
vals=sys.argv[2].split()
s=0.0
for a in vals:
try: s += float(a.split(":",1)[-1])
except Exception: pass
buf = max(10.0, s * 0.05)
print("%s:%g" % (cur, s + buf))
' "$CUR" "$WITHDRAW_LIST" 2>/dev/null || echo "${CUR}:100")
# First withdraw/pay for legacy single-step labels
WITHDRAW_AMT=$(printf '%s' "$WITHDRAW_LIST" | awk '{print $1}')
PAY_AMT=$(printf '%s' "$PAY_LIST" | awk '{print $1}')
# More wall time when running ATM ladder
if [ "$E2E_VARIABLE" = "1" ]; then
n_w=$(printf '%s' "$WITHDRAW_LIST" | wc -w | tr -d ' ')
n_p=$(printf '%s' "$PAY_LIST" | wc -w | tr -d ' ')
need=$(( 40 + n_w * 35 + n_p * 20 ))
if [ "${E2E_TIMEOUT}" -lt "$need" ]; then
E2E_TIMEOUT=$need
fi
# cap ATM attempts
WITHDRAW_LIST=$(printf '%s' "$WITHDRAW_LIST" | tr ' ' '\n' | head -n "$E2E_ATM_MAX" | tr '\n' ' ' | sed 's/ *$//')
fi
BANK_HOST=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$BANK_PUBLIC"'").hostname or "bank")' 2>/dev/null || echo bank)
SCRATCH=$(mktemp -d)
WDB="$SCRATCH/wallet.sqlite3"
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
# Always dump balances on any exit (timeout, blocker, signal, success)
trap 'ec=$?; e2e_finish "$ec"; rm -rf "$SCRATCH"; exit "$ec"' EXIT
# Abort cleanly on login / KYC / registration barriers (esp. remote domains)
e2e_abort_auth() {
local step="$1" msg="$2"
warn "$step" "$msg"
blocker "$step" "abort (login/KYC): $msg"
section "e2e · report"
info "ABORTED" "login or KYC blocked further e2e — not retrying"
info "hint" "remote e2e needs open bank registration + credit path; set E2E_BANK_ADMIN_PASS / E2E_MERCHANT_TOKEN if you have them"
e2e_finish 1
exit 1
}
is_auth_kyc_body() {
# stdin or file arg: true if looks like login/KYC barrier
local f="${1:-}"
local t
if [ -n "$f" ] && [ -f "$f" ]; then t=$(head -c 800 "$f" 2>/dev/null || true)
else t=$(cat 2>/dev/null || true); fi
printf '%s' "$t" | grep -qiE 'kyc|legitim|login required|unauthorized|forbidden|captcha|challenge|tan_|not.?allowed|registration.?disabled|admin.?only|permission.?denied|401|403'
}
WALLET_CLI=$(find_wallet_cli) || {
blocker "prereq" "taler-wallet-cli not found (set WALLET_CLI=)"
exit 1
}
ok "wallet-cli ($WALLET_CLI)"
info "e2e mode" "$([ "$E2E_REMOTE" = "1" ] && echo "remote/public domain (no SSH)" || echo "local koopa stack")"
info "currency" "$CUR"
info "ATM withdraw ladder" "$WITHDRAW_LIST (credit $CREDIT_AMT)"
info "pay ladder" "$PAY_LIST"
info "e2e budget" "${E2E_TIMEOUT}s"
# Local stack: koopa secrets. Remote: only explicit env (never leak local passwords to foreign banks).
if [ "$E2E_REMOTE" = "1" ]; then
ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-}"
MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}"
else
ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-$(read_secret "taler-bank/bank-admin-password.txt" || true)}"
MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-$(read_secret "taler-merchant/merchant-goa-demo-cp4zqk-password.txt" || true)}}"
fi
if [ -n "$ADMIN_PASS" ]; then
ok "bank admin secret"
elif [ "$E2E_REMOTE" = "1" ]; then
warn "bank admin secret" "missing — will try public registration only"
else
blocker "prereq" "bank admin password missing (SECRETS_ROOT or koopa /root)"
exit 1
fi
if [ -n "$MPW" ]; then
ok "merchant secret (instance ${MERCHANT_INSTANCE})"
elif [ "$E2E_REMOTE" = "1" ]; then
warn "merchant secret" "missing — order create may fail (set E2E_MERCHANT_TOKEN)"
else
blocker "prereq" "merchant instance password missing"
exit 1
fi
# Public reachability gates (remote: soft-skip if bank/merchant absent)
for pair in \
"bank|$BANK_PUBLIC/config" \
"exchange|$EXCHANGE_PUBLIC/config" \
"exchange-keys|$EXCHANGE_PUBLIC/keys" \
"bank-integration|$BANK_PUBLIC/taler-integration/config" \
"merchant|$MERCHANT_PUBLIC/config"
do
IFS='|' read -r name url <<<"$pair"
code=$(http_code "$url")
if [ "$code" = "200" ]; then
ok "reachable $name"
else
if [ "$E2E_REMOTE" = "1" ] && [[ "$name" == bank* || "$name" == merchant ]]; then
warn "reachable $name" "HTTP $code — e2e may abort"
else
blocker "prereq" "$name HTTP $code ($url) — cannot start payment cycle"
fi
fi
done
# Remote: need at least bank+exchange+merchant for a full cycle
if [ "$E2E_REMOTE" = "1" ]; then
bc=$(http_code "$BANK_PUBLIC/config")
ec=$(http_code "$EXCHANGE_PUBLIC/config")
mc=$(http_code "$MERCHANT_PUBLIC/config")
if [ "$bc" != "200" ] || [ "$ec" != "200" ]; then
e2e_abort_auth "prereq" "bank/exchange not publicly usable (bank=$bc exchange=$ec) — skip e2e"
fi
if [ "$mc" != "200" ]; then
warn "prereq" "merchant HTTP $mc — will try withdraw only if possible"
fi
fi
if [ "${#BLOCKERS[@]}" -gt 0 ]; then
exit 1
fi
USER="mon$(date +%s | tail -c 6)"
USER_PW=$(python3 -c 'import secrets;print(secrets.token_urlsafe(10))')
info "e2e user" "$USER withdraw=$WITHDRAW_AMT pay=$PAY_AMT"
BANK="$BANK_PUBLIC"
INST="$MERCHANT_INSTANCE"
# $1 = max seconds for this call (optional); rest = wallet-cli args
wcli() {
local maxc=12
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
maxc=$1
shift
fi
e2e_over && return 124
local cap
cap=$(e2e_left)
[ "$cap" -gt "$maxc" ] && cap=$maxc
[ "$cap" -lt 3 ] && return 124
if command -v perl >/dev/null 2>&1; then
perl -e 'alarm shift; exec @ARGV' "$cap" \
node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"
else
node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"
fi
}
# Pay must not be starved: use reserved seconds even if global budget is tight
wcli_pay() {
local cap=$E2E_PAY_SECS
local left
left=$(e2e_left)
if [ "$left" -gt "$cap" ]; then
cap=$E2E_PAY_SECS
elif [ "$left" -ge 8 ]; then
cap=$left
else
# still try once with floor 12s so Alarm clock is not the blocker
cap=12
fi
info "pay wallet cap" "${cap}s"
if command -v perl >/dev/null 2>&1; then
perl -e 'alarm shift; exec @ARGV' "$cap" \
node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"
else
node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"
fi
}
# When coins never become available: run inside + targeted bank/exchange dig
dig_when_no_coins() {
section "e2e · coins missing — inside dig"
info "reason" "wallet empty after withdraw path (often wirewatch timing) — probing bank + exchange"
if [ -n "${WID:-}" ]; then
curl -sS -m 6 -o "$SCRATCH/wd-dig.json" \
"$BANK_PUBLIC/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || true
info "backend withdrawal-operation" "$(python3 -c '
import json
try:
d=json.load(open("'"$SCRATCH"'/wd-dig.json"))
print("status=%s transfer_done=%s selection_done=%s amount=%s reserve=%s" % (
d.get("status"), d.get("transfer_done"), d.get("selection_done"),
d.get("amount"), (d.get("selected_reserve_pub") or "-")[:24]))
except Exception as e:
print("unreadable", e)
' 2>/dev/null)"
fi
# Full component inside report (capped SSH; ignore its exit)
if [ "${SKIP_SSH}" != "1" ] && [ -x "$ROOT/check_inside.sh" ]; then
info "inside" "running check_inside.sh …"
# subshell so its PASS_N/FAIL_N/summary don't poison ours; still print to stdout
(
# shellcheck disable=SC2030
"$ROOT/check_inside.sh" || true
) || true
else
warn "inside" "skipped (SKIP_SSH=1 or check_inside.sh missing)"
fi
# Extra targeted: wirewatch + recent journal + bank→exchange path
if koopa_ssh_ok; then
section "e2e · coins missing — exchange/bank focus"
DIG=$(koopa_ssh_bash 15 <<'REMOTE' || true
set +e
emit() { printf 'D|%s|%s\n' "$1" "$(printf '%s' "$2" | tr '\n' ' ' | head -c 220)"; }
EX=$(podman ps --format '{{.Names}}' | grep -i exchange | head -1)
BANK=$(podman ps --format '{{.Names}}' | grep -iE 'hacktivism-bank|taler-bank' | head -1)
[ -z "$BANK" ] && BANK=$(podman ps --format '{{.Names}}' | grep -i bank | head -1)
if [ -n "$EX" ]; then
podman exec "$EX" pgrep -af taler-exchange-wirewatch 2>/dev/null | head -1 | \
{ read -r l; [ -n "$l" ] && emit OK "wirewatch: $l" || emit ERROR "wirewatch not running"; }
podman exec "$EX" pgrep -af taler-exchange-transfer 2>/dev/null | head -1 | \
{ read -r l; [ -n "$l" ] && emit OK "transfer: $l" || emit WARN "transfer not running"; }
podman exec "$EX" pgrep -af taler-exchange-aggregator 2>/dev/null | head -1 | \
{ read -r l; [ -n "$l" ] && emit OK "aggregator: $l" || emit WARN "aggregator not running"; }
# last wirewatch log lines if journal available
j=$(podman exec "$EX" bash -c 'journalctl -u "taler-exchange-wirewatch*" -n 8 --no-pager 2>/dev/null | tail -5' 2>/dev/null | tr '\n' ';' | head -c 280)
[ -n "$j" ] && emit INFO "wirewatch journal: $j"
# reserve lookup via exchange if tools exist (best-effort)
c=$(curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:9011/keys 2>/dev/null || echo 000)
emit INFO "exchange /keys HTTP $c"
else
emit ERROR "no exchange container"
fi
if [ -n "$BANK" ]; then
podman exec "$BANK" pgrep -af 'MainKt serve|libeufin-bank serve' 2>/dev/null | head -1 | \
{ read -r l; [ -n "$l" ] && emit OK "libeufin: $l" || emit ERROR "libeufin not running"; }
c=$(curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:9012/taler-integration/config 2>/dev/null || echo 000)
emit INFO "bank integration /config HTTP $c"
else
emit ERROR "no bank container"
fi
REMOTE
)
while IFS= read -r line; do
case "$line" in
D\|*)
IFS="|" read -r _ lvl msg <<<"$line"
case "$lvl" in
OK) ok "dig $msg" ;;
ERROR) err "dig" "$msg" ;;
WARN) warn "dig" "$msg" ;;
INFO) info "dig" "$msg" ;;
esac
;;
esac
done <<<"$DIG"
fi
}
# Baseline wallet balance (empty DB) — always shown even if we abort later
wcli_bal_snap "$SCRATCH/bal-before.out" || true
BAL_BEFORE=$(fmt_bal "$SCRATCH/bal-before.out")
info "BALANCE before" "$BAL_BEFORE"
# ---------------------------------------------------------------------------
section "e2e · bank (account · credit · withdraw)"
# ---------------------------------------------------------------------------
AT=""
if [ -n "${ADMIN_PASS:-}" ]; then
AT=$(curl -sS -m 15 -u "admin:${ADMIN_PASS}" -H 'Content-Type: application/json' \
-d '{"scope":"readwrite"}' \
"$BANK/accounts/admin/token" 2>"$SCRATCH/at.err" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || true)
fi
if [ -n "$AT" ]; then
ok "admin token"
elif [ "$E2E_REMOTE" = "1" ]; then
warn "admin token" "unavailable — trying open registration"
else
if is_auth_kyc_body "$SCRATCH/at.err"; then
e2e_abort_auth "bank-auth" "admin login blocked (KYC/auth) — $(head -c 80 "$SCRATCH/at.err" 2>/dev/null)"
fi
blocker "bank-auth" "admin token failed — check admin password / bank up ($(head -c 80 "$SCRATCH/at.err" 2>/dev/null))"
exit 1
fi
# Create account: with admin bearer if we have it, else unauthenticated registration (demo banks)
if [ -n "$AT" ]; then
code=$(curl -sS -m 15 -o "$SCRATCH/acc.json" -w '%{http_code}' -X POST \
-H "Authorization: Bearer $AT" -H 'Content-Type: application/json' \
-d "{\"username\":\"${USER}\",\"password\":\"${USER_PW}\",\"name\":\"Monitoring\",\"is_public\":false,\"is_taler_exchange\":false,\"debit_threshold\":\"${CUR}:1000\"}" \
"$BANK/accounts")
else
code=$(curl -sS -m 15 -o "$SCRATCH/acc.json" -w '%{http_code}' -X POST \
-H 'Content-Type: application/json' \
-d "{\"username\":\"${USER}\",\"password\":\"${USER_PW}\",\"name\":\"Monitoring\",\"is_public\":false,\"is_taler_exchange\":false,\"debit_threshold\":\"${CUR}:1000\"}" \
"$BANK/accounts")
fi
if [ "$code" = "200" ] || [ "$code" = "201" ]; then
ok "create account $USER"
elif [ "$code" = "401" ] || [ "$code" = "403" ] || is_auth_kyc_body "$SCRATCH/acc.json"; then
e2e_abort_auth "bank-account" "registration/login blocked HTTP $code$(head -c 100 "$SCRATCH/acc.json" | tr '\n' ' ')"
else
if [ "$E2E_REMOTE" = "1" ]; then
e2e_abort_auth "bank-account" "POST /accounts HTTP $code — cannot open account on this domain"
fi
blocker "bank-account" "POST /accounts HTTP $code$(head -c 100 "$SCRATCH/acc.json" | tr '\n' ' ')"
exit 1
fi
if [ -n "$AT" ]; then
export USER_NAME="$USER" CREDIT_AMT="$CREDIT_AMT" OUT="$SCRATCH/credit.json" BANK_HOST="$BANK_HOST"
python3 - <<'PY'
import json, os
ALPH = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
def crock32(b):
bits = val = 0; o = []
for byte in b:
val = (val << 8) | byte; bits += 8
while bits >= 5:
bits -= 5; o.append(ALPH[(val >> bits) & 31])
if bits: o.append(ALPH[(val << (5 - bits)) & 31])
return "".join(o)
uid = crock32(os.urandom(32))
user = os.environ["USER_NAME"]
host = os.environ.get("BANK_HOST") or "bank"
json.dump({
"payto_uri": f"payto://x-taler-bank/{host}/{user}?receiver-name={user}&message=mon",
"amount": os.environ["CREDIT_AMT"],
"request_uid": uid,
}, open(os.environ["OUT"], "w"))
PY
curl -sS -m 15 -o "$SCRATCH/credit.out" -H "Authorization: Bearer $AT" \
-H 'Content-Type: application/json' -d @"$SCRATCH/credit.json" \
"$BANK/accounts/admin/transactions" >/dev/null || true
if grep -q row_id "$SCRATCH/credit.out" 2>/dev/null; then
ok "credit $CREDIT_AMT$USER"
else
if is_auth_kyc_body "$SCRATCH/credit.out"; then
e2e_abort_auth "bank-credit" "admin credit blocked (KYC/auth)"
fi
if [ "$E2E_REMOTE" = "1" ]; then
e2e_abort_auth "bank-credit" "admin transfer failed — no credit path on remote ($(head -c 80 "$SCRATCH/credit.out" | tr '\n' ' '))"
fi
blocker "bank-credit" "admin transfer failed — $(head -c 100 "$SCRATCH/credit.out" | tr '\n' ' ')"
exit 1
fi
else
# No admin: remote demo may start funded or require cash-in — try withdraw later; warn
warn "bank-credit" "skipped (no admin) — withdraw may fail without balance"
fi
UT=$(curl -sS -m 15 -u "${USER}:${USER_PW}" -H 'Content-Type: application/json' \
-d '{"scope":"readwrite"}' \
"$BANK/accounts/${USER}/token" 2>"$SCRATCH/ut.err" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || true)
if [ -n "$UT" ]; then
ok "user token"
elif is_auth_kyc_body "$SCRATCH/ut.err" || is_auth_kyc_body /dev/null; then
e2e_abort_auth "bank-auth" "user token failed (login/KYC) for $USER"
else
e2e_abort_auth "bank-auth" "user token failed for $USER$(head -c 80 "$SCRATCH/ut.err" 2>/dev/null)"
fi
# ---------------------------------------------------------------------------
section "e2e · wallet setup (exchange + ToS once)"
# ---------------------------------------------------------------------------
if ! wcli exchanges add "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-add.out" 2>&1; then
if ! grep -qiE 'already|ok' "$SCRATCH/ex-add.out"; then
blocker "wallet-exchange" "exchanges add failed — $(tail -c 160 "$SCRATCH/ex-add.out" | tr '\n' ' ')"
else
ok "wallet exchange known"
fi
else
ok "wallet add exchange"
fi
wcli exchanges update "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-up.out" 2>&1 || true
if wcli exchanges accept-tos "${EXCHANGE_PUBLIC}/" >"$SCRATCH/ex-tos.out" 2>&1; then
ok "wallet accept ToS"
else
if grep -qiE 'pending|tos|terms' "$SCRATCH/ex-tos.out"; then
blocker "wallet-tos" "accept-tos failed — wallet may refuse withdraw ($(tail -c 120 "$SCRATCH/ex-tos.out" | tr '\n' ' '))"
else
warn "wallet accept ToS" "$(tail -c 80 "$SCRATCH/ex-tos.out" | tr '\n' ' ')"
fi
fi
wd_status() {
curl -sS -m 8 -o "$SCRATCH/wd-st.json" -w '' \
"$BANK/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || true
python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("status",""))' 2>/dev/null || true
}
wd_status_detail() {
python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"))
print("status=%s reserve=%s exchange=%s" % (
d.get("status"),
(d.get("selected_reserve_pub") or "-")[:20],
(d.get("selected_exchange") or d.get("selected_exchange_account") or "-")[:60],
))' 2>/dev/null || echo "(no status body)"
}
fake_incoming_speedup() {
[ "${E2E_FAKE_INCOMING}" = "1" ] || return 0
st=$(wd_status)
RPUB=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("selected_reserve_pub") or "")' 2>/dev/null || true)
AMT=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("amount") or "'"$WITHDRAW_AMT"'")' 2>/dev/null || echo "$WITHDRAW_AMT")
DEBIT=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-st.json"));print(d.get("sender_wire") or "")' 2>/dev/null || true)
[ -z "$DEBIT" ] && DEBIT="payto://x-taler-bank/${BANK_HOST}/${USER}?receiver-name=${USER}"
[ -z "$RPUB" ] && return 0
[ -z "${AT:-}" ] && return 0
WG="${BANK}/accounts/exchange/taler-wire-gateway/admin/add-incoming"
curl -sS -m 10 -o "$SCRATCH/fake-in.json" -w '%{http_code}' -X POST \
-H "Authorization: Bearer $AT" -H 'Content-Type: application/json' \
-d "{\"amount\":\"${AMT}\",\"reserve_pub\":\"${RPUB}\",\"debit_account\":\"${DEBIT}\"}" \
"$WG" >/dev/null || true
if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then
koopa_ssh_run 22 \
'podman exec taler-hacktivism-exchange-ansible bash -c "runuser -u taler-exchange-wire -- timeout 15 taler-exchange-wirewatch -c /etc/taler-exchange/taler-exchange.conf -t -L INFO 2>&1 | tail -5"' \
>/dev/null 2>&1 || true
fi
}
# One ATM-style withdraw: create → accept → select → confirm → settle
# returns 0 on wallet balance increase, 1 on soft fail (ladder continues)
e2e_one_withdraw() {
WITHDRAW_AMT="$1"
local tag
tag=$(printf '%s' "$WITHDRAW_AMT" | tr '.:' '__')
section "e2e · ATM withdraw $WITHDRAW_AMT"
e2e_over && { warn "ATM withdraw" "budget exhausted — skip $WITHDRAW_AMT"; return 1; }
curl -sS -m 15 -o "$SCRATCH/wd-$tag.json" -H "Authorization: Bearer $UT" \
-H 'Content-Type: application/json' \
-d "{\"amount\":\"${WITHDRAW_AMT}\",\"exchange_url\":\"${EXCHANGE_PUBLIC}/\"}" \
"$BANK/accounts/${USER}/withdrawals"
WID=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json"));print(d.get("withdrawal_id") or d.get("id") or "")' 2>/dev/null || true)
URI=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json"));print(d.get("taler_withdraw_uri") or "")' 2>/dev/null || true)
URI_CLEAN=$(python3 -c 'import json;u=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json")).get("taler_withdraw_uri") or "";print(u.replace(":443/","/"))' 2>/dev/null || true)
if [ -z "$WID" ] || [ -z "$URI_CLEAN" ]; then
warn "ATM withdraw $WITHDRAW_AMT" "create failed — $(head -c 80 "$SCRATCH/wd-$tag.json" | tr '\n' ' ')"
return 1
fi
ok "ATM withdrawal created $WITHDRAW_AMT ($WID)"
cp "$SCRATCH/wd-$tag.json" "$SCRATCH/wd.json"
USE_URI="$URI"
[ -z "$USE_URI" ] && USE_URI="$URI_CLEAN"
if wcli withdraw accept-uri --exchange "${EXCHANGE_PUBLIC}/" "$USE_URI" >"$SCRATCH/accept-$tag.out" 2>&1; then
ok "wallet accept $WITHDRAW_AMT"
elif [ "$USE_URI" != "$URI_CLEAN" ] && [ -n "$URI_CLEAN" ] \
&& wcli withdraw accept-uri --exchange "${EXCHANGE_PUBLIC}/" "$URI_CLEAN" >"$SCRATCH/accept-$tag.out" 2>&1; then
ok "wallet accept $WITHDRAW_AMT (no :443)"
else
warn "ATM withdraw $WITHDRAW_AMT" "accept-uri failed — $(tail -c 100 "$SCRATCH/accept-$tag.out" | tr '\n' ' ')"
return 1
fi
cp "$SCRATCH/accept-$tag.out" "$SCRATCH/accept.out"
st=""
for i in 1 2 3 4; do
e2e_over && break
wcli run-until-done >"$SCRATCH/sel-$tag-$i.out" 2>&1 || true
st=$(wd_status)
case "$st" in selected|confirmed|aborted) break ;; esac
done
if [ "$st" != "selected" ] && [ "$st" != "confirmed" ]; then
wcli transactions >"$SCRATCH/tx-pre.json" 2>&1 || true
RPUB=$(python3 -c '
import re
t=open("'"$SCRATCH"'/accept.out").read()+open("'"$SCRATCH"'/tx-pre.json").read()
m=re.search(r"reserve[_ ]?pub[\"=: ]+([A-Z0-9]{40,})", t, re.I)
if not m: m=re.search(r"\"reservePub\"\s*:\s*\"([^\"]+)\"", t)
print(m.group(1) if m else "")
' 2>/dev/null || true)
EPAYTO=$(curl -sS -m 12 "$EXCHANGE_PUBLIC/keys" 2>/dev/null | python3 -c '
import json,sys
d=json.load(sys.stdin)
acc=d.get("accounts") or []
for a in acc:
p=a.get("payto_uri") or a.get("payto_address") or ""
if "x-taler-bank" in p or "exchange" in p:
print(p); break
else:
if acc: print(acc[0].get("payto_uri") or "")
' 2>/dev/null || true)
if [ -n "$RPUB" ] && [ -n "$EPAYTO" ]; then
curl -sS -m 12 -o "$SCRATCH/sel.json" -X POST \
-H 'Content-Type: application/json' \
-d "{\"reserve_pub\":\"$RPUB\",\"selected_exchange\":\"$EPAYTO\"}" \
"$BANK/taler-integration/withdrawal-operation/${WID}" >/dev/null || true
st=$(wd_status)
fi
fi
if [ "$st" != "selected" ] && [ "$st" != "confirmed" ]; then
warn "ATM withdraw $WITHDRAW_AMT" "not selected (status=${st:-?})"
return 1
fi
if [ "$st" != "confirmed" ]; then
code=$(curl -sS -m 15 -o "$SCRATCH/conf-$tag.json" -w '%{http_code}' -X POST \
-H "Authorization: Bearer $UT" -H 'Content-Type: application/json' -d '{}' \
"$BANK/accounts/${USER}/withdrawals/${WID}/confirm")
case "$code" in
200|204) ok "bank confirm $WITHDRAW_AMT" ;;
*) warn "ATM withdraw $WITHDRAW_AMT" "confirm HTTP $code"; return 1 ;;
esac
fi
fake_incoming_speedup
# Short per-ATM poll; full settle wait happens after the ladder (avoids false FAIL)
local ok_bal=0 r av
for r in 1 2 3 4 5 6; do
wcli run-until-done >"$SCRATCH/run-$tag-$r.out" 2>&1 || true
wcli_bal_snap "$SCRATCH/bal-$tag.out" || wcli balance >"$SCRATCH/bal-$tag.out" 2>&1 || true
av=$(wallet_avail_num "$SCRATCH/bal-$tag.out")
if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > 0 else 1)" "$av" 2>/dev/null; then
ok_bal=1
ok "wallet funded after ATM $WITHDRAW_AMT" "avail=${CUR}:${av}"
info "balance" "$(fmt_bal "$SCRATCH/bal-$tag.out")"
break
fi
sleep 2
done
if [ "$ok_bal" = "1" ]; then
return 0
fi
# Bank side often already confirmed — treat as timing lag, not hard fail
st=$(wd_status)
if [ "$st" = "confirmed" ]; then
warn "ATM withdraw $WITHDRAW_AMT" "bank confirmed; wallet not funded yet (settlement timing — will recheck later)"
return 2
fi
warn "ATM withdraw $WITHDRAW_AMT" "no wallet balance yet (status=${st:-?})"
return 1
}
# One variable payment amount
e2e_one_pay() {
PAY_AMT="$1"
local tag
tag=$(printf '%s' "$PAY_AMT" | tr '.:' '__')
section "e2e · pay $PAY_AMT"
e2e_over && { warn "pay" "budget exhausted — skip $PAY_AMT"; return 1; }
if [ -z "${MPW:-}" ]; then
warn "pay $PAY_AMT" "no merchant token"
return 1
fi
AUTH="Authorization: Bearer secret-token:${MPW}"
curl -skS -m 15 -o "$SCRATCH/ord-$tag.json" -X POST \
-H "$AUTH" -H 'Content-Type: application/json' \
-d "{\"order\":{\"summary\":\"monitoring pay ${PAY_AMT}\",\"amount\":\"${PAY_AMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}" \
"${MERCHANT_PUBLIC}/instances/${INST}/private/orders" 2>"$SCRATCH/ord-$tag.err" || true
if ! grep -q order_id "$SCRATCH/ord-$tag.json" 2>/dev/null; then
if [ "$E2E_REMOTE" != "1" ] && koopa_ssh_ok; then
koopa_ssh_run 20 \
"curl -skS -m 12 -X POST -H 'Authorization: Bearer secret-token:${MPW}' -H 'Content-Type: application/json' -d '{\"order\":{\"summary\":\"monitoring pay\",\"amount\":\"${PAY_AMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}' 'https://127.0.0.1:9010/instances/${INST}/private/orders'" \
>"$SCRATCH/ord-$tag.json" 2>"$SCRATCH/ord-$tag.err" || true
fi
fi
OID=$(python3 -c 'import json,re,sys;t=open(sys.argv[1]).read()
try: print(json.loads(t).get("order_id") or "")
except Exception:
m=re.search(r"\"order_id\"\s*:\s*\"([^\"]+)\"",t); print(m.group(1) if m else "")
' "$SCRATCH/ord-$tag.json" 2>/dev/null || true)
OTOK=$(python3 -c 'import json,re,sys;t=open(sys.argv[1]).read()
try: print(json.loads(t).get("token") or "")
except Exception:
m=re.search(r"\"token\"\s*:\s*\"([^\"]+)\"",t); print(m.group(1) if m else "")
' "$SCRATCH/ord-$tag.json" 2>/dev/null || true)
if [ -z "$OID" ]; then
if is_auth_kyc_body "$SCRATCH/ord-$tag.json"; then
e2e_abort_auth "merchant-order" "merchant auth/KYC on pay $PAY_AMT"
fi
warn "pay $PAY_AMT" "order create failed — $(head -c 80 "$SCRATCH/ord-$tag.json" | tr '\n' ' ')"
return 1
fi
ok "merchant order $OID ($PAY_AMT)"
curl -skS -m 12 -o "$SCRATCH/ord-det-$tag.json" -H "$AUTH" \
"${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true
PAYURI=$(python3 -c 'import json,sys
try: print(json.load(open(sys.argv[1])).get("taler_pay_uri") or "")
except Exception: print("")
' "$SCRATCH/ord-det-$tag.json" 2>/dev/null || true)
if [ -z "$PAYURI" ] && [ -n "$OTOK" ]; then
MH=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$MERCHANT_PUBLIC"'").hostname or "taler.hacktivism.ch")' 2>/dev/null || echo "taler.hacktivism.ch")
PAYURI="taler://pay/${MH}/instances/${INST}/${OID}/?c=${OTOK}"
fi
if [ -z "$PAYURI" ]; then
warn "pay $PAY_AMT" "no pay URI for $OID"
return 1
fi
if ! wcli_pay handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$tag.out" 2>&1; then
warn "pay $PAY_AMT" "handle-uri failed — $(tail -c 100 "$SCRATCH/pay-$tag.out" | tr '\n' ' ')"
return 1
fi
ok "wallet handle pay $PAY_AMT"
local r
for r in 1 2 3; do
wcli_pay run-until-done >"$SCRATCH/pay-run-$tag.out" 2>&1 || true
wcli_pay transactions >"$SCRATCH/tx-$tag.out" 2>&1 || true
curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \
"${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true
if grep -qiE 'payment|paid|Payment' "$SCRATCH/tx-$tag.out" 2>/dev/null \
|| grep -qiE 'done|paid|success|Payment' "$SCRATCH/pay-$tag.out" 2>/dev/null \
|| python3 -c 'import json,sys
d=json.load(open(sys.argv[1]))
sys.exit(0 if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid" else 1)
' "$SCRATCH/ord-paid-$tag.json" 2>/dev/null; then
ok "payment settled $PAY_AMT (order $OID)"
return 0
fi
done
warn "pay $PAY_AMT" "not settled for order $OID"
return 1
}
# ---------------------------------------------------------------------------
section "e2e · ATM withdraw ladder"
# ---------------------------------------------------------------------------
WITHDRAW_OK=0
WITHDRAW_OK_N=0
WITHDRAW_LAG_N=0
WITHDRAW_FAIL_N=0
WITHDRAW_REPORT=""
for WITHDRAW_AMT in $WITHDRAW_LIST; do
e2e_over && { warn "ATM ladder" "time budget low — stopping more ATM withdraws (not a protocol error)"; break; }
set +e
e2e_one_withdraw "$WITHDRAW_AMT"
wc=$?
set -e
case "$wc" in
0)
WITHDRAW_OK=1
WITHDRAW_OK_N=$((WITHDRAW_OK_N + 1))
WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=OK"
;;
2)
WITHDRAW_LAG_N=$((WITHDRAW_LAG_N + 1))
WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=LAG"
;;
*)
WITHDRAW_FAIL_N=$((WITHDRAW_FAIL_N + 1))
WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${WITHDRAW_AMT}=FAIL"
;;
esac
done
info "ATM withdraw summary" "$WITHDRAW_REPORT (ok=$WITHDRAW_OK_N lag=$WITHDRAW_LAG_N fail=$WITHDRAW_FAIL_N)"
# Settlement catch-up: bank may have confirmed while wallet was still empty
section "e2e · wallet settlement (timing)"
if ! wait_wallet_balance 0 "${E2E_SETTLE_ROUNDS}" "${E2E_SETTLE_SLEEP}"; then
if [ "$WITHDRAW_LAG_N" -gt 0 ] || [ "$WITHDRAW_OK_N" -gt 0 ]; then
warn "settlement timing" "ATM path reached bank confirm (lag=$WITHDRAW_LAG_N ok=$WITHDRAW_OK_N) but wallet empty after wait — TIME lag, not protocol error"
fi
fi
av_now=$(wallet_avail_num)
if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > 0 else 1)" "$av_now" 2>/dev/null; then
WITHDRAW_OK=1
if [ "$WITHDRAW_OK_N" = "0" ]; then
warn "settlement timing" "coins arrived after ATM ladder (${CUR}:${av_now}) — earlier 'FAIL' was timing lag"
WITHDRAW_REPORT="${WITHDRAW_REPORT} → late-OK avail=${CUR}:${av_now}"
fi
ok "spendable balance for payments" "${CUR}:${av_now}"
else
if [ "$WITHDRAW_OK" != "1" ]; then
warn "withdraw" "still no spendable ${CUR} after settle wait"
# diagnostic dig only — do not treat as hard stop if we can still see bank state
if [ "$E2E_REMOTE" != "1" ]; then
dig_when_no_coins || true
fi
# one last balance after dig (dig takes wall time — often enough for wirewatch)
wait_wallet_balance 0 8 3 || true
av_now=$(wallet_avail_num)
if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > 0 else 1)" "$av_now" 2>/dev/null; then
WITHDRAW_OK=1
warn "settlement timing" "coins present after dig wait (${CUR}:${av_now}) — timing, not blocker"
else
blocker "withdraw-settle" "no spendable ${CUR} after ATM ladder + settle wait ($WITHDRAW_LIST)"
section "e2e · report"
info "WITHDRAW" "$WITHDRAW_REPORT — no coins after extended wait"
info "PAY" "SKIPPED (no spendable balance after settle wait)"
exit 1
fi
fi
fi
# ---------------------------------------------------------------------------
section "e2e · variable payments (if balance allows)"
# ---------------------------------------------------------------------------
PAY_OK=0
PAY_OK_N=0
PAY_FAIL_N=0
PAY_SKIP_N=0
PAY_REPORT=""
if [ -z "${MPW:-}" ]; then
if [ "$E2E_REMOTE" = "1" ]; then
e2e_abort_auth "merchant-order" "no merchant token — set E2E_MERCHANT_TOKEN"
fi
blocker "merchant-order" "no merchant token"
exit 1
fi
for PAY_AMT in $PAY_LIST; do
e2e_over && { warn "pay ladder" "time budget low — stopping more payments"; break; }
av_now=$(wallet_avail_num)
pay_n=$(python3 -c 'import sys; print(float(sys.argv[1].split(":",1)[-1]))' "$PAY_AMT" 2>/dev/null || echo 0)
if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-12 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_n" 2>/dev/null; then
warn "pay $PAY_AMT" "skip — insufficient avail ${CUR}:${av_now} (need ${pay_n})"
PAY_SKIP_N=$((PAY_SKIP_N + 1))
PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=SKIP(bal)"
continue
fi
set +e
e2e_one_pay "$PAY_AMT"
pc=$?
set -e
if [ "$pc" = "0" ]; then
PAY_OK=1
PAY_OK_N=$((PAY_OK_N + 1))
PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=OK"
else
PAY_FAIL_N=$((PAY_FAIL_N + 1))
PAY_REPORT="${PAY_REPORT}${PAY_REPORT:+ }${PAY_AMT}=FAIL"
fi
done
info "pay summary" "$PAY_REPORT (ok=$PAY_OK_N fail=$PAY_FAIL_N skip=$PAY_SKIP_N)"
if [ "$PAY_OK" != "1" ]; then
av_now=$(wallet_avail_num)
if python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) > 0 else 1)" "$av_now" 2>/dev/null; then
warn "pay" "balance ${CUR}:${av_now} but no pay amount completed — check merchant/timing (not necessarily empty wallet)"
else
blocker "pay-settle" "no payment succeeded and no spendable balance ($PAY_LIST)"
fi
fi
section "e2e · report"
info "user" "$USER"
info "ATM withdraws" "$WITHDRAW_REPORT"
info "payments" "$PAY_REPORT"
info "final balance" "$(fmt_bal "$SCRATCH/bal-live.out" 2>/dev/null || echo "(n/a)")"
info "scratch" "$SCRATCH"
# Success if we had coins and at least one pay, OR coins + only pay skips (nothing affordable)
if [ "${#BLOCKERS[@]}" -eq 0 ]; then
if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK" = "1" ]; then
exit 0
fi
if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK_N" = "0" ] && [ "$PAY_FAIL_N" = "0" ]; then
warn "pay" "no payment tried/completed — withdraw OK"
exit 0
fi
if [ "$WITHDRAW_OK" = "1" ] && [ "$PAY_OK" != "1" ]; then
# money there, pays failed → soft exit 1 without inventing blockers if already warned
exit 1
fi
fi
exit 1