#!/usr/bin/env bash # check_goa_ladder.sh — withdraw/pay amount ladder (GOA + stage TESTPAYSAN) # # Flow (bank landings): # 1) GET /intro/auto-account.json → personal *account-* (balance 0) # 2) Mint pool withdrawals as explorer + confirm when selected # 3) wallet-cli accept-uri only (no run-until-done) # 4) bank confirm when selected; settle = balance + transfer_done # # Amounts: random strictly increasing; pins 0, max-1, max. # Phase A: withdraw ladder (cumulative wallet). Phase B: pay ladder. # # Stacks: # GOA — LADDER_MAX_AMOUNT = libeufin ceiling; explorer from koopa-admin-secrets # TESTPAYSAN stage — auto max_wire from bank /config, min denom from /keys; # explorer from stagepaysan secrets / SSH # # Usage: # ./taler-monitoring.sh ladder # ./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder # LADDER_MAX_AMOUNT=100 LADDER_STEPS=7 ./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder # # Env (see body): LADDER_STEPS, LADDER_MAX_AMOUNT, LADDER_MIN_AMOUNT, LADDER_STACK_AUTO, # LADDER_PAY, EXP_PW / EXP_PW_FILE, CLI_JS, MERCHANT_INSTANCE, … set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh source "$ROOT/lib.sh" # When invoked standalone (not via taler-monitoring.sh), still load secrets.env load_monitoring_secrets_env 2>/dev/null || true # Area ladder.* — withdraw/pay amount ladder (GOA + TESTPAYSAN) # Groups: ladder.plan / ladder.load / ladder.withdraw / ladder.pay / ladder.report set_area ladder set_group plan SECTION_T0=$(date +%s) now_ms() { python3 -c 'import time; print(int(time.time()*1000))'; } elapsed_ms() { # elapsed_ms START_MS python3 -c 'import sys; print(int(sys.argv[1]) - int(sys.argv[2]))' "$(now_ms)" "$1" } : "${LADDER_TIMEOUT_S:=3600}" : "${LADDER_SETTLE_ROUNDS:=18}" : "${LADDER_SETTLE_SLEEP:=2}" : "${EXP_USER:=explorer}" # EXP_PW_FILE / EXP_PW optional overrides; otherwise read_secret + SECRETS_ROOT / stage SSH : "${EXP_PW_FILE:=}" : "${EXP_PW:=}" : "${CLI_JS:=}" # GOA libeufin amount ceiling (hacktivism). Stage auto-replaces via bank /config. : "${LADDER_MAX_AMOUNT:=4503599627370496}" : "${LADDER_STEPS:=23}" : "${LADDER_LOAD:=1}" : "${LADDER_PAY:=1}" : "${LADDER_STACK_AUTO:=1}" # Withdraw mids = pay mids × scale (so wallet can afford the pay ladder) : "${LADDER_WITHDRAW_SCALE:=1.5}" # Floor for random mids (must be ≥ smallest exchange coin; GOA min denom ≈ 0.000001) : "${LADDER_MIN_AMOUNT:=0.000001}" : "${LADDER_CONFIRM_POLLS:=40}" : "${LADDER_PAY_SETTLE_ROUNDS:=6}" : "${MERCHANT_INSTANCE:=goa-demo-cp4zqk}" # Public variable template for stage pays (optional; fixed templates used when amount maps) : "${LADDER_PAY_TEMPLATE:=}" : "${LADDER_PAY_TEMPLATE_INSTANCE:=fermes-des-collines}" GOA_LADDER_CEILING="4503599627370496" # Resolve wallet-cli .mjs (no hardcoded laptop path) if [ -z "${CLI_JS}" ] || [ ! -f "${CLI_JS}" ]; then CLI_JS=$(find_wallet_cli 2>/dev/null || true) fi if [ -z "${CLI_JS}" ] || [ ! -f "${CLI_JS}" ]; then # allow PATH wrapper as last resort (wcli falls back to taler-wallet-cli) CLI_JS="" fi CUR="${EXPECT_CURRENCY:-GOA}" BANK="${BANK_PUBLIC%/}" EX="${EXCHANGE_PUBLIC%/}/" MER="${MERCHANT_PUBLIC%/}" INST="${MERCHANT_INSTANCE}" # --- Stack-specific ladder maxima (TESTPAYSAN stage) --- # Uses bank max_wire_transfer_amount and exchange min denom when still on GOA defaults. apply_ladder_stack_defaults() { [ "${LADDER_STACK_AUTO}" = "1" ] || return 0 if [ "${CUR}" != "TESTPAYSAN" ]; then case "${TALER_DOMAIN:-}" in stage.lefrancpaysan.ch|stage.bank.lefrancpaysan.ch|stage.exchange.lefrancpaysan.ch|stage.monnaie.lefrancpaysan.ch) CUR="TESTPAYSAN" ;; *) return 0 ;; esac fi local bank_cfg max_wire min_denom bank_cfg=$(curl -sS -m 12 "${BANK}/config" 2>/dev/null || true) max_wire=$(printf '%s' "$bank_cfg" | python3 -c ' import json,sys try: d=json.load(sys.stdin) except Exception: print("") raise SystemExit(0) a=d.get("max_wire_transfer_amount") or "" print(a.split(":",1)[-1] if a else "") ' 2>/dev/null || true) min_denom=$(curl -sS -m 25 -H 'Accept: application/json' "${EX%/}/keys" 2>/dev/null | python3 -c ' import json,sys try: d=json.load(sys.stdin) except Exception: print("") raise SystemExit(0) den=d.get("denoms") or d.get("denominations") or [] vals=[] for x in den if isinstance(den,list) else []: v=x.get("value") or x.get("amount") or "" if not v: continue try: vals.append(float(str(v).split(":")[-1])) except Exception: pass print(min(vals) if vals else "") ' 2>/dev/null || true) # Fallback maxima if public config unreachable [ -n "$max_wire" ] || max_wire="2000" [ -n "$min_denom" ] || min_denom="0.01" # Only rewrite when caller left GOA defaults (explicit LADDER_MAX_AMOUNT=… kept) if [ -n "$max_wire" ] && { [ "${LADDER_MAX_AMOUNT}" = "${GOA_LADDER_CEILING}" ] || [ "${LADDER_MAX_AMOUNT}" = "4503599627370496" ]; }; then LADDER_MAX_AMOUNT="$max_wire" fi if [ -n "$min_denom" ] && { [ "${LADDER_MIN_AMOUNT}" = "0.000001" ] || [ "${LADDER_MIN_AMOUNT}" = "0.00000001" ]; }; then LADDER_MIN_AMOUNT="$min_denom" fi # Slightly fewer rungs on stage (full GOA 23 still ok if user set LADDER_STEPS) if [ "${LADDER_STEPS}" = "23" ]; then LADDER_STEPS=15 fi # Stage farmer shops: private goa-demo instance does not exist if [ "${MERCHANT_INSTANCE}" = "goa-demo-cp4zqk" ]; then MERCHANT_INSTANCE="${LADDER_PAY_TEMPLATE_INSTANCE:-fermes-des-collines}" INST="$MERCHANT_INSTANCE" fi # Prefer public templates for pays when no merchant token (set later) : "${E2E_USE_TEMPLATES:=1}" export E2E_USE_TEMPLATES export LADDER_MAX_AMOUNT LADDER_MIN_AMOUNT LADDER_STEPS MERCHANT_INSTANCE info "ladder stack" "TESTPAYSAN · max=${CUR}:${LADDER_MAX_AMOUNT} min=${CUR}:${LADDER_MIN_AMOUNT} steps=${LADDER_STEPS} (from bank max_wire / keys denoms)" } apply_ladder_stack_defaults SCRATCH=$(mktemp -d) WDB="$SCRATCH/wallet.sqlite3" REPORT_DIR="${LADDER_REPORT_DIR:-$SCRATCH}" mkdir -p "$REPORT_DIR" TSV="$REPORT_DIR/ladder-results.tsv" PAY_TSV="$REPORT_DIR/ladder-pay-results.tsv" JSON="$REPORT_DIR/ladder-report.json" LOAD_BEFORE="$REPORT_DIR/load-before.json" LOAD_AFTER="$REPORT_DIR/load-after.json" echo -e "rung\trange\tamount\tstatus\tms_mint\tms_accept\tms_confirm\tms_settle\tms_total\twid\tnote" >"$TSV" echo -e "rung\trange\tamount\tstatus\tms_order\tms_handle\tms_settle\tms_total\toid\tnote" >"$PAY_TSV" ladder_over() { local now now=$(date +%s) [ $((now - SECTION_T0)) -ge "$LADDER_TIMEOUT_S" ] } wcli() { if [ -n "${CLI_JS:-}" ] && [ -f "$CLI_JS" ]; then node "$CLI_JS" --wallet-db="$WDB" --no-throttle "$@" else taler-wallet-cli --wallet-db="$WDB" --no-throttle "$@" fi } # Explorer pool password: env → file override → stack secrets → SSH resolve_explorer_pw() { local pw="" f="" host="" if [ -n "${EXP_PW:-}" ]; then printf '%s' "$EXP_PW" return 0 fi if [ -n "${EXP_PW_FILE:-}" ] && [ -f "$EXP_PW_FILE" ]; then tr -d '\n\r' <"$EXP_PW_FILE" return 0 fi # Stage TESTPAYSAN — never use GOA koopa-admin-secrets explorer pw first if [ "${CUR}" = "TESTPAYSAN" ] \ || [[ "${TALER_DOMAIN:-}" == stage.*lefrancpaysan* ]] \ || [[ "${TALER_DOMAIN:-}" == *stage.lefrancpaysan* ]]; then for f in \ ${FRANCPAYSAN_SECRETS:+"${FRANCPAYSAN_SECRETS}/stage/bank-explorer-password.txt"} \ "${HOME}/.config/taler-landing/stage-bank-explorer-password.txt" do [ -n "$f" ] && [ -f "$f" ] || continue tr -d '\n\r' <"$f" return 0 done _remote_exp="${FRANCPAYSAN_REMOTE_BANK_EXPLORER_SECRET:-}" [ -z "$_remote_exp" ] && [ -n "${FRANCPAYSAN_REMOTE_SECRETS_ROOT:-}" ] && \ _remote_exp="${FRANCPAYSAN_REMOTE_SECRETS_ROOT}/bank/secrets/bank-explorer-password.txt" for host in ${INSIDE_SSH:+"$INSIDE_SSH"} ${FRANCPAYSAN_SSH:+"$FRANCPAYSAN_SSH"}; do [ -n "$host" ] && [ -n "$_remote_exp" ] || continue pw=$(ssh -o BatchMode=yes -o ConnectTimeout=12 "$host" \ "tr -d '\\n\\r' <${_remote_exp} 2>/dev/null || sudo tr -d '\\n\\r' <${_remote_exp} 2>/dev/null" \ 2>/dev/null || true) if [ -n "$pw" ]; then printf '%s' "$pw" return 0 fi done return 1 fi # GOA / default: SECRETS_ROOT / ~/.config / koopa SSH if pw=$(read_secret "taler-bank/bank-explorer-password.txt" 2>/dev/null) && [ -n "$pw" ]; then printf '%s' "$pw" return 0 fi for f in \ "${SECRETS_ROOT:+${SECRETS_ROOT}/taler-bank/bank-explorer-password.txt}" \ "${HOME}/.config/taler-landing/bank-explorer-password.txt" do [ -n "$f" ] && [ -f "$f" ] || continue tr -d '\n\r' <"$f" return 0 done return 1 } wallet_avail() { wcli balance 2>/dev/null | python3 -c ' import json,sys t=sys.stdin.read() i=t.find("{") if i<0: print("0"); raise SystemExit d=json.loads(t[i:t.rfind("}")+1]) cur=sys.argv[1] for b in d.get("balances") or []: a=b.get("available") or "" if a.startswith(cur+":"): print(a.split(":",1)[1]); raise SystemExit print("0") ' "$CUR" 2>/dev/null || echo "0" } # Build paired pay + withdraw ladders (same step count, shared random shape). # Pay: [0] + log-uniform mids + [max-1] + [max] # Wd: [0] + max(mid, mid×scale) mids + [max-1] + [max] (mids higher → enough to spend) # Writes: $1 = withdraw list file, $2 = pay list file (space-separated CUR:amt lines as one line each) build_ladder_pair() { local wd_out="$1" pay_out="$2" python3 - <<'PY' "$CUR" "${LADDER_MAX_AMOUNT}" "${LADDER_STEPS}" "${LADDER_MIN_AMOUNT}" "${LADDER_WITHDRAW_SCALE}" "$wd_out" "$pay_out" import math, random, sys from decimal import Decimal, ROUND_HALF_UP, ROUND_UP from pathlib import Path cur = sys.argv[1] max_amt = Decimal(sys.argv[2]) steps = max(1, int(sys.argv[3])) min_amt = Decimal(sys.argv[4]) scale = Decimal(sys.argv[5]) wd_path, pay_path = Path(sys.argv[6]), Path(sys.argv[7]) if min_amt <= 0: min_amt = Decimal("0.000001") if min_amt >= max_amt: min_amt = max_amt / Decimal(1000) if scale < 1: scale = Decimal(1) max_m1 = max_amt - Decimal(1) if max_amt > 1 else max_amt def fmt(v: Decimal) -> str: q = v.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) if q == q.to_integral(): return format(int(q), "d") return format(q, "f").rstrip("0").rstrip(".") def quant(v: Decimal) -> Decimal: if v >= 1: return v.quantize(Decimal(1), rounding=ROUND_HALF_UP) if v < min_amt: return min_amt return v.quantize(Decimal("0.00000001"), rounding=ROUND_UP) def amt(v: Decimal) -> str: return "%s:%s" % (cur, fmt(v)) def make_mids(n_mid: int, hi_cap: Decimal): if n_mid <= 0 or hi_cap <= min_amt: return [] lo, hi = float(min_amt), float(hi_cap) * 0.999999 if hi <= lo: hi = lo * 10 cuts = sorted(math.exp(random.uniform(math.log(lo), math.log(hi))) for _ in range(n_mid)) out, prev = [], Decimal(0) for c in cuts: v = quant(Decimal(str(c))) if v <= prev: step = min_amt if prev < 1 else max(prev * Decimal("1e-6"), Decimal(1)) v = quant(prev + step) if v >= hi_cap: v = quant(hi_cap - (Decimal(1) if hi_cap > 1 else min_amt)) if v <= prev or v >= hi_cap: continue out.append(v) prev = v return out # Build withdraw ladder first (full range), then pay mids = withdraw/scale # so each pay mid is always cheaper than the matching withdraw mid. if steps == 1: wd_vals = [max_amt] elif steps == 2: wd_vals = [Decimal(0), max_amt] else: n_mid = max(0, steps - 3) wd_vals = [Decimal(0)] + make_mids(n_mid, max_m1) + [max_m1, max_amt] while len(wd_vals) > steps and len(wd_vals) > 3: wd_vals.pop(len(wd_vals) // 2) while len(wd_vals) < steps and len(wd_vals) >= 2: i = max(1, len(wd_vals) - 2) a, b = wd_vals[i - 1], wd_vals[i] if a <= 0: m = max(min_amt, b / 2 if b > 0 else min_amt) else: m = (a * b).sqrt() if a * b > 0 else (a + b) / 2 m = quant(m) if m <= a or m >= b: break wd_vals.insert(i, m) wd_vals = wd_vals[:steps] pay_vals = [] prev_p = Decimal(-1) for i, w in enumerate(wd_vals): is_first = i == 0 is_last = i == len(wd_vals) - 1 is_m1 = (not is_last) and w == max_m1 and i == len(wd_vals) - 2 if is_first and w == 0: pay_vals.append(Decimal(0)) prev_p = Decimal(0) continue if is_last: pay_vals.append(max_amt) continue if is_m1 or w == max_m1: pay_vals.append(max_m1) prev_p = max_m1 continue # pay mid = withdraw / scale (strictly less funding needed per step) p = quant(w / scale) if scale > 0 else w if p < min_amt and w >= min_amt: p = min_amt if p <= prev_p: p = quant(prev_p + (min_amt if prev_p < 1 else Decimal(1))) if p >= w: # keep pay strictly below this withdraw rung when possible p = quant(w - (Decimal(1) if w > 1 else min_amt)) if w > prev_p else prev_p if p <= prev_p: p = prev_p # flat ok only if stuck; still ≤ w pay_vals.append(p) prev_p = p assert len(pay_vals) == len(wd_vals) # sanity: every non-pin pay mid ≤ matching withdraw mid for i, (p, w) in enumerate(zip(pay_vals, wd_vals)): if i == 0 or i >= len(wd_vals) - 2: continue if p > w: pay_vals[i] = w wd_path.write_text(" ".join(amt(v) for v in wd_vals) + "\n") pay_path.write_text(" ".join(amt(v) for v in pay_vals) + "\n") print(" ".join(amt(v) for v in wd_vals)) PY } # shellcheck source=metrics.sh source "$ROOT/metrics.sh" METRICS_DIR="$REPORT_DIR" ALT_UNITS_FILE="${REPORT_DIR}/alt_unit_names.json" export METRICS_DIR CUR WDB CLI_JS ALT_UNITS_FILE # Ladder can disable host load without killing coin metrics if [ "${LADDER_LOAD:-1}" = "0" ]; then METRICS_LOAD=0 export METRICS_LOAD fi section "ladder · ${CUR} withdraw (0 → random → max · ${LADDER_STEPS} steps)" info "bank" "$BANK" info "exchange" "$EX" info "currency" "$CUR" # alt_unit_names for human amounts (Kilo-GOA / Mega-GOA / … + GOA in parentheses) if metrics_load_alt_units "${EX%/}/config"; then info "alt_unit_names" "from ${EX%/}/config → $ALT_UNITS_FILE" else warn "alt_unit_names" "using built-in SI fallback ($ALT_UNITS_FILE)" fi info "budget" "${LADDER_TIMEOUT_S}s" _max_m1=$(python3 -c 'import sys; from decimal import Decimal; m=Decimal(sys.argv[1]); print(m-1 if m>1 else m)' "${LADDER_MAX_AMOUNT}" 2>/dev/null || echo "${LADDER_MAX_AMOUNT}-1") info "steps" "${LADDER_STEPS} (0 + random≥${LADDER_MIN_AMOUNT} + max-1=${CUR}:${_max_m1} + max=${CUR}:${LADDER_MAX_AMOUNT})" info "withdraw_scale" "${LADDER_WITHDRAW_SCALE}× pay mids (fund pay ladder)" info "pay_phase" "$([ "${LADDER_PAY}" = "1" ] && echo enabled || echo disabled)" info "currency/domain" "${CUR} · ${TALER_DOMAIN:-?} · bank=${BANK}" set_group load section "ladder · load snapshot (before withdraws)" metrics_report_load "$LOAD_BEFORE" "ladder-start" || true # Fresh wallet per rung — baseline empty (or last-rung DB if re-used later) metrics_report_coins "ladder-start" || true if ! EXP_PW=$(resolve_explorer_pw); then err bank "explorer password missing" \ "set EXP_PW / EXP_PW_FILE or SECRETS_ROOT=…/koopa/host-root (taler-bank/bank-explorer-password.txt)${SECRETS_ROOT:+ · SECRETS_ROOT=${SECRETS_ROOT}}" secrets_hint 2>/dev/null || true exit 1 fi if [ -n "${SECRETS_ROOT:-}" ]; then info "explorer secret" "resolved (SECRETS_ROOT=${SECRETS_ROOT} · stage uses stagepaysan secret first when CUR=TESTPAYSAN)" else info "explorer secret" "resolved via EXP_PW / EXP_PW_FILE / stage SSH / koopa" fi if [ -n "${CLI_JS:-}" ] && [ -f "${CLI_JS}" ]; then info "wallet-cli" "$CLI_JS" else info "wallet-cli" "PATH taler-wallet-cli ($(command -v taler-wallet-cli 2>/dev/null || echo missing))" fi # --- auto-account --- t0=$(now_ms) if ! curl -sS -m 30 -o "$SCRATCH/auto-account.json" "${BANK}/intro/auto-account.json"; then err bank "auto-account.json unreachable" exit 1 fi ms_auto=$(elapsed_ms "$t0") if ! python3 -c 'import json; d=json.load(open("'"$SCRATCH"'/auto-account.json")); assert d.get("ok") or d.get("username")' 2>/dev/null; then err bank "auto-account create failed" "$(head -c 120 "$SCRATCH/auto-account.json" | tr '\n' ' ')" exit 1 fi ACCT_USER=$(python3 -c 'import json; print(json.load(open("'"$SCRATCH"'/auto-account.json"))["username"])') ok "auto-account ${ACCT_USER} (${ms_auto}ms) — personal ${CUR}:0; pool=explorer" info "auto-account password" "(see $SCRATCH/auto-account.json — not logged)" # --- explorer token --- t0=$(now_ms) TOK=$(curl -sS -m 20 -u "${EXP_USER}:${EXP_PW}" \ -H 'Content-Type: application/json' \ -d '{"scope":"readwrite","duration":{"d_us":3600000000}}' \ "${BANK}/accounts/${EXP_USER}/token" \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])') ms_tok=$(elapsed_ms "$t0") [ -n "$TOK" ] || { err bank "explorer token failed"; exit 1; } ok "explorer token (${ms_tok}ms)" # ONE cumulative wallet for all withdraws + pays (need balance to spend). # force-select uses *last* reserve_pub from the current accept output. wallet_prepare() { local label="${1:-wallet}" WDB="$SCRATCH/wallet-${label}.sqlite3" export WDB if [ ! -f "$WDB" ]; then wcli exchanges add "$EX" >"$SCRATCH/ex-add-$label.out" 2>&1 || true wcli exchanges update "$EX" >"$SCRATCH/ex-upd-$label.out" 2>&1 || true wcli exchanges accept-tos "$EX" >"$SCRATCH/ex-tos-$label.out" 2>&1 || true fi } t0=$(now_ms) rm -f "$SCRATCH/wallet-main.sqlite3" wallet_prepare "main" ms_tos=$(elapsed_ms "$t0") ok "wallet exchange + ToS (${ms_tos}ms) — cumulative DB for withdraw+pay (no run-until-done)" build_ladder_pair "$SCRATCH/ladder-wd-plan.txt" "$SCRATCH/ladder-pay-plan.txt" LADDER_LIST=$(tr -d '\n' <"$SCRATCH/ladder-wd-plan.txt") PAY_LIST=$(tr -d '\n' <"$SCRATCH/ladder-pay-plan.txt") : "${LADDER_MAX_RUNGS:=99}" # shellcheck disable=SC2086 set -- $LADDER_LIST if [ "$#" -gt "$LADDER_MAX_RUNGS" ]; then # shellcheck disable=SC2046 set -- $(printf '%s\n' "$@" | head -n "$LADDER_MAX_RUNGS") fi LADDER_N=$# info "withdraw plan" "$*" info "withdraw plan (alt)" "$(format_amount_list_alt "$@")" info "pay plan" "$PAY_LIST" # shellcheck disable=SC2086 info "pay plan (alt)" "$(format_amount_list_alt $PAY_LIST)" printf '%s\n' "$@" >"$SCRATCH/ladder-plan.txt" printf '%s\n' $PAY_LIST >"$SCRATCH/ladder-pay-plan-lines.txt" 2>/dev/null || true OK_N=0 FAIL_N_L=0 PAY_OK_N=0 PAY_FAIL_N=0 STOP_REASON="" STOP_AMOUNT="" declare -a RUNG_JSON=() set_group withdraw section "ladder · phase A · withdraw (${LADDER_N} rungs)" rung=0 for AMT in "$@"; do rung=$((rung + 1)) if ladder_over; then STOP_REASON="timeout budget ${LADDER_TIMEOUT_S}s" warn ladder "time budget exhausted" \ "problem: LADDER_TIMEOUT_S=${LADDER_TIMEOUT_S}s reached after ${OK_N} ok rungs; remaining amounts not tried" break fi section "ladder · rung $rung $AMT · $(format_amount_alt "$AMT")" tag=$(printf '%s' "$AMT" | tr '.:' '__') # TSV "range" column: fixed pins at ends, random mids (refined after AMT_NUM) if [ "$rung" -eq 1 ]; then range_note="pin:0" elif [ "$rung" -eq "$LADDER_N" ]; then range_note="pin:max" elif [ "$rung" -eq $((LADDER_N - 1)) ] && [ "$LADDER_N" -ge 3 ]; then range_note="pin:max-1" else range_note="random" fi t_rung=$(now_ms) ms_mint=0 ms_accept=0 ms_confirm=0 ms_settle=0 note="" status="FAIL" WID="-" FORCE_SEL_409_LOGGED=0 # keep cumulative main wallet wallet_prepare "main" # mint from explorer pool t0=$(now_ms) code=$(curl -sS -m 30 -o "$SCRATCH/wd-$tag.json" -w '%{http_code}' \ -H "Authorization: Bearer ${TOK}" \ -H 'Content-Type: application/json' \ -d "{\"amount\":\"${AMT}\"}" \ "${BANK}/accounts/${EXP_USER}/withdrawals") ms_mint=$(elapsed_ms "$t0") WID=$(python3 -c 'import json;d=json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json"));print(d.get("withdrawal_id") or "")' 2>/dev/null || true) URI=$(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) # numeric amount (for zero / settle / ceiling special-cases) AMT_NUM=$(python3 -c 'import sys; print(sys.argv[1].split(":",1)[-1])' "$AMT") IS_ZERO=0 python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])==0 else 1)' "$AMT_NUM" 2>/dev/null && IS_ZERO=1 IS_MAX_PIN=0 IS_MAX_M1_PIN=0 if [ "$AMT_NUM" = "${LADDER_MAX_AMOUNT}" ]; then IS_MAX_PIN=1 range_note="pin:max" elif [ "$AMT_NUM" = "$((LADDER_MAX_AMOUNT - 1))" ] 2>/dev/null || \ [ "$AMT_NUM" = "$(python3 -c 'print(int("'"$LADDER_MAX_AMOUNT"'")-1)')" ]; then IS_MAX_M1_PIN=1 range_note="pin:max-1" fi if [ "$code" != "200" ] && [ "$code" != "201" ] || [ -z "$WID" ] || [ -z "$URI" ]; then # strip quotes so STOP_REASON never breaks later shell/python argv note="mint HTTP $code $(head -c 100 "$SCRATCH/wd-$tag.json" 2>/dev/null | tr '\n\"' ' ')" ms_total=$(elapsed_ms "$t_rung") if [ "$IS_ZERO" = "1" ]; then # Probe only: bank may reject GOA:0 — record and continue ladder status="ZERO_REJECT" note="zero-withdraw rejected (expected possible): $note" warn bank "mint $AMT rejected" \ "problem: bank will not create a GOA:0 withdrawal (zero amount probe). Ladder continues. detail: $note" echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" OK_N=$((OK_N + 1)) continue fi # Absolute max is a ceiling probe — bank often returns SQL P0001/5110; do not abort. if [ "$IS_MAX_PIN" = "1" ]; then status="CEILING_REJECT" note="absolute max rejected (ceiling probe; max-1 is the hard pin): $note" warn bank "mint $AMT rejected (ceiling)" \ "problem: bank rejects absolute LADDER_MAX_AMOUNT (often SQL P0001/5110). max-1 rung is the last expected success. Ladder continues to report. detail: $note" echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" continue fi err bank "mint $AMT failed" "$note" status="FAIL_MINT" STOP_REASON="$note" STOP_AMOUNT="$AMT" echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" FAIL_N_L=$((FAIL_N_L + 1)) break fi ok "mint $AMT ($WID) ${ms_mint}ms" before=$(wallet_avail) # accept t0=$(now_ms) if wcli withdraw accept-uri --exchange "$EX" "$URI" >"$SCRATCH/accept-$tag.out" 2>&1; then ms_accept=$(elapsed_ms "$t0") ok "accept-uri $AMT ${ms_accept}ms" else ms_accept=$(elapsed_ms "$t0") note="accept-uri failed: $(tail -c 200 "$SCRATCH/accept-$tag.out" | tr '\n' ' ')" ms_total=$(elapsed_ms "$t_rung") # Wallet 7006: no denominations for this amount (0, sub-denom dust, etc.) — warn & continue if [ "$IS_ZERO" = "1" ] || grep -qE 'code: 7006|"code"[[:space:]]*:[[:space:]]*7006|No denominations could be selected' \ "$SCRATCH/accept-$tag.out" 2>/dev/null; then if [ "$IS_ZERO" = "1" ]; then status="ZERO_SKIP" note="zero-withdraw skip (7006 / no denoms): $note" warn wallet "accept $AMT skipped" \ "problem: wallet code 7006 — no coin denominations for GOA:0 (zero amount cannot be withdrawn as coins). Ladder continues. detail: $note" else status="SKIP_DENOM" note="skip amount (wallet 7006 no denoms): $note" warn wallet "accept $AMT skipped" \ "problem: wallet code 7006 — no denominations match this amount (below smallest coin or not combinable). Ladder continues with next rung. detail: $note" fi echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" continue fi err wallet "accept $AMT" "$note" status="FAIL_ACCEPT" STOP_REASON="$note" STOP_AMOUNT="$AMT" echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" FAIL_N_L=$((FAIL_N_L + 1)) break fi # Confirm ASAP when bank status is selected. No run-until-done (hangs on macOS/wallet). # Server-side auto-confirm only watches landing withdraw-watch.ids — ladder must confirm itself. bank_st() { curl -sS -m 8 "${BANK}/taler-integration/withdrawal-operation/${WID}" 2>/dev/null \ | python3 -c 'import json,sys; print((json.load(sys.stdin).get("status") or "").strip())' 2>/dev/null || true } do_confirm() { curl -sS -m 15 -o "$SCRATCH/conf-$tag.json" -w '%{http_code}' -X POST \ -H "Authorization: Bearer ${TOK}" -H 'Content-Type: application/json' -d '{}' \ "${BANK}/accounts/${EXP_USER}/withdrawals/${WID}/confirm" } # Collect reserve_pub candidates for *this* withdrawal (WID + amount). # Cumulative wallets re-print old reserves in accept/tx dumps — never trust a single "last" blindly. # Prints unique pubs one per line, preferred order first. extract_rpubs_for_wid() { python3 - "$WID" "$AMT" "$SCRATCH/accept-$tag.out" "$SCRATCH/tx-$tag.json" "$SCRATCH/used-rpubs.txt" <<'PY' import json, re, sys from pathlib import Path wid = sys.argv[1] amt = sys.argv[2] paths = sys.argv[3:5] used_path = sys.argv[5] used = set() if Path(used_path).is_file(): used = {ln.strip() for ln in open(used_path) if ln.strip()} def walk_collect(o, bag, ctx=None): ctx = dict(ctx or {}) if isinstance(o, dict): for k, v in o.items(): kl = str(k).lower() if kl in ("withdrawal_id", "withdraw_id", "wopid", "id") and isinstance(v, str): ctx["id"] = v if kl in ("amount", "rawamount", "instructedamount") and isinstance(v, str): ctx["amount"] = v if kl in ("taler_withdraw_uri", "talerwithdrawuri", "uri") and isinstance(v, str): ctx["uri"] = v if kl in ("reserve_pub", "reservepub") and isinstance(v, str) and len(v) >= 40: bag.append((v, dict(ctx))) walk_collect(v, bag, ctx) elif isinstance(o, list): for i in o: walk_collect(i, bag, ctx) raw_pubs = [] # ordered as found scored = [] # (score, pub) higher better blob_all = "" for p in paths: try: blob_all += open(p, errors="replace").read() + "\n" except Exception: pass # 1) full JSON objects in files for p in paths: try: t = open(p, errors="replace").read() except Exception: continue # whole-file JSON for m in re.finditer(r"\{", t): try: o = json.loads(t[m.start():]) except Exception: continue bag = [] walk_collect(o, bag) for pub, ctx in bag: raw_pubs.append(pub) score = 0 cid = str(ctx.get("id") or "") camt = str(ctx.get("amount") or "") curi = str(ctx.get("uri") or "") if wid and wid in cid: score += 100 if wid and wid in curi: score += 80 if amt and (camt == amt or camt.endswith(amt.split(":", 1)[-1])): score += 40 if pub in used: score -= 200 scored.append((score, pub)) # 2) regex fallback on accept file only (more current) try: acc = open(paths[0], errors="replace").read() except Exception: acc = "" for pat in ( r"\"reserve_pub\"\s*:\s*\"([A-Z0-9]{40,})\"", r"\"reservePub\"\s*:\s*\"([A-Z0-9]{40,})\"", r"reserve_pub[\"\s:=]+([A-Z0-9]{40,})", ): for m in re.finditer(pat, acc, re.I): pub = m.group(1) raw_pubs.append(pub) score = 10 # proximity to WID in accept output window = acc[max(0, m.start() - 400) : m.end() + 400] if wid and wid in window: score += 100 if amt and amt in window: score += 30 if pub in used: score -= 200 scored.append((score, pub)) # prefer high score, then later occurrence order = [] seen = set() for score, pub in sorted(scored, key=lambda x: (-x[0],), reverse=False): # sort by score desc: use reverse sorted pass for score, pub in sorted(scored, key=lambda x: x[0], reverse=True): if pub in seen or pub in used: continue seen.add(pub) order.append(pub) # append unused raw in reverse (newest-ish) for pub in reversed(raw_pubs): if pub in seen or pub in used: continue seen.add(pub) order.append(pub) for pub in order: print(pub) PY } mark_rpub_used() { local p="$1" [ -n "$p" ] || return 0 mkdir -p "$SCRATCH" 2>/dev/null || true grep -qxF "$p" "$SCRATCH/used-rpubs.txt" 2>/dev/null || echo "$p" >>"$SCRATCH/used-rpubs.txt" } force_select_if_needed() { local st_now="$1" [ "$st_now" = "pending" ] || [ -z "$st_now" ] || return 0 local rpub epayto code_fs any=0 # refresh tx dump each try (wallet may attach reserve late) wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true epayto=$(curl -sS -m 10 "${EX%/}/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 [ -z "$epayto" ]; then warn bank "force-select skipped" "problem: exchange payto empty from /keys" return 0 fi # Try candidates until bank leaves pending (200/204) or we exhaust while IFS= read -r rpub; do [ -n "$rpub" ] || continue any=1 code_fs=$(curl -sS -m 12 -o "$SCRATCH/force-sel-$tag.json" -w '%{http_code}' -X POST \ -H 'Content-Type: application/json' \ -d "{\"reserve_pub\":\"${rpub}\",\"selected_exchange\":\"${epayto}\"}" \ "${BANK}/taler-integration/withdrawal-operation/${WID}" 2>/dev/null || echo "000") if [ "$code_fs" = "200" ] || [ "$code_fs" = "204" ]; then info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}… (ok for WID ${WID:0:8})" mark_rpub_used "$rpub" return 0 fi if [ "$code_fs" = "409" ]; then # 5114 = this reserve already bound to another op — not "out of money" info "force-select" "HTTP 409 rpub=${rpub:0:12}… (stale/used reserve — not balance; trying next)" mark_rpub_used "$rpub" continue fi info "force-select" "HTTP ${code_fs} rpub=${rpub:0:12}… body=$(tr '\n' ' ' <"$SCRATCH/force-sel-$tag.json" 2>/dev/null | head -c 120)" # other errors: still try next candidate done < <(extract_rpubs_for_wid) if [ "$any" != "1" ]; then warn bank "force-select skipped" \ "problem: no reserve_pub for this withdraw (WID=${WID:0:8}…); wallet may not have selected yet" fi } # No run-until-done (hangs / banned). Read transactions only for reserve_pub candidates. wcli transactions >"$SCRATCH/tx-$tag.json" 2>&1 || true t0=$(now_ms) conf_ok=0 st="" # Poll bank status; force-select while pending; confirm as soon as selected for i in $(seq 1 "${LADDER_CONFIRM_POLLS}"); do ladder_over && break st=$(bank_st) case "$st" in selected) ccode=$(do_confirm) if [ "$ccode" = "204" ] || [ "$ccode" = "200" ]; then conf_ok=1 info "confirm" "HTTP $ccode after selected (poll $i)" else note="confirm HTTP $ccode" fi break ;; confirmed) conf_ok=1 break ;; aborted) note="withdrawal aborted by bank" break ;; esac # force-select while pending — try alternate rpubs on 5114 (not out-of-money) if [ "$st" = "pending" ] || [ -z "$st" ]; then if [ "$i" -eq 1 ] || [ "$i" -eq 2 ] || [ $((i % 3)) -eq 0 ]; then force_select_if_needed "$st" fi fi sleep 0.35 done ms_confirm=$(elapsed_ms "$t0") st=$(printf '%s' "${st:-}" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if [ "$conf_ok" != "1" ]; then note="${note:-confirm timeout last=${st:-empty}}" # Soft: not confirmed — usually stale reserve_pub (5114), NOT empty pool balance status="SKIP_CONFIRM" warn bank "confirm $AMT skipped" \ "problem: bank status='${st:-empty}' after ${LADDER_CONFIRM_POLLS} polls (want selected/confirmed). Usually reserve_pub mismatch (5114), not out-of-money — mint/accept already OK. detail: $note" ms_total=$(elapsed_ms "$t_rung") echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" continue fi ok "confirm $AMT ${ms_confirm}ms (client, on selected)" # settle: poll wallet balance + bank transfer_done only — never run-until-done t0=$(now_ms) settled=0 xfer="?" if [ "$IS_ZERO" = "1" ]; then settled=1 note="zero-amount: no coin delta expected" else for r in $(seq 1 "$LADDER_SETTLE_ROUNDS"); do ladder_over && break after=$(wallet_avail) if python3 -c " from decimal import Decimal import sys sys.exit(0 if Decimal(sys.argv[1]) > Decimal(sys.argv[2]) else 1) " "$after" "$before" 2>/dev/null; then settled=1 break fi xfer=$(curl -sS -m 10 "${BANK}/taler-integration/withdrawal-operation/${WID}" \ | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("transfer_done"), d.get("status"))' 2>/dev/null || echo "?") # bank done is enough to leave settle without hanging on wallet if echo "$xfer" | grep -qi True; then note="bank transfer_done (no run-until-done) avail=${after} $xfer" break fi sleep "$LADDER_SETTLE_SLEEP" done fi ms_settle=$(elapsed_ms "$t0") after=$(wallet_avail) ms_total=$(elapsed_ms "$t_rung") if [ -z "${xfer:-}" ] || [ "$xfer" = "?" ]; then xfer=$(curl -sS -m 10 "${BANK}/taler-integration/withdrawal-operation/${WID}" \ | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("transfer_done"), d.get("status"))' 2>/dev/null || echo "?") fi if [ "$settled" = "1" ]; then status="OK" note="${note:-avail=${CUR}:${after}}" ok "settle $AMT → ${CUR}:${after} (settle ${ms_settle}ms, rung ${ms_total}ms)" OK_N=$((OK_N + 1)) echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" metrics_report_coins "ladder-r${rung}-after-${tag}" || true metrics_record_flow withdrawn "$AMT" || true elif echo "$xfer" | grep -qi True; then status="OK_BANK" note="bank transfer_done avail=${after} $xfer (no run-until-done)" ok "settle $AMT bank transfer_done (wallet avail=${CUR}:${after}, ${ms_settle}ms)" OK_N=$((OK_N + 1)) echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" metrics_report_coins "ladder-r${rung}-after-${tag}" || true metrics_record_flow withdrawn "$AMT" || true else note="no coins / no transfer_done avail=${after} $xfer" err wallet "settle $AMT" "$note" status="FAIL_SETTLE" STOP_REASON="$note" STOP_AMOUNT="$AMT" FAIL_N_L=$((FAIL_N_L + 1)) echo -e "${rung}\t${range_note}\t${AMT}\t${status}\t${ms_mint}\t${ms_accept}\t${ms_confirm}\t${ms_settle}\t${ms_total}\t${WID}\t${note}" >>"$TSV" metrics_report_coins "ladder-r${rung}-fail-${tag}" || true break fi done # --------------------------------------------------------------------------- # Phase B — payment ladder (same shape: 0 → low → … → max-1 → max) # --------------------------------------------------------------------------- PAY_OK_N=0 PAY_FAIL_N=0 if [ "${LADDER_PAY}" = "1" ] && [ -n "${PAY_LIST:-}" ] && [ "$FAIL_N_L" -eq 0 ]; then set_group pay section "ladder · phase B · pay" metrics_report_coins "before-pay-ladder" || true # Merchant secret (same as e2e) — stage often has only public shop templates MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}" if [ -z "$MPW" ]; then MPW=$(read_secret "taler-merchant/merchant-${INST}-password.txt" 2>/dev/null || true) fi if [ -z "$MPW" ]; then MPW=$(read_secret "taler-merchant/merchant-goa-demo-cp4zqk-password.txt" 2>/dev/null || true) fi if [ -z "$MPW" ] && [ "${CUR}" = "TESTPAYSAN" ]; then if [ -n "${FRANCPAYSAN_SECRETS:-}" ] && [ -f "${FRANCPAYSAN_SECRETS}/stage/default-instance-token.txt" ]; then MPW=$(tr -d '\n\r' <"${FRANCPAYSAN_SECRETS}/stage/default-instance-token.txt") fi if [ -z "$MPW" ]; then _remote_mer="${FRANCPAYSAN_REMOTE_MERCHANT_TOKEN:-}" [ -z "$_remote_mer" ] && [ -n "${FRANCPAYSAN_REMOTE_SECRETS_ROOT:-}" ] && \ _remote_mer="${FRANCPAYSAN_REMOTE_SECRETS_ROOT}/merchant/secrets/default-instance-token.txt" for host in ${INSIDE_SSH:+"$INSIDE_SSH"} ${FRANCPAYSAN_SSH:+"$FRANCPAYSAN_SSH"}; do [ -n "$host" ] && [ -n "$_remote_mer" ] || continue MPW=$(ssh -o BatchMode=yes -o ConnectTimeout=12 "$host" \ "tr -d '\\n\\r' <${_remote_mer} 2>/dev/null || true" \ 2>/dev/null || true) [ -n "$MPW" ] && break done unset _remote_mer fi fi if [ -z "$MPW" ]; then if [ "${CUR}" = "TESTPAYSAN" ]; then # Stage: withdraw ladder is the main probe; pays need instance token or # public templates for *exact* face values (not continuous ladder amounts). info pay "no merchant token — skip pay ladder on TESTPAYSAN (withdraw maxima still covered)" info pay "hint: set E2E_MERCHANT_TOKEN or use ./taler-monitoring.sh -d stage.lefrancpaysan.ch e2e for shop templates" else warn pay "no merchant token — skip pay ladder (set E2E_MERCHANT_TOKEN or secrets)" fi else ok "merchant token" "instance ${INST}" case "$MPW" in secret-token:*) AUTH="Authorization: Bearer ${MPW}" ;; *) AUTH="Authorization: Bearer secret-token:${MPW}" ;; esac wallet_prepare "main" # shellcheck disable=SC2086 set -- $PAY_LIST PAY_N=$# info "pay rungs" "$PAY_N · $*" prung=0 for PAMT in "$@"; do prung=$((prung + 1)) if ladder_over; then warn pay "time budget exhausted" "after ${PAY_OK_N} ok pays" break fi section "ladder · pay rung $prung $PAMT · $(format_amount_alt "$PAMT")" ptag=$(printf '%s' "$PAMT" | tr '.:' '__') if [ "$prung" -eq 1 ]; then prange="pin:0" elif [ "$prung" -eq "$PAY_N" ]; then prange="pin:max" elif [ "$prung" -eq $((PAY_N - 1)) ] && [ "$PAY_N" -ge 3 ]; then prange="pin:max-1" else prange="random" fi PNUM=$(python3 -c 'import sys; print(sys.argv[1].split(":",1)[-1])' "$PAMT") IS_PZERO=0 python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])==0 else 1)' "$PNUM" 2>/dev/null && IS_PZERO=1 IS_PMAX=0 [ "$PNUM" = "${LADDER_MAX_AMOUNT}" ] && IS_PMAX=1 t_pay=$(now_ms) ms_order=0 ms_handle=0 ms_psettle=0 pnote="" pstatus="FAIL" OID="-" metrics_report_coins "before-pay-${ptag}" || true bal=$(wallet_avail) if [ "$IS_PZERO" = "1" ]; then pstatus="ZERO_SKIP" pnote="zero pay probe skipped" warn pay "pay $PAMT skipped" "problem: zero amount order not useful. Ladder continues." ms_total=$(elapsed_ms "$t_pay") echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t${ms_psettle}\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" PAY_OK_N=$((PAY_OK_N + 1)) continue fi # Soft: absolute max pay is a ceiling probe (unlikely affordable / merchant may reject) if [ "$IS_PMAX" = "1" ]; then # try once; on fail CEILING_REJECT : fi # Insufficient balance → soft skip for max pin only; hard fail for mid/max-1 if ! python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])>=Decimal(sys.argv[2]) else 1)' "$bal" "$PNUM" 2>/dev/null; then pnote="insufficient balance avail=${CUR}:${bal} need=${PAMT}" ms_total=$(elapsed_ms "$t_pay") if [ "$IS_PMAX" = "1" ]; then pstatus="CEILING_SKIP" warn pay "pay $PAMT skipped (ceiling)" "$pnote" echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t0\t0\t0\t${ms_total}\t-\t${pnote}" >>"$PAY_TSV" continue fi err pay "pay $PAMT" "$pnote" pstatus="FAIL_BALANCE" PAY_FAIL_N=$((PAY_FAIL_N + 1)) FAIL_N_L=$((FAIL_N_L + 1)) STOP_REASON="$pnote" STOP_AMOUNT="$PAMT" echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t0\t0\t0\t${ms_total}\t-\t${pnote}" >>"$PAY_TSV" break fi t0=$(now_ms) SUM_JSON=$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "ladder pay ${PAMT}" 2>/dev/null || echo '"ladder pay"') curl -skS -m 20 -o "$SCRATCH/ord-$ptag.json" -X POST \ -H "$AUTH" -H 'Content-Type: application/json' \ -d "{\"order\":{\"summary\":${SUM_JSON},\"amount\":\"${PAMT}\",\"fulfillment_message\":\"ok\"},\"create_token\":true}" \ "${MER}/instances/${INST}/private/orders" 2>"$SCRATCH/ord-$ptag.err" || true ms_order=$(elapsed_ms "$t0") 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-$ptag.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-$ptag.json" 2>/dev/null || true) if [ -z "$OID" ]; then pnote="order create failed $(head -c 80 "$SCRATCH/ord-$ptag.json" 2>/dev/null | tr '\n\"' ' ')" ms_total=$(elapsed_ms "$t_pay") if [ "$IS_PMAX" = "1" ]; then pstatus="CEILING_REJECT" warn pay "pay $PAMT rejected (ceiling)" "$pnote" echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t0\t0\t${ms_total}\t-\t${pnote}" >>"$PAY_TSV" continue fi err pay "order $PAMT" "$pnote" pstatus="FAIL_ORDER" PAY_FAIL_N=$((PAY_FAIL_N + 1)) FAIL_N_L=$((FAIL_N_L + 1)) STOP_REASON="$pnote" STOP_AMOUNT="$PAMT" echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t0\t0\t${ms_total}\t-\t${pnote}" >>"$PAY_TSV" break fi ok "order $OID ($PAMT) ${ms_order}ms" curl -skS -m 12 -o "$SCRATCH/ord-det-$ptag.json" -H "$AUTH" \ "${MER}/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-$ptag.json" 2>/dev/null || true) if [ -z "$PAYURI" ] && [ -n "$OTOK" ]; then MH=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$MER"'").hostname or "taler.hacktivism.ch")' 2>/dev/null || echo "taler.hacktivism.ch") PAYURI="taler://pay/${MH}/instances/${INST}/${OID}/?c=${OTOK}" fi PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g') if [ -z "$PAYURI" ]; then pnote="no pay URI for $OID" ms_total=$(elapsed_ms "$t_pay") err pay "uri $PAMT" "$pnote" pstatus="FAIL_URI" PAY_FAIL_N=$((PAY_FAIL_N + 1)) FAIL_N_L=$((FAIL_N_L + 1)) STOP_REASON="$pnote" STOP_AMOUNT="$PAMT" echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t0\t0\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" break fi t0=$(now_ms) if ! wcli handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$ptag.out" 2>&1; then ms_handle=$(elapsed_ms "$t0") pnote="handle-uri failed $(tail -c 100 "$SCRATCH/pay-$ptag.out" | tr '\n\"' ' ')" ms_total=$(elapsed_ms "$t_pay") if [ "$IS_PMAX" = "1" ]; then pstatus="CEILING_REJECT" warn pay "pay $PAMT handle failed (ceiling)" "$pnote" echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t0\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" continue fi err pay "handle $PAMT" "$pnote" pstatus="FAIL_HANDLE" PAY_FAIL_N=$((PAY_FAIL_N + 1)) FAIL_N_L=$((FAIL_N_L + 1)) STOP_REASON="$pnote" STOP_AMOUNT="$PAMT" echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t0\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" break fi ms_handle=$(elapsed_ms "$t0") ok "handle-uri $PAMT ${ms_handle}ms" # Short settle polls: merchant order + wallet tx only — never run-until-done t0=$(now_ms) settled=0 r=0 while [ "$r" -lt "${LADDER_PAY_SETTLE_ROUNDS}" ]; do r=$((r + 1)) wcli transactions >"$SCRATCH/tx-$ptag.out" 2>&1 || true curl -skS -m 8 -o "$SCRATCH/ord-paid-$ptag.json" -H "$AUTH" \ "${MER}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true if grep -qiE 'payment|paid|Payment' "$SCRATCH/tx-$ptag.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-$ptag.json" 2>/dev/null; then settled=1 break fi sleep 0.5 done ms_psettle=$(elapsed_ms "$t0") ms_total=$(elapsed_ms "$t_pay") after=$(wallet_avail) if [ "$settled" = "1" ]; then pstatus="OK" pnote="avail=${CUR}:${after}" ok "pay settled $PAMT → bal ${CUR}:${after} (total ${ms_total}ms)" PAY_OK_N=$((PAY_OK_N + 1)) echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t${ms_psettle}\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" metrics_report_coins "after-pay-${ptag}" || true metrics_record_flow spent "$PAMT" || true else pnote="not settled order=$OID avail=${CUR}:${after}" if [ "$IS_PMAX" = "1" ]; then pstatus="CEILING_REJECT" warn pay "pay $PAMT not settled (ceiling)" "$pnote" echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t${ms_psettle}\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" continue fi err pay "settle $PAMT" "$pnote" pstatus="FAIL_SETTLE" PAY_FAIL_N=$((PAY_FAIL_N + 1)) FAIL_N_L=$((FAIL_N_L + 1)) STOP_REASON="$pnote" STOP_AMOUNT="$PAMT" echo -e "${prung}\t${prange}\t${PAMT}\t${pstatus}\t${ms_order}\t${ms_handle}\t${ms_psettle}\t${ms_total}\t${OID}\t${pnote}" >>"$PAY_TSV" metrics_report_coins "after-pay-fail-${ptag}" || true break fi done metrics_report_coins "after-pay-ladder" || true info "pay summary" "ok=$PAY_OK_N fail=$PAY_FAIL_N tsv=$PAY_TSV" fi elif [ "${LADDER_PAY}" = "1" ] && [ "$FAIL_N_L" -gt 0 ]; then warn pay "skipped pay ladder" "withdraw phase already failed" fi ms_phase=$(python3 -c 'import sys,time; print(int((time.time()-float(sys.argv[1]))*1000))' "$SECTION_T0") # --- report --- set_group report section "ladder · report" info "auto-account" "$ACCT_USER" info "ok_rungs" "$OK_N" info "fail_rungs" "$FAIL_N_L" info "pay_ok" "$PAY_OK_N" info "pay_fail" "$PAY_FAIL_N" info "phase_ms" "$ms_phase" info "tsv" "$TSV" info "pay_tsv" "$PAY_TSV" # speed summary via python — free-text stop reason via env (JSON " in bank errors # used to break shell argv quoting → "syntax error near unexpected token '('") export LADDER_REPORT_STOP_AMOUNT="${STOP_AMOUNT:-}" export LADDER_REPORT_STOP_REASON="${STOP_REASON:-}" python3 - "$TSV" "$PAY_TSV" "$JSON" "$OK_N" "$FAIL_N_L" "$PAY_OK_N" "$PAY_FAIL_N" "$ms_phase" "$ACCT_USER" "$CUR" <<'PY' import csv, json, os, sys, statistics tsv, pay_tsv, jpath = sys.argv[1:4] ok_n, fail_n, pay_ok, pay_fail, phase_ms, acct, cur = sys.argv[4:11] stop_amt = os.environ.get("LADDER_REPORT_STOP_AMOUNT") or None stop_reason = os.environ.get("LADDER_REPORT_STOP_REASON") or None def load_rows(path): rows = [] try: with open(path, newline="") as f: for row in csv.DictReader(f, delimiter="\t"): rows.append(row) except Exception: pass return rows rows = load_rows(tsv) prows = load_rows(pay_tsv) def nums(rs, key): out = [] for row in rs: try: out.append(int(row[key])) except Exception: pass return out def stats(xs): if not xs: return {"n": 0} return { "n": len(xs), "min_ms": min(xs), "max_ms": max(xs), "avg_ms": int(sum(xs) / len(xs)), "p50_ms": int(statistics.median(xs)), } report = { "currency": cur, "auto_account": acct, "ok_rungs": int(ok_n), "fail_rungs": int(fail_n), "pay_ok": int(pay_ok), "pay_fail": int(pay_fail), "stopped_at_amount": stop_amt or None, "stop_reason": stop_reason or None, "phase_ms": int(phase_ms), "timing": { "mint": stats(nums(rows, "ms_mint")), "accept": stats(nums(rows, "ms_accept")), "confirm": stats(nums(rows, "ms_confirm")), "settle": stats(nums(rows, "ms_settle")), "rung_total": stats(nums(rows, "ms_total")), "pay_order": stats(nums(prows, "ms_order")), "pay_handle": stats(nums(prows, "ms_handle")), "pay_settle": stats(nums(prows, "ms_settle")), "pay_total": stats(nums(prows, "ms_total")), }, "rungs": rows, "pays": prows, } json.dump(report, open(jpath, "w"), indent=2) print("JSON", jpath) print("--- withdraw speed (ms) ---") for k in ("mint", "accept", "confirm", "settle", "rung_total"): v = report["timing"][k] if v.get("n"): print(" %s n=%s min=%s p50=%s avg=%s max=%s" % (k.ljust(12), v["n"], v["min_ms"], v["p50_ms"], v["avg_ms"], v["max_ms"])) print("--- pay speed (ms) ---") for k in ("pay_order", "pay_handle", "pay_settle", "pay_total"): v = report["timing"][k] if v.get("n"): print(" %s n=%s min=%s p50=%s avg=%s max=%s" % (k.ljust(12), v["n"], v["min_ms"], v["p50_ms"], v["avg_ms"], v["max_ms"])) print("--- withdraw rungs ---") for row in rows: print(" %2s %-16s %-12s total=%sms" % (row.get("rung"), row.get("amount"), row.get("status"), row.get("ms_total"))) print("--- pay rungs ---") for row in prows: print(" %2s %-16s %-12s total=%sms oid=%s" % (row.get("rung"), row.get("amount"), row.get("status"), row.get("ms_total"), row.get("oid"))) if stop_amt: print("STOPPED at %s: %s" % (stop_amt, stop_reason)) else: print("Completed without hard failure (or budget stop without fail).") PY wcli balance 2>&1 | tee "$REPORT_DIR/balance-final.out" | tail -20 || true set_group load section "ladder · load snapshot (after withdraws)" metrics_report_load "$LOAD_AFTER" "ladder-end" || true if [ -f "$LOAD_BEFORE" ] && [ -f "$LOAD_AFTER" ]; then section "metrics · ladder load delta" metrics_print_load_delta "$LOAD_BEFORE" "$LOAD_AFTER" || true fi # Speed timings → metrics overall (min/p50/avg/max per phase) if [ -f "$JSON" ]; then python3 - "$JSON" "${METRICS_DIR}/perf-summary.json" <<'PY' 2>/dev/null || true import json, sys rep = json.load(open(sys.argv[1])) out = {} for k, v in (rep.get("timing") or {}).items(): if isinstance(v, dict) and v.get("n"): out[k] = v json.dump(out, open(sys.argv[2], "w"), indent=2) PY fi export METRICS_WITHDRAW_TSV="$TSV" export METRICS_PAY_TSV="$PAY_TSV" metrics_report_coins "ladder-end" || true metrics_print_overall "ladder final" || true # Keep scratch if LADDER_REPORT_DIR set; else copy key files to /tmp if [ -z "${LADDER_REPORT_DIR:-}" ]; then KEEP="/tmp/goa-ladder-report-$(date +%Y%m%d-%H%M%S)" mkdir -p "$KEEP" cp -a "$TSV" "$PAY_TSV" "$JSON" "$SCRATCH/auto-account.json" "$SCRATCH/ladder-plan.txt" \ "$SCRATCH/ladder-wd-plan.txt" "$SCRATCH/ladder-pay-plan.txt" \ "$REPORT_DIR/balance-final.out" "$LOAD_BEFORE" "$LOAD_AFTER" "$KEEP/" 2>/dev/null || true info "report_dir" "$KEEP" echo "$KEEP" >"$SCRATCH/KEEP_PATH" fi if [ "$FAIL_N_L" -gt 0 ]; then blocker "ladder" "stopped at ${STOP_AMOUNT:-?} — ${STOP_REASON:-error}" exit 1 fi if [ "$OK_N" -eq 0 ]; then blocker "ladder" "no successful withdraw rungs" exit 1 fi ok "ladder finished withdraw_ok=$OK_N pay_ok=$PAY_OK_N phase=${ms_phase}ms" exit 0