koopa-admin-log/scripts/taler-monitoring/check_e2e.sh

1183 lines
48 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"
: "${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 payment: e2e_one_pay AMOUNT [SUMMARY] [TAG]
# SUMMARY defaults to "monitoring pay $AMOUNT"; TAG defaults from amount.
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 · $PAY_SUM"
e2e_over && { warn "pay" "budget exhausted — skip $PAY_AMT ($PAY_SUM)"; return 1; }
if [ -z "${MPW:-}" ]; then
warn "pay $PAY_AMT" "no merchant token"
return 1
fi
AUTH="Authorization: Bearer secret-token:${MPW}"
# 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
koopa_ssh_run 20 \
"curl -skS -m 12 -X POST -H 'Authorization: Bearer secret-token:${MPW}' -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)"
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 ($PAY_SUM · order $OID)"
return 0
fi
done
warn "pay $PAY_AMT ($PAY_SUM)" "not settled for order $OID"
return 1
}
# GOA shop samples from merchant landing (taler.hacktivism.ch/intro/)
# id|product_name|amount — keep in sync with configs/merchant-landing/index.html
# Landing QR encodes taler://pay-template/…/{id}; popup creates live pay via public POST templates/{id}.
: "${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}"
# Settlement payto shown on landing shop popup (static)
: "${E2E_SHOP_PAYTO:=payto://x-taler-bank/bank.hacktivism.ch/goa-demo-cp4zqk?receiver-name=GOA%20Demo%20Shop%20cp4zqk}"
# 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 tag
tag=$(printf 'shop_%s' "$pid" | tr -c 'A-Za-z0-9._-' '_')
section "e2e · shop product · $pname ($pid) · $pamt"
e2e_over && { warn "shop $pname" "budget exhausted — skip"; return 1; }
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" "pay-template URI $TPL_URI"
info "shop $pname" "public template POST $TPL_HTTPS"
info "shop $pname" "settlement payto $E2E_SHOP_PAYTO"
# 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"
local r AUTH=""
if [ -n "${MPW:-}" ]; then
AUTH="Authorization: Bearer secret-token:${MPW}"
fi
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
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
if 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 "shop $pname" "payment settled ($pamt · order $OID)"
return 0
fi
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; then
ok "shop $pname" "payment settled via wallet tx ($pamt · order $OID)"
return 0
fi
done
warn "shop $pname ($pid)" "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 · GOA shop products (merchant landing catalog)"
# ---------------------------------------------------------------------------
# Public template POST + wallet pay — product id|name|amount (landing QR = pay-template).
SHOP_OK=0
SHOP_OK_N=0
SHOP_FAIL_N=0
SHOP_SKIP_N=0
SHOP_REPORT=""
if [ "$E2E_REMOTE" = "1" ] || [ "${CUR:-}" != "GOA" ]; then
info "goa-shop" "SKIPPED (remote or non-GOA currency)"
else
info "goa-shop" "public template pays for each sample (id · product name · amount)"
info "goa-shop payto" "$E2E_SHOP_PAYTO"
while IFS= read -r line; do
[ -z "$line" ] && continue
case "$line" in \#*) continue ;; esac
e2e_over && { warn "goa-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)
[ -z "$pid" ] || [ -z "$pamt" ] && continue
[ -z "$pname" ] && pname="$pid"
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 "goa-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
$E2E_SHOP_PRODUCTS
EOF
info "goa-shop summary" "$SHOP_REPORT (ok=$SHOP_OK_N fail=$SHOP_FAIL_N skip=$SHOP_SKIP_N)"
if [ "$SHOP_OK" != "1" ] && [ "$SHOP_FAIL_N" -gt 0 ]; then
warn "goa-shop" "no sample product payment succeeded ($SHOP_REPORT)"
fi
fi
section "e2e · report"
info "user" "$USER"
info "ATM withdraws" "$WITHDRAW_REPORT"
info "payments" "$PAY_REPORT"
info "goa-shop" "${SHOP_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