1711 lines
69 KiB
Bash
Executable file
1711 lines
69 KiB
Bash
Executable file
#!/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 koopa or koopa-external)
|
||
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 francpaysan host secrets.
|
||
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: already-set env → local secrets tree → SSH francpaysan-host (ops laptop).
|
||
if [ -z "$ADMIN_PASS" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "TESTPAYSAN" ]; then
|
||
_fp_try=""
|
||
for _fp_try in \
|
||
"${FRANCPAYSAN_SECRETS:-}/stage/bank-admin-password.txt" \
|
||
"${HOME}/francpaysan-secrets/stage/bank-admin-password.txt" \
|
||
"${HOME}/src/francpaysan-secrets/stage/bank-admin-password.txt" \
|
||
"${HOME}/taler/src/francpaysan-secrets/stage/bank-admin-password.txt"
|
||
do
|
||
if [ -n "$_fp_try" ] && [ -f "$_fp_try" ]; then
|
||
ADMIN_PASS=$(tr -d '\n\r' <"$_fp_try")
|
||
info "bank admin secret" "from $_fp_try"
|
||
break
|
||
fi
|
||
done
|
||
if [ -z "$ADMIN_PASS" ] && command -v ssh >/dev/null 2>&1; then
|
||
for _fp_host in "${FRANCPAYSAN_SSH:-francpaysan-host}" francpaysan-host; do
|
||
ADMIN_PASS=$(ssh -o BatchMode=yes -o ConnectTimeout=8 "$_fp_host" \
|
||
'sudo cat /mnt/data/stagepaysan/bank/secrets/bank-admin-password.txt 2>/dev/null' \
|
||
2>/dev/null | tr -d '\n\r' || true)
|
||
if [ -n "$ADMIN_PASS" ]; then
|
||
info "bank admin secret" "from ${_fp_host}:/mnt/data/stagepaysan/bank/secrets/"
|
||
break
|
||
fi
|
||
done
|
||
fi
|
||
unset _fp_try _fp_host
|
||
fi
|
||
# Stage merchant: public templates need no token; optional default-instance token
|
||
if [ -z "$MPW" ] && [ "${CUR:-${EXPECT_CURRENCY:-}}" = "TESTPAYSAN" ]; then
|
||
for _fp_try in \
|
||
"${FRANCPAYSAN_SECRETS:-}/stage/default-instance-token.txt" \
|
||
"${HOME}/francpaysan-secrets/stage/default-instance-token.txt"
|
||
do
|
||
if [ -n "$_fp_try" ] && [ -f "$_fp_try" ]; then
|
||
MPW=$(tr -d '\n\r' <"$_fp_try")
|
||
break
|
||
fi
|
||
done
|
||
if [ -z "$MPW" ] && command -v ssh >/dev/null 2>&1; then
|
||
MPW=$(ssh -o BatchMode=yes -o ConnectTimeout=8 "${FRANCPAYSAN_SSH:-francpaysan-host}" \
|
||
'sudo cat /mnt/data/stagepaysan/merchant/secrets/default-instance-token.txt 2>/dev/null' \
|
||
2>/dev/null | tr -d '\n\r' || true)
|
||
fi
|
||
unset _fp_try
|
||
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
|