taler-monitoring/check_e2e.sh
Hernâni Marques 5a5431bfa9
taler-monitoring v1.13.12
Multi-phase global SUMMARY, warn-filter dimmed context, SUMMARY chrome
as meta; FP stage host-agent via francpaysan-stage-user (stagepaysan);
monpages GOA + FP stage.
2026-07-19 04:22:10 +02:00

1710 lines
70 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"
# shellcheck source=metrics.sh
source "$ROOT/metrics.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:-}" ]; then
echo "(no wallet)" >"$out"
return 1
fi
# one-shot needs DB file; serve mode needs socket
if [ -z "${WSERVE_PID:-}" ] || [ ! -S "${WSOCK:-}" ]; then
if [ ! -f "${WDB:-}" ]; then
echo "(no wallet)" >"$out"
return 1
fi
fi
local err="${out}.err"
if command -v perl >/dev/null 2>&1; then
if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then
perl -e 'alarm shift; exec @ARGV' 10 \
node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle balance \
>"$out" 2>"$err" || true
else
perl -e 'alarm shift; exec @ARGV' 10 \
node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \
>"$out" 2>"$err" || true
fi
else
if [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then
node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle balance \
>"$out" 2>"$err" || true
else
node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults balance \
>"$out" 2>"$err" || true
fi
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
# Poll balance only — never run-until-done (hangs; banned in monitoring).
info "wallet settle wait" "up to ${rounds}×${sleep_s}s for avail>${min_n} ${CUR} (poll balance only; no run-until-done)"
for r in $(seq 1 "$rounds"); do
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
# Final coin inventory + host/container load + statistics dashboard
if [ -n "${METRICS_DIR:-}" ]; then
section "metrics · e2e coins final"
metrics_report_coins "e2e-end" || true
if [ "${METRICS_LOAD:-1}" != "0" ]; then
metrics_report_load "${METRICS_DIR}/load-after.json" "e2e-end" || true
fi
metrics_print_overall "e2e final" || true
fi
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
set_group prereq
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:=5}"
if [ "$E2E_REMOTE" = "1" ]; then
# remote: still ATM-shaped; TESTPAYSAN needs larger notes for shop pays
if [ "$CUR" = "TESTPAYSAN" ]; then
: "${E2E_WITHDRAW_VALUES:=20 50}"
# farmer shop template face values (oeufs-6=5, fromage=8.5, jus=6)
: "${E2E_PAY_VALUES:=5 8.5 6}"
else
: "${E2E_WITHDRAW_VALUES:=10 20 50}"
: "${E2E_PAY_VALUES:=0.01 0.05 0.1 1}"
fi
else
# local GOA: classic ATM notes + 4200 for paivana paywall template
: "${E2E_WITHDRAW_VALUES:=20 50 100 200 4200}"
: "${E2E_PAY_VALUES:=0.01 0.05 0.1 0.5 1 2 5 10}"
fi
# Public template pays (landing/shop style) — default on for TESTPAYSAN stage
if [ -z "${E2E_USE_TEMPLATES:-}" ]; then
if [ "$CUR" = "TESTPAYSAN" ]; then E2E_USE_TEMPLATES=1; else E2E_USE_TEMPLATES=0; fi
fi
: "${E2E_USE_TEMPLATES:=0}"
# 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=$(( 50 + n_w * 40 + n_p * 20 + 60 )) # + room for paivana pay
if [ "${E2E_TIMEOUT}" -lt "$need" ]; then
E2E_TIMEOUT=$need
fi
# cap ATM attempts but always keep CUR:4200 if listed (paivana)
WITHDRAW_LIST=$(
printf '%s' "$WITHDRAW_LIST" | tr ' ' '\n' | awk -v max="$E2E_ATM_MAX" -v big="${CUR}:4200" '
NF==0 { next }
$0==big { has=1; next }
{ small[++n]=$0 }
END {
keep = max - (has ? 1 : 0)
if (keep < 0) keep = 0
for (i = 1; i <= n && i <= keep; i++) printf "%s ", small[i]
if (has) printf "%s", big
print ""
}' | 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"
WSOCK="$SCRATCH/wallet.sock"
WSERVE_PID=""
# Client/server wallet (advanced serve): long-lived shepherd without run-until-done.
# Default on — wallet-cli only completes withdraw/pay while a serve process is up.
: "${E2E_WALLET_SERVE:=1}"
METRICS_DIR="${METRICS_DIR:-$SCRATCH/metrics}"
mkdir -p "$METRICS_DIR"
export METRICS_DIR CUR="${CUR:-${EXPECT_CURRENCY:-GOA}}" WDB
export PATH="${HOME}/.local/bin:${HOME}/taler/opt/taler-wallet-cli/usr/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
stop_wallet_serve() {
if [ -n "${WSERVE_PID:-}" ] && kill -0 "$WSERVE_PID" 2>/dev/null; then
kill "$WSERVE_PID" 2>/dev/null || true
wait "$WSERVE_PID" 2>/dev/null || true
fi
WSERVE_PID=""
rm -f "${WSOCK:-}" 2>/dev/null || true
}
# Always dump balances on any exit (timeout, blocker, signal, success)
trap 'ec=$?; e2e_finish "$ec"; stop_wallet_serve; 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
}
# Implementation version (--version) + wallet-core API ranges (version command)
WALLET_IMPL_VER=$(
node "$WALLET_CLI" --version 2>/dev/null | head -1 | tr -d '\r' || true
)
WALLET_CORE_VER=$(
_wver_db=$(mktemp "${TMPDIR:-/tmp}/wver.XXXXXX.sqlite3" 2>/dev/null || echo "${TMPDIR:-/tmp}/wver-$$.sqlite3")
node "$WALLET_CLI" --wallet-db="$_wver_db" --no-throttle --skip-defaults version 2>/dev/null \
| python3 -c '
import json,re,sys
raw=sys.stdin.read()
d=None
for m in re.finditer(r"\{", raw):
try:
d=json.loads(raw[m.start():])
if isinstance(d, dict) and "version" in d:
break
except Exception:
d=None
if not isinstance(d, dict):
sys.exit(0)
parts=[]
# wallet-core libversion + protocol ranges used against exchange/merchant/bank
for k in ("version","implementationSemver","exchange","merchant","bank","bankIntegrationApiRange","corebankApiRange"):
if k in d and d[k] is not None:
parts.append("%s=%s" % (k, d[k]))
print(" ".join(parts))
' 2>/dev/null || true
rm -f "$_wver_db" "$_wver_db"-* 2>/dev/null || true
)
ok "wallet-cli" "${WALLET_IMPL_VER:-?} · ${WALLET_CLI}"
if [ -n "${WALLET_CORE_VER:-}" ]; then
info "wallet-core" "$WALLET_CORE_VER"
else
info "wallet-core" "version details unavailable (impl ${WALLET_IMPL_VER:-?})"
fi
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"
# alt_unit_names for human amounts (Kilo-GOA … + base GOA in parentheses)
ALT_UNITS_FILE="${METRICS_DIR}/alt_unit_names.json"
export ALT_UNITS_FILE
if metrics_load_alt_units "${EXCHANGE_PUBLIC}/config"; then
info "alt_unit_names" "from ${EXCHANGE_PUBLIC}/config"
else
warn "alt_unit_names" "using built-in SI fallback"
fi
# Host + bank/exchange/merchant RAM/CPU/load (SSH via KOOPA_SSH from env)
set_group load
section "e2e · load snapshot (before withdraw/pay)"
metrics_report_load "${METRICS_DIR}/load-before.json" "e2e-start" || true
# Local stack: koopa secrets. Remote: env first; TESTPAYSAN stage may load stagepaysan secrets via FRANCPAYSAN_SSH/INSIDE_SSH.
set_group prereq
if [ "$E2E_REMOTE" = "1" ]; then
ADMIN_PASS="${E2E_BANK_ADMIN_PASS:-${BANK_ADMIN_PASS:-}}"
MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}"
# Stage FrancPaysan: allow admin credit without committing passwords.
# Order: env → FRANCPAYSAN_SECRETS → SSH FRANCPAYSAN_SSH/INSIDE_SSH + remote secret paths.
if [ -z "$ADMIN_PASS" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "TESTPAYSAN" ]; then
_fp_try=""
if [ -n "${FRANCPAYSAN_SECRETS:-}" ]; then
_fp_try="${FRANCPAYSAN_SECRETS}/stage/bank-admin-password.txt"
if [ -f "$_fp_try" ]; then
ADMIN_PASS=$(tr -d '\n\r' <"$_fp_try")
info "bank admin secret" "from $_fp_try"
fi
fi
if [ -z "$ADMIN_PASS" ] && command -v ssh >/dev/null 2>&1; then
_remote_bank_sec="${FRANCPAYSAN_REMOTE_BANK_ADMIN_SECRET:-}"
[ -z "$_remote_bank_sec" ] && [ -n "${FRANCPAYSAN_REMOTE_SECRETS_ROOT:-}" ] && \
_remote_bank_sec="${FRANCPAYSAN_REMOTE_SECRETS_ROOT}/bank/secrets/bank-admin-password.txt"
for _fp_host in ${FRANCPAYSAN_SSH:+"$FRANCPAYSAN_SSH"} ${INSIDE_SSH:+"$INSIDE_SSH"}; do
[ -n "$_fp_host" ] && [ -n "$_remote_bank_sec" ] || continue
ADMIN_PASS=$(ssh -o BatchMode=yes -o ConnectTimeout=8 "$_fp_host" \
"sudo cat ${_remote_bank_sec} 2>/dev/null" \
2>/dev/null | tr -d '\n\r' || true)
if [ -n "$ADMIN_PASS" ]; then
info "bank admin secret" "from ${_fp_host} (remote path via env)"
break
fi
done
fi
unset _fp_try _fp_host _remote_bank_sec
fi
# Stage merchant: public templates need no token; optional default-instance token
if [ -z "$MPW" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "TESTPAYSAN" ]; then
if [ -n "${FRANCPAYSAN_SECRETS:-}" ] && [ -f "${FRANCPAYSAN_SECRETS}/stage/default-instance-token.txt" ]; then
MPW=$(tr -d '\n\r' <"${FRANCPAYSAN_SECRETS}/stage/default-instance-token.txt")
fi
if [ -z "$MPW" ] && command -v ssh >/dev/null 2>&1; then
_remote_mer_sec="${FRANCPAYSAN_REMOTE_MERCHANT_TOKEN:-}"
[ -z "$_remote_mer_sec" ] && [ -n "${FRANCPAYSAN_REMOTE_SECRETS_ROOT:-}" ] && \
_remote_mer_sec="${FRANCPAYSAN_REMOTE_SECRETS_ROOT}/merchant/secrets/default-instance-token.txt"
_fp_host="${FRANCPAYSAN_SSH:-${INSIDE_SSH:-}}"
if [ -n "$_fp_host" ] && [ -n "$_remote_mer_sec" ]; then
MPW=$(ssh -o BatchMode=yes -o ConnectTimeout=8 "$_fp_host" \
"sudo cat ${_remote_mer_sec} 2>/dev/null" \
2>/dev/null | tr -d '\n\r' || true)
fi
unset _fp_host _remote_mer_sec
fi
fi
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" "${SECRETS_ROOT:+SECRETS_ROOT=${SECRETS_ROOT}}"
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)"
info "secrets" "SECRETS_ROOT=${SECRETS_ROOT:-'(unset)'}"
if type secrets_hint >/dev/null 2>&1; then
while IFS= read -r _line; do info "secrets-hint" "$_line"; done < <(secrets_hint)
fi
exit 1
fi
if [ -n "$MPW" ]; then
# Secrets often store full "secret-token:…"; strip before re-prefixing Bearer.
MPW=$(printf '%s' "$MPW" | tr -d '\n\r')
case "$MPW" in
secret-token:*) MPW_TOKEN="$MPW" ;;
*) MPW_TOKEN="secret-token:${MPW}" ;;
esac
ok "merchant secret (instance ${MERCHANT_INSTANCE})"
elif [ "$E2E_REMOTE" = "1" ]; then
MPW_TOKEN=""
warn "merchant secret" "missing — private orders fail unless E2E_USE_TEMPLATES=1 (set E2E_MERCHANT_TOKEN)"
else
blocker "prereq" "merchant instance password missing"
info "secrets" "SECRETS_ROOT=${SECRETS_ROOT:-'(unset)'} — need taler-merchant/merchant-*-password.txt"
if type secrets_hint >/dev/null 2>&1; then
while IFS= read -r _line; do info "secrets-hint" "$_line"; done < <(secrets_hint)
fi
exit 1
fi
# Authorization header for merchant private API (never double secret-token:)
merchant_auth_header() {
local t="${1:-${MPW_TOKEN:-}}"
[ -n "$t" ] || return 1
case "$t" in
secret-token:*) printf 'Authorization: Bearer %s' "$t" ;;
*) printf 'Authorization: Bearer secret-token:%s' "$t" ;;
esac
}
# 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"
start_wallet_serve() {
[ "${E2E_WALLET_SERVE}" = "1" ] || return 0
[ -n "${WALLET_CLI:-}" ] && [ -f "${WALLET_CLI}" ] || return 1
stop_wallet_serve
rm -f "$WSOCK"
# serve holds the DB open; clients use --wallet-connection only
node "$WALLET_CLI" --wallet-db="$WDB" --no-throttle --skip-defaults \
advanced serve --unix-path "$WSOCK" \
>"$SCRATCH/wallet-serve.log" 2>&1 &
WSERVE_PID=$!
local i
for i in $(seq 1 40); do
if [ -S "$WSOCK" ] && kill -0 "$WSERVE_PID" 2>/dev/null; then
ok "wallet serve" "pid=$WSERVE_PID sock=$WSOCK (no run-until-done)"
return 0
fi
sleep 0.15
done
warn "wallet serve" "socket not ready — falling back to one-shot CLI (coins may stay pending)"
stop_wallet_serve
return 1
}
# $1 = max seconds for this call (optional); rest = wallet-cli args
# Prefer live serve socket so the shepherd stays up (alternative to run-until-done).
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 [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then
if command -v perl >/dev/null 2>&1; then
perl -e 'alarm shift; exec @ARGV' "$cap" \
node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@"
else
node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@"
fi
else
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
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 [ -n "${WSERVE_PID:-}" ] && [ -S "${WSOCK:-}" ]; then
if command -v perl >/dev/null 2>&1; then
perl -e 'alarm shift; exec @ARGV' "$cap" \
node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@"
else
node "$WALLET_CLI" --wallet-connection="$WSOCK" --no-throttle "$@"
fi
else
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
fi
}
# When coins never become available: run inside + targeted bank/exchange dig
dig_when_no_coins() {
set_group dig
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"
# ---------------------------------------------------------------------------
set_group bank
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
# ---------------------------------------------------------------------------
set_group wallet
section "e2e · wallet setup (exchange + ToS once)"
# ---------------------------------------------------------------------------
# Long-lived serve = client/server wallet (shepherd keeps running; no run-until-done).
start_wallet_serve || true
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
# Empty wallet baseline before ATM / pay load
metrics_report_coins "wallet-ready" || true
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 · $(format_amount_alt "$WITHDRAW_AMT")"
e2e_over && { warn "ATM withdraw" "budget exhausted — skip $WITHDRAW_AMT"; return 1; }
metrics_report_coins "before-ATM-${tag}" || true
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"
# Poll bank withdrawal status only (no run-until-done). force-select if stuck pending.
st=""
for i in 1 2 3 4 5 6 8 10; do
e2e_over && break
st=$(wd_status)
case "$st" in selected|confirmed|aborted) break ;; esac
sleep 0.4
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 settle: poll balance + bank transfer_done only (never run-until-done)
local ok_bal=0 r av xfer
for r in 1 2 3 4 5 6 8 10; do
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
xfer=$(curl -sS -m 8 "$BANK/taler-integration/withdrawal-operation/${WID}" 2>/dev/null \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("transfer_done"), d.get("status"))' 2>/dev/null || echo "?")
if echo "$xfer" | grep -qi True; then
ok "ATM $WITHDRAW_AMT bank transfer_done" "wallet avail=${CUR}:${av} ($xfer) — no run-until-done"
ok_bal=1
break
fi
sleep 2
done
metrics_report_coins "after-ATM-${tag}" || true
if [ "$ok_bal" = "1" ]; then
metrics_record_flow withdrawn "$WITHDRAW_AMT" || true
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
}
# Map CUR:amount → public template id.
# GOA default: e2e-* on goa-demo. TESTPAYSAN: farmer shop templates (set in apply_taler_domain).
# Override with E2E_TEMPLATE_MAP="5=oeufs-6 8.5=fromage-chevre"
e2e_template_id_for_amount() {
local amt="$1"
local val map_entry k v
val=$(printf '%s' "$amt" | awk -F: '{print $NF}')
if [ -z "${E2E_TEMPLATE_MAP:-}" ]; then
if [ "${CUR:-}" = "TESTPAYSAN" ]; then
E2E_TEMPLATE_MAP="5=oeufs-6 4.5=pain-seigle 6=jus-pomme 8.5=fromage-chevre 12=miel-printemps 25=panier-legumes"
else
E2E_TEMPLATE_MAP="0.01=e2e-001 0.05=e2e-005 1=e2e-1 1.0=e2e-1"
fi
fi
for map_entry in $E2E_TEMPLATE_MAP; do
k="${map_entry%%=*}"
v="${map_entry#*=}"
if [ "$k" = "$val" ]; then
printf '%s' "$v"
return 0
fi
done
return 1
}
# Template id → merchant instance (stage farmer shops / GOA goa-shop)
e2e_template_instance_for_id() {
local pid="$1"
case "$pid" in
oeufs-6|pain-seigle|jus-pomme) printf '%s' "jardin-du-creux" ;;
panier-legumes|fromage-chevre|miel-printemps) printf '%s' "fermes-des-collines" ;;
e2e-001|e2e-005|e2e-1|orbit-sticker|nebula-coffee|voidwave-playlist|comet-cap|shuttle-pass|beacon-badge|relay-pin|eclipse-shades|star-chart|rainbow-pill|blue-or-red-pill)
printf '%s' "${E2E_SHOP_INSTANCE:-goa-shop}"
;;
*) printf '%s' "${MERCHANT_INSTANCE:-default}" ;;
esac
}
# One payment: e2e_one_pay AMOUNT [SUMMARY] [TAG]
# SUMMARY defaults to "monitoring pay $AMOUNT"; TAG defaults from amount.
# When E2E_USE_TEMPLATES=1 and amount maps to a public template, uses
# POST /instances/{inst}/templates/{id} (same as landings/shops).
e2e_one_pay() {
PAY_AMT="$1"
local PAY_SUM="${2:-monitoring pay ${PAY_AMT}}"
local tag="${3:-}"
if [ -z "$tag" ]; then
tag=$(printf '%s' "$PAY_AMT" | tr '.:' '__')
fi
# shell-safe tag for files
tag=$(printf '%s' "$tag" | tr -c 'A-Za-z0-9._-' '_')
section "e2e · pay $PAY_AMT · $(format_amount_alt "$PAY_AMT") · $PAY_SUM"
e2e_over && { warn "pay" "budget exhausted — skip $PAY_AMT ($PAY_SUM)"; return 1; }
metrics_report_coins "before-pay-${tag}" || true
# Prefer public templates when enabled (stage TESTPAYSAN / shop-style orders)
local tpl_id=""
if [ "${E2E_USE_TEMPLATES:-0}" = "1" ]; then
tpl_id=$(e2e_template_id_for_amount "$PAY_AMT" 2>/dev/null || true)
if [ -n "$tpl_id" ]; then
info "pay $PAY_AMT" "via public template $tpl_id (E2E_USE_TEMPLATES=1)"
e2e_one_pay_public_template "$tpl_id" "$PAY_SUM" "$PAY_AMT"
return $?
fi
warn "pay $PAY_AMT" "no template map for amount — fall back to private order"
fi
if [ -z "${MPW:-}" ] && [ -z "${MPW_TOKEN:-}" ]; then
warn "pay $PAY_AMT" "no merchant token"
return 1
fi
local AUTH
AUTH=$(merchant_auth_header) || {
warn "pay $PAY_AMT" "no merchant token"
return 1
}
# JSON-escape summary for curl -d
local SUM_JSON
SUM_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$PAY_SUM" 2>/dev/null || printf '"%s"' "$PAY_SUM")
curl -skS -m 15 -o "$SCRATCH/ord-$tag.json" -X POST \
-H "$AUTH" -H 'Content-Type: application/json' \
-d "{\"order\":{\"summary\":${SUM_JSON},\"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
local mpw_raw="${MPW_TOKEN:-secret-token:${MPW}}"
koopa_ssh_run 20 \
"curl -skS -m 12 -X POST -H 'Authorization: Bearer ${mpw_raw}' -H 'Content-Type: application/json' -d '{\"order\":{\"summary\":${SUM_JSON},\"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 ($PAY_SUM)"
fi
warn "pay $PAY_AMT ($PAY_SUM)" "order create failed — $(head -c 80 "$SCRATCH/ord-$tag.json" | tr '\n' ' ')"
return 1
fi
ok "merchant order $OID ($PAY_AMT · $PAY_SUM)"
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
# normalize default HTTPS port (wallets accept both)
PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g')
if [ -z "$PAYURI" ]; then
warn "pay $PAY_AMT ($PAY_SUM)" "no pay URI for $OID"
return 1
fi
ok "taler_pay_uri ready ($tag)"
if ! wcli_pay handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$tag.out" 2>&1; then
warn "pay $PAY_AMT ($PAY_SUM)" "handle-uri failed — $(tail -c 100 "$SCRATCH/pay-$tag.out" | tr '\n' ' ')"
return 1
fi
ok "wallet handle pay $PAY_AMT ($PAY_SUM)"
# Settle: poll merchant order + wallet transactions only (never run-until-done)
local r
for r in 1 2 3 4 5 6; do
wcli_pay transactions >"$SCRATCH/tx-$tag.out" 2>&1 || true
if [ -n "${AUTH:-}" ]; then
curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \
"${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true
fi
if [ -n "${OTOK:-}" ]; then
curl -skS -m 8 -o "$SCRATCH/ord-pub-$tag.json" \
"${MERCHANT_PUBLIC}/instances/${INST}/orders/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OID")?token=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OTOK")" \
2>/dev/null || true
fi
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
for p in sys.argv[1:]:
try:
d=json.load(open(p))
if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid":
sys.exit(0)
except Exception:
pass
sys.exit(1)
' "$SCRATCH/ord-paid-$tag.json" "$SCRATCH/ord-pub-$tag.json" 2>/dev/null; then
ok "payment settled $PAY_AMT ($PAY_SUM · order $OID)"
metrics_report_coins "after-pay-${tag}" || true
metrics_record_flow spent "$PAY_AMT" || true
return 0
fi
sleep 1
done
metrics_report_coins "after-pay-fail-${tag}" || true
warn "pay $PAY_AMT ($PAY_SUM)" "not settled for order $OID"
return 1
}
# GOA shop catalog (full list) — keep in sync with configs/merchant-landing/index.html
# id|product_name|amount
# Landing QR = taler://pay-template/…/{id}; popup = public POST templates/{id}.
# E2E does not pay every product every run: shuffle + pick E2E_SHOP_PICK_N (default 2).
: "${E2E_SHOP_PRODUCTS:=orbit-sticker|Orbit sticker pack|GOA:2
nebula-coffee|Nebula coffee|GOA:5
voidwave-playlist|Voidwave playlist|GOA:8
comet-cap|Comet cap|GOA:15
shuttle-pass|Shuttle day pass|GOA:25
beacon-badge|Beacon badge|GOA:3
relay-pin|Relay pin|GOA:4
eclipse-shades|Eclipse shades|GOA:12
star-chart|Star chart print|GOA:7
rainbow-pill|Rainbow pill (sample/joke)|GOA:4.2
blue-or-red-pill|Blue or red pill (sample/joke)|GOA:1.5}"
: "${E2E_SHOP_PICK_N:=2}"
# Public templates live on goa-shop (landing shop-pay.js); not the e2e default demo instance.
: "${E2E_SHOP_INSTANCE:=goa-shop}"
# Settlement payto shown on landing shop popup (static)
: "${E2E_SHOP_PAYTO:=payto://x-taler-bank/bank.hacktivism.ch/goa-shop?receiver-name=GOA%20Shop}"
# Pay one shop product the way the public landing does:
# POST /instances/{inst}/templates/{id} body {} → order_id+token → taler_pay_uri
# Also checks pay-template URI shape (what qrencode PNGs encode).
# Usage: e2e_one_pay_public_template PRODUCT_ID PRODUCT_NAME AMOUNT
e2e_one_pay_public_template() {
local pid="$1"
local pname="${2:-$1}"
local pamt="$3"
local inst="${4:-}"
local tag
tag=$(printf 'shop_%s' "$pid" | tr -c 'A-Za-z0-9._-' '_')
section "e2e · shop product · $pname ($pid) · $pamt · $(format_amount_alt "$pamt")"
e2e_over && { warn "shop $pname" "budget exhausted — skip"; return 1; }
metrics_report_coins "before-shop-${tag}" || true
# Resolve instance: explicit arg → template map → global INST
if [ -z "$inst" ]; then
inst=$(e2e_template_instance_for_id "$pid")
fi
[ -n "$inst" ] || inst="${INST:-${MERCHANT_INSTANCE:-default}}"
local MH
MH=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$MERCHANT_PUBLIC"'").hostname or "taler.hacktivism.ch")' 2>/dev/null || echo "taler.hacktivism.ch")
local TPL_URI="taler://pay-template/${MH}/instances/${inst}/${pid}"
local TPL_HTTPS="${MERCHANT_PUBLIC}/instances/${inst}/templates/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$pid")"
info "shop $pname" "instance $inst · pay-template URI $TPL_URI"
info "shop $pname" "public template POST $TPL_HTTPS"
# Stage settlement payto: instance bank account; GOA uses E2E_SHOP_PAYTO
local payto_show="${E2E_SHOP_PAYTO:-}"
if [ "${CUR:-}" = "TESTPAYSAN" ]; then
payto_show="payto://x-taler-bank/stage.bank.lefrancpaysan.ch/${inst}"
fi
info "shop $pname" "settlement payto ${payto_show:-(none)}"
# Public create (no merchant secret) — same as shop-pay.js
curl -skS -m 15 -o "$SCRATCH/tpl-$tag.json" -X POST \
-H 'Content-Type: application/json' \
-d '{}' \
"$TPL_HTTPS" 2>"$SCRATCH/tpl-$tag.err" || true
if ! grep -q order_id "$SCRATCH/tpl-$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 'Content-Type: application/json' -d '{}' 'https://127.0.0.1:9010/instances/${INST}/templates/${pid}'" \
>"$SCRATCH/tpl-$tag.json" 2>"$SCRATCH/tpl-$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/tpl-$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/tpl-$tag.json" 2>/dev/null || true)
if [ -z "$OID" ] || [ -z "$OTOK" ]; then
warn "shop $pname ($pid)" "public template POST failed — $(head -c 100 "$SCRATCH/tpl-$tag.json" 2>/dev/null | tr '\n' ' ')"
return 1
fi
ok "shop $pname" "public order $OID (template $pid)"
local statusUrl="${MERCHANT_PUBLIC}/instances/${INST}/orders/$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OID")?token=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$OTOK")"
curl -skS -m 12 -o "$SCRATCH/tpl-st-$tag.json" "$statusUrl" 2>/dev/null || true
PAYURI=$(python3 -c 'import json,sys
try:
d=json.load(open(sys.argv[1]))
print(d.get("taler_pay_uri") or "")
except Exception: print("")
' "$SCRATCH/tpl-st-$tag.json" 2>/dev/null || true)
if [ -z "$PAYURI" ]; then
PAYURI="taler://pay/${MH}/instances/${INST}/${OID}/?c=${OTOK}"
fi
PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g')
if ! printf '%s' "$PAYURI" | grep -q '^taler://pay/'; then
warn "shop $pname ($pid)" "bad pay URI: $PAYURI"
return 1
fi
ok "shop $pname" "taler_pay_uri $PAYURI"
if ! wcli_pay handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$tag.out" 2>&1; then
warn "shop $pname ($pid)" "handle-uri failed — $(tail -c 120 "$SCRATCH/pay-$tag.out" | tr '\n' ' ')"
return 1
fi
ok "shop $pname" "wallet accepted pay URI"
# Settle: public order status + transactions only (never run-until-done)
local r AUTH=""
AUTH=$(merchant_auth_header 2>/dev/null) || AUTH=""
for r in 1 2 3 4 5 6; do
wcli_pay transactions >"$SCRATCH/tx-$tag.out" 2>&1 || true
if [ -n "$AUTH" ]; then
curl -skS -m 8 -o "$SCRATCH/ord-paid-$tag.json" -H "$AUTH" \
"${MERCHANT_PUBLIC}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true
fi
curl -skS -m 8 -o "$SCRATCH/ord-pub-$tag.json" "$statusUrl" 2>/dev/null || true
if python3 -c 'import json,sys
for p in sys.argv[1:]:
try:
d=json.load(open(p))
if d.get("paid") is True or str(d.get("order_status","")).lower()=="paid":
sys.exit(0)
except Exception:
pass
sys.exit(1)
' "$SCRATCH/ord-paid-$tag.json" "$SCRATCH/ord-pub-$tag.json" 2>/dev/null \
|| 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; then
ok "shop $pname" "payment settled ($pamt · order $OID)"
metrics_report_coins "after-shop-${tag}" || true
metrics_record_flow spent "$pamt" || true
return 0
fi
sleep 1
done
metrics_report_coins "after-shop-fail-${tag}" || true
warn "shop $pname ($pid)" "not settled for order $OID"
return 1
}
# ---------------------------------------------------------------------------
set_group atm
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)"
set_group load
section "e2e · load snapshot (after ATM withdraws)"
metrics_report_load "${METRICS_DIR}/load-after-withdraw.json" "after-withdraw" || true
cp -f "${METRICS_DIR}/load-after-withdraw.json" "${METRICS_DIR}/load-after.json" 2>/dev/null || true
metrics_report_coins "after-ATM-ladder" || true
# Settlement catch-up: bank may have confirmed while wallet was still empty
set_group settle
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}"
metrics_report_coins "after-settle" || true
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
# ---------------------------------------------------------------------------
set_group pay
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:-}" ] && [ "${E2E_USE_TEMPLATES:-0}" != "1" ]; then
if [ "$E2E_REMOTE" = "1" ]; then
e2e_abort_auth "merchant-order" "no merchant token — set E2E_MERCHANT_TOKEN (or E2E_USE_TEMPLATES=1)"
fi
blocker "merchant-order" "no merchant token"
exit 1
fi
if [ -z "${MPW:-}" ] && [ "${E2E_USE_TEMPLATES:-0}" = "1" ]; then
info "pay" "no merchant token — public templates only (E2E_USE_TEMPLATES=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)"
set_group load
section "e2e · load snapshot (after payments)"
metrics_report_load "${METRICS_DIR}/load-after-pay.json" "after-pay" || true
cp -f "${METRICS_DIR}/load-after-pay.json" "${METRICS_DIR}/load-after.json" 2>/dev/null || true
metrics_report_coins "after-pay-ladder" || true
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
# ---------------------------------------------------------------------------
set_group shop
section "e2e · shop products (public templates)"
# ---------------------------------------------------------------------------
# Public template POST + wallet pay — catalog random pick of N products.
# GOA: goa-shop on hacktivism. TESTPAYSAN: farmer shops (jardin / fermes).
SHOP_OK=0
SHOP_OK_N=0
SHOP_FAIL_N=0
SHOP_SKIP_N=0
SHOP_REPORT=""
# Auto catalog for stage TESTPAYSAN when not overridden
if [ "${CUR:-}" = "TESTPAYSAN" ] && [ -z "${E2E_SHOP_PRODUCTS_SET:-}" ] && [ -z "${E2E_SHOP_FORCE_GOA:-}" ]; then
# id|name|amount|instance — force stage catalog (GOA default is set earlier)
E2E_SHOP_PRODUCTS="oeufs-6|Œufs plein air × 6|TESTPAYSAN:5|jardin-du-creux
pain-seigle|Pain de seigle 800 g|TESTPAYSAN:4.5|jardin-du-creux
jus-pomme|Jus de pomme 1 L|TESTPAYSAN:6|jardin-du-creux
panier-legumes|Panier légumes|TESTPAYSAN:25|fermes-des-collines
fromage-chevre|Fromage de chèvre 200 g|TESTPAYSAN:8.5|fermes-des-collines
miel-printemps|Miel de printemps 250 g|TESTPAYSAN:12|fermes-des-collines"
# Force stage defaults (GOA goa-shop is already set earlier via :=)
if [ -z "${E2E_SHOP_INSTANCE_SET:-}" ]; then
E2E_SHOP_INSTANCE=jardin-du-creux
fi
if [ -z "${E2E_SHOP_PAYTO_SET:-}" ]; then
E2E_SHOP_PAYTO="payto://x-taler-bank/stage.bank.lefrancpaysan.ch/jardin-du-creux"
fi
E2E_SHOP_PICK_N="${E2E_SHOP_PICK_N:-2}"
E2E_SHOP_ENABLE=1
fi
# GOA local only by default; remote non-GOA shops need E2E_SHOP_ENABLE=1 or TESTPAYSAN auto above
if [ -z "${E2E_SHOP_ENABLE:-}" ]; then
if [ "$E2E_REMOTE" = "1" ] || [ "${CUR:-}" != "GOA" ]; then
E2E_SHOP_ENABLE=0
else
E2E_SHOP_ENABLE=1
fi
fi
if [ "${E2E_SHOP_ENABLE}" != "1" ]; then
info "shop" "SKIPPED (set E2E_SHOP_ENABLE=1 or use TESTPAYSAN stage catalog)"
else
# Build catalog array from E2E_SHOP_PRODUCTS (id|name|amount[|instance] lines)
SHOP_CATALOG=()
while IFS= read -r line; do
[ -z "$line" ] && continue
case "$line" in \#*) continue ;; esac
SHOP_CATALOG+=("$line")
done <<EOF
$E2E_SHOP_PRODUCTS
EOF
SHOP_CAT_N=${#SHOP_CATALOG[@]}
PICK_N="${E2E_SHOP_PICK_N:-2}"
case "$PICK_N" in ''|*[!0-9]*) PICK_N=2 ;; esac
if [ "$PICK_N" -lt 1 ]; then PICK_N=1; fi
if [ "$SHOP_CAT_N" -eq 0 ]; then
warn "shop" "empty E2E_SHOP_PRODUCTS catalog"
else
if [ "$PICK_N" -gt "$SHOP_CAT_N" ]; then PICK_N=$SHOP_CAT_N; fi
# Shuffle catalog, take first PICK_N (portable: python)
SHOP_PICKED=$(
printf '%s\n' "${SHOP_CATALOG[@]}" | python3 -c "
import random, sys
lines = [ln.strip() for ln in sys.stdin if ln.strip()]
n = min(int(sys.argv[1]), len(lines))
random.shuffle(lines)
print('\n'.join(lines[:n]))
" "$PICK_N"
)
info "shop" "catalog ${SHOP_CAT_N} products · random pick ${PICK_N} · default instance ${E2E_SHOP_INSTANCE}"
info "shop payto" "$E2E_SHOP_PAYTO"
info "shop pick" "$(printf '%s\n' "$SHOP_PICKED" | cut -d'|' -f1,2 | tr '\n' '; ' | sed 's/; $//')"
# e2e_one_pay_public_template uses global INST — pin per product if 4th field set
_E2E_INST_SAVE="$INST"
INST="${E2E_SHOP_INSTANCE}"
while IFS= read -r line; do
[ -z "$line" ] && continue
e2e_over && { warn "shop" "time budget low — stopping product pays"; break; }
pid=$(printf '%s' "$line" | cut -d'|' -f1)
pname=$(printf '%s' "$line" | cut -d'|' -f2)
pamt=$(printf '%s' "$line" | cut -d'|' -f3)
pinst=$(printf '%s' "$line" | cut -d'|' -f4)
[ -z "$pid" ] || [ -z "$pamt" ] && continue
[ -z "$pname" ] && pname="$pid"
INST="${pinst:-${E2E_SHOP_INSTANCE}}"
av_now=$(wallet_avail_num)
pay_n=$(python3 -c 'import sys; print(float(sys.argv[1].split(":",1)[-1]))' "$pamt" 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 "shop $pname ($pid)" "skip — insufficient avail ${CUR}:${av_now} (need ${pay_n})"
SHOP_SKIP_N=$((SHOP_SKIP_N + 1))
SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=SKIP(bal)"
continue
fi
set +e
e2e_one_pay_public_template "$pid" "$pname" "$pamt"
pc=$?
set -e
if [ "$pc" = "0" ]; then
SHOP_OK=1
SHOP_OK_N=$((SHOP_OK_N + 1))
PAY_OK=1
PAY_OK_N=$((PAY_OK_N + 1))
SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=OK"
else
SHOP_FAIL_N=$((SHOP_FAIL_N + 1))
SHOP_REPORT="${SHOP_REPORT}${SHOP_REPORT:+ }${pname}=FAIL"
fi
done <<EOF
$SHOP_PICKED
EOF
INST="$_E2E_INST_SAVE"
unset _E2E_INST_SAVE
info "shop summary" "$SHOP_REPORT (ok=$SHOP_OK_N fail=$SHOP_FAIL_N skip=$SHOP_SKIP_N · pick $PICK_N/$SHOP_CAT_N)"
if [ "$SHOP_OK" != "1" ] && [ "$SHOP_FAIL_N" -gt 0 ]; then
warn "shop" "no sample product payment succeeded ($SHOP_REPORT)"
fi
section "e2e · load snapshot (after shop pays)"
metrics_report_load "${METRICS_DIR}/load-after-shop.json" "after-shop" || true
cp -f "${METRICS_DIR}/load-after-shop.json" "${METRICS_DIR}/load-after.json" 2>/dev/null || true
fi
fi
# ---------------------------------------------------------------------------
set_group paivana
section "e2e · paivana paywall (template GOA:4200)"
# ---------------------------------------------------------------------------
# Public paywall: https://paivana.hacktivism.ch → 302 to merchant template "paivana".
# Requires spendable ≥ 4200 GOA (ATM ladder includes GOA:4200 by default).
: "${PAIVANA_PUBLIC:=https://paivana.hacktivism.ch}"
: "${E2E_PAIVANA:=1}"
: "${E2E_PAIVANA_TEMPLATE:=paivana}"
: "${E2E_PAIVANA_AMOUNT:=GOA:4200}"
: "${E2E_PAIVANA_INSTANCE:=goa-shop}"
PAIVANA_OK=0
PAIVANA_REPORT=""
if [ "$E2E_REMOTE" = "1" ] || [ "${CUR:-}" != "GOA" ] || [ "${E2E_PAIVANA}" = "0" ]; then
info "paivana" "SKIPPED (remote, non-GOA, or E2E_PAIVANA=0)"
PAIVANA_REPORT="SKIP"
else
PAIVANA_PUBLIC="${PAIVANA_PUBLIC%/}"
# HTTP: paywall front must redirect into template flow (or serve well-known)
hdr=$(curl -skS -m 12 -D - -o /dev/null "$PAIVANA_PUBLIC/" 2>/dev/null || true)
pcode=$(printf '%s' "$hdr" | awk 'BEGIN{c="000"} /^HTTP/{c=$2} END{print c}')
loc=$(printf '%s' "$hdr" | awk 'BEGIN{IGNORECASE=1} /^location:/{sub(/\r$/,""); sub(/^location:[[:space:]]*/,""); print; exit}')
case "$pcode" in
301|302|303|307|308)
if printf '%s' "$loc" | grep -qiE 'paivana|templates|well-known'; then
ok "paivana HTTP" "HTTP $pcode${loc:0:120}"
else
ok "paivana HTTP" "HTTP $pcode redirect (Location: ${loc:0:80})"
fi
;;
200)
warn "paivana HTTP" "HTTP 200 without paywall redirect — unexpected for locked site"
;;
*)
fail "paivana HTTP" "HTTP ${pcode:-000} on $PAIVANA_PUBLIC/ (want 302 to template)"
PAIVANA_REPORT="HTTP_FAIL"
;;
esac
# Ensure wallet can cover 4200 (extra ATM if ladder did not fund enough)
pay_need=$(python3 -c 'import sys; print(float(sys.argv[1].split(":",1)[-1]))' "$E2E_PAIVANA_AMOUNT" 2>/dev/null || echo 4200)
av_now=$(wallet_avail_num)
if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-9 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_need" 2>/dev/null; then
info "paivana" "avail ${CUR}:${av_now} < ${pay_need} — ATM withdraw ${E2E_PAIVANA_AMOUNT}"
e2e_over && warn "paivana" "budget low before 4200 withdraw"
set +e
e2e_one_withdraw "$E2E_PAIVANA_AMOUNT"
wc=$?
set -e
if [ "$wc" = "0" ]; then
WITHDRAW_OK=1
WITHDRAW_OK_N=$((WITHDRAW_OK_N + 1))
WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${E2E_PAIVANA_AMOUNT}=OK(paivana)"
elif [ "$wc" = "2" ]; then
WITHDRAW_LAG_N=$((WITHDRAW_LAG_N + 1))
WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${E2E_PAIVANA_AMOUNT}=LAG(paivana)"
else
WITHDRAW_FAIL_N=$((WITHDRAW_FAIL_N + 1))
WITHDRAW_REPORT="${WITHDRAW_REPORT}${WITHDRAW_REPORT:+ }${E2E_PAIVANA_AMOUNT}=FAIL(paivana)"
fi
wait_wallet_balance "$(python3 -c "print(max(0, $pay_need - 1))" 2>/dev/null || echo 4199)" 12 3 || true
av_now=$(wallet_avail_num)
fi
if ! python3 -c "import sys; sys.exit(0 if float(sys.argv[1]) + 1e-9 >= float(sys.argv[2]) else 1)" "$av_now" "$pay_need" 2>/dev/null; then
warn "paivana" "skip pay — insufficient avail ${CUR}:${av_now} (need ${pay_need})"
PAIVANA_REPORT="${PAIVANA_REPORT:+$PAIVANA_REPORT+}SKIP(bal)"
else
_E2E_INST_SAVE="$INST"
INST="${E2E_PAIVANA_INSTANCE}"
set +e
e2e_one_pay_public_template \
"${E2E_PAIVANA_TEMPLATE}" \
"Paivana paywall" \
"${E2E_PAIVANA_AMOUNT}"
pc=$?
set -e
INST="$_E2E_INST_SAVE"
unset _E2E_INST_SAVE
if [ "$pc" = "0" ]; then
PAIVANA_OK=1
PAY_OK=1
PAY_OK_N=$((PAY_OK_N + 1))
PAIVANA_REPORT="${PAIVANA_REPORT:+$PAIVANA_REPORT+}PAY_OK"
ok "paivana" "template ${E2E_PAIVANA_TEMPLATE} paid ${E2E_PAIVANA_AMOUNT}"
else
PAIVANA_REPORT="${PAIVANA_REPORT:+$PAIVANA_REPORT+}PAY_FAIL"
warn "paivana" "template pay failed (${E2E_PAIVANA_TEMPLATE} · ${E2E_PAIVANA_AMOUNT})"
fi
fi
info "paivana summary" "${PAIVANA_REPORT:-?} · instance ${E2E_PAIVANA_INSTANCE}"
fi
set_group report
section "e2e · report"
info "user" "$USER"
info "ATM withdraws" "$WITHDRAW_REPORT"
info "payments" "$PAY_REPORT"
info "shop" "${SHOP_REPORT:-(n/a)}"
info "paivana" "${PAIVANA_REPORT:-(n/a)}"
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