From 765c528313464666dc374a43799291056a6c8229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Fri, 17 Jul 2026 02:53:44 +0200 Subject: [PATCH] feat(monitoring): ladder max-1 pin, pay ladder, cumulative wallet --- scripts/taler-monitoring/check_goa_ladder.sh | 597 ++++++++++++++++--- 1 file changed, 509 insertions(+), 88 deletions(-) diff --git a/scripts/taler-monitoring/check_goa_ladder.sh b/scripts/taler-monitoring/check_goa_ladder.sh index 954843e..41d37dc 100755 --- a/scripts/taler-monitoring/check_goa_ladder.sh +++ b/scripts/taler-monitoring/check_goa_ladder.sh @@ -7,21 +7,21 @@ # 3) wallet-cli accept-uri only (no run-until-done — hangs / developer ban) # 4) bank confirm when selected; settle = poll balance + bank transfer_done # -# Amounts: random strictly increasing; 0 and max fixed. +# Amounts: random strictly increasing; fixed pins 0, max-1, max (23 default). +# Phase A: withdraw ladder into ONE cumulative wallet (mids scaled up for pay budget). +# Phase B: pay ladder 0 → low → … → max-1 → max (same step count). # On first hard failure: stop, print timing report, exit 1. -# Soft: GOA:0 / wallet 7006 (no denoms) → WARN and continue. +# Soft: GOA:0 / wallet 7006 / absolute max (CEILING_REJECT) → WARN and continue. # # Env: -# LADDER_STEPS total rungs (default 23) = 0 + (N-2) random + max -# LADDER_MAX_AMOUNT fixed last pin (libeufin ceiling 4503599627370496) -# LADDER_TIMEOUT_S default 3600 -# LADDER_LOAD=0 skip host load snapshots +# LADDER_STEPS total rungs (default 23) = 0 + (N-3) random + max-1 + max +# LADDER_MAX_AMOUNT absolute last pin (libeufin ceiling 4503599627370496) +# LADDER_WITHDRAW_SCALE mid withdraw amounts × this vs pay mids (default 1.5) +# LADDER_PAY=0 skip payment phase +# LADDER_TIMEOUT_S default 3600 +# LADDER_LOAD=0 skip host load snapshots # LADDER_SETTLE_ROUNDS / LADDER_SETTLE_SLEEP — balance poll only (no shepherd) -# EXP_PW_FILE, LADDER_REPORT_DIR, … -# -# Path: always [0] → strictly increasing random (log-uniform) → [max] -# Only 0 and max are fixed amounts. -# Load: koopa host snapshot BEFORE withdraw ladder and AFTER (loadavg, mem, podman). +# EXP_PW_FILE, LADDER_REPORT_DIR, MERCHANT_INSTANCE, … # # Phase: ./taler-monitoring.sh ladder set -euo pipefail @@ -46,22 +46,31 @@ elapsed_ms() { : "${LADDER_MAX_AMOUNT:=4503599627370496}" : "${LADDER_STEPS:=23}" : "${LADDER_LOAD:=1}" +: "${LADDER_PAY:=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}" CUR="${EXPECT_CURRENCY:-GOA}" BANK="${BANK_PUBLIC%/}" EX="${EXCHANGE_PUBLIC%/}/" +MER="${MERCHANT_PUBLIC%/}" +INST="${MERCHANT_INSTANCE}" 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 @@ -94,21 +103,30 @@ print("0") ' "$CUR" 2>/dev/null || echo "0" } -# Build exactly LADDER_STEPS: [0] + (N-2) log-uniform random increasing + [max] -# Random mids are always ≥ LADDER_MIN_AMOUNT (smallest viable coin). -build_ladder() { - python3 - <<'PY' "$CUR" "${LADDER_MAX_AMOUNT}" "${LADDER_STEPS}" "${LADDER_MIN_AMOUNT}" +# 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(2, int(sys.argv[3])) +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) @@ -119,61 +137,134 @@ def fmt(v: Decimal) -> str: def quant(v: Decimal) -> Decimal: if v >= 1: return v.quantize(Decimal(1), rounding=ROUND_HALF_UP) - # snap up to min_amt grid for sub-unit amounts if v < min_amt: return min_amt return v.quantize(Decimal("0.00000001"), rounding=ROUND_UP) -mid = steps - 2 # between 0 and max -out = ["%s:0" % cur] -if mid > 0 and max_amt > 0: - lo, hi = float(min_amt), float(max_amt) * 0.999999 +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(mid)) - prev = Decimal(0) + 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 >= max_amt: - v = max_amt - (Decimal(1) if max_amt > 1 else min_amt) - v = quant(v) - if v <= prev or v >= max_amt: + 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("%s:%s" % (cur, fmt(v))) + out.append(v) prev = v -out.append("%s:%s" % (cur, fmt(max_amt))) -while len(out) > steps: - out.pop(len(out) // 2) -while len(out) < steps and len(out) >= 2: - i = len(out) // 2 - a = Decimal(out[i - 1].split(":", 1)[1]) - b = Decimal(out[i].split(":", 1)[1]) - 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 - out.insert(i, "%s:%s" % (cur, fmt(m))) -print(" ".join(out[:steps])) + 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" -export METRICS_DIR CUR WDB CLI_JS +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 · GOA 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" -info "steps" "${LADDER_STEPS} (0 + $((LADDER_STEPS - 2)) random≥${LADDER_MIN_AMOUNT} + max=${CUR}:${LADDER_MAX_AMOUNT})" +_max_m1=$(python3 -c 'import sys; print(int(sys.argv[1])-1)' "${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)" + +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 [ ! -f "$EXP_PW_FILE" ]; then err bank "explorer password missing" "$EXP_PW_FILE" @@ -207,24 +298,28 @@ ms_tok=$(elapsed_ms "$t0") [ -n "$TOK" ] || { err bank "explorer token failed"; exit 1; } ok "explorer token (${ms_tok}ms)" -# Fresh wallet DB per rung: without run-until-done the same reserve_pub is reused -# → bank 409 "Reserve pub already used" on later force-selects. +# 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 - rm -f "$WDB" - 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 + 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) -wallet_prepare "bootstrap" +rm -f "$SCRATCH/wallet-main.sqlite3" +wallet_prepare "main" ms_tos=$(elapsed_ms "$t0") -ok "wallet exchange + ToS (${ms_tos}ms) — fresh DB per rung (no run-until-done)" +ok "wallet exchange + ToS (${ms_tos}ms) — cumulative DB for withdraw+pay (no run-until-done)" -LADDER_LIST=$(build_ladder) +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 @@ -233,15 +328,23 @@ if [ "$#" -gt "$LADDER_MAX_RUNGS" ]; then set -- $(printf '%s\n' "$@" | head -n "$LADDER_MAX_RUNGS") fi LADDER_N=$# -info "ladder plan" "$*" +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=() +section "ladder · phase A · withdraw (${LADDER_N} rungs)" rung=0 for AMT in "$@"; do rung=$((rung + 1)) @@ -252,13 +355,15 @@ for AMT in "$@"; do break fi - section "ladder · rung $rung $AMT" + section "ladder · rung $rung $AMT · $(format_amount_alt "$AMT")" tag=$(printf '%s' "$AMT" | tr '.:' '__') - # TSV "range" column: fixed pins at ends, random mids (no legacy LADDER_RANGES) + # 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 @@ -269,8 +374,8 @@ for AMT in "$@"; do WID="-" FORCE_SEL_409_LOGGED=0 - # New wallet sqlite for this rung → unique reserve_pub (avoids bank 5114) - wallet_prepare "r${rung}" + # keep cumulative main wallet + wallet_prepare "main" # mint from explorer pool t0=$(now_ms) @@ -282,13 +387,24 @@ for AMT in "$@"; do 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 special-cases) + # 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 - note="mint HTTP $code $(head -c 100 "$SCRATCH/wd-$tag.json" 2>/dev/null | tr '\n' ' ')" + # 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 @@ -300,6 +416,15 @@ for AMT in "$@"; do 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" @@ -538,12 +663,14 @@ sys.exit(0 if Decimal(sys.argv[1]) > Decimal(sys.argv[2]) else 1) 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 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 else note="no coins / no transfer_done avail=${after} $xfer" err wallet "settle $AMT" "$note" @@ -552,10 +679,250 @@ sys.exit(0 if Decimal(sys.argv[1]) > Decimal(sys.argv[2]) else 1) 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 + section "ladder · phase B · pay" + metrics_report_coins "before-pay-ladder" || true + # Merchant secret (same as e2e) + 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" ]; then + warn pay "no merchant token — skip pay ladder (set E2E_MERCHANT_TOKEN or secrets)" + else + ok "merchant token" "instance ${INST}" + AUTH="Authorization: Bearer secret-token:${MPW}" + 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 (bounded — no infinite hang) + t0=$(now_ms) + settled=0 + r=0 + while [ "$r" -lt "${LADDER_PAY_SETTLE_ROUNDS}" ]; do + r=$((r + 1)) + if command -v perl >/dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' 10 \ + "$CLI_JS" --wallet-db="$WDB" --no-throttle run-until-done \ + >"$SCRATCH/pay-run-$ptag.out" 2>&1 || true + else + wcli run-until-done >"$SCRATCH/pay-run-$ptag.out" 2>&1 || true + fi + 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 + 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 + 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 --- @@ -563,22 +930,39 @@ 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 -python3 - "$TSV" "$JSON" "$OK_N" "$FAIL_N_L" "${STOP_AMOUNT:-}" "${STOP_REASON:-}" "$ms_phase" "$ACCT_USER" "$CUR" <<'PY' -import csv, json, sys, statistics -tsv, jpath, ok_n, fail_n, stop_amt, stop_reason, phase_ms, acct, cur = sys.argv[1:10] -rows = [] -with open(tsv, newline="") as f: - r = csv.DictReader(f, delimiter="\t") - for row in r: - rows.append(row) +# 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 nums(key): +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 rows: + for row in rs: try: out.append(int(row[key])) except Exception: @@ -601,41 +985,78 @@ report = { "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("ms_mint")), - "accept": stats(nums("ms_accept")), - "confirm": stats(nums("ms_confirm")), - "settle": stats(nums("ms_settle")), - "rung_total": stats(nums("ms_total")), + "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("--- speed (ms) ---") -for k, v in report["timing"].items(): +print("--- withdraw speed (ms) ---") +for k in ("mint", "accept", "confirm", "settle", "rung_total"): + v = report["timing"][k] if v.get("n"): - print(f" {k:12} n={v['n']} min={v['min_ms']} p50={v['p50_ms']} avg={v['avg_ms']} max={v['max_ms']}") -print("--- rungs ---") + 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(f" {row['rung']:>2} {row['amount']:16} {row['status']:12} total={row['ms_total']}ms mint={row['ms_mint']} accept={row['ms_accept']} conf={row['ms_confirm']} set={row['ms_settle']}") + 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(f"STOPPED at {stop_amt}: {stop_reason}") + 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 +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 +metrics_print_overall "ladder overall" || 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" "$JSON" "$SCRATCH/auto-account.json" "$SCRATCH/ladder-plan.txt" \ - "$REPORT_DIR/balance-final.out" "$KEEP/" 2>/dev/null || true + 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 @@ -645,8 +1066,8 @@ if [ "$FAIL_N_L" -gt 0 ]; then exit 1 fi if [ "$OK_N" -eq 0 ]; then - blocker "ladder" "no successful rungs" + blocker "ladder" "no successful withdraw rungs" exit 1 fi -ok "ladder finished ok_rungs=$OK_N phase=${ms_phase}ms" +ok "ladder finished withdraw_ok=$OK_N pay_ok=$PAY_OK_N phase=${ms_phase}ms" exit 0