#!/usr/bin/env bash # check_amount_ladder.sh — generic withdraw/pay amount ladder (any Taler currency) # # Orchestrator for the modular amount ladder: # Phase A withdraw — this file (classic plan or max-search) # Phase B pay — ladder/lib_pay.sh (classic list or max payable) # Force-select — ladder/extract_rpubs.py # See ladder/README.md. # # 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 # 5) pay via free-amount template (GOA: goa-free) or private orders # # Modes (LADDER_MODE): # classic — default 23-rung withdraw + matching pay plan (pins 0, max-1, max) # max — max-search: highest withdrawable AND highest payable # (pay never exceeds wallet available; GOA uses public free-amount # template goa-free by default) # # Usage: # ./taler-monitoring.sh ladder # classic # ./taler-monitoring.sh max-ladder # LADDER_MODE=max # ./taler-monitoring.sh goa-ladder # alias of ladder (phase name only) # ./taler-monitoring.sh -d stage.lefrancpaysan.ch ladder # LADDER_MODE=max LADDER_MAX_PROBES=32 ./taler-monitoring.sh ladder # # Env: LADDER_MODE, LADDER_STEPS, LADDER_MAX_AMOUNT, LADDER_MIN_AMOUNT, # LADDER_MAX_PROBES, LADDER_MAX_TOL_REL, LADDER_HIGH_RUNGS, LADDER_STACK_AUTO, # LADDER_PAY, LADDER_FREE_TEMPLATE, LADDER_FREE_TEMPLATE_INSTANCE, # 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 (any EXPECT_CURRENCY / stack) # 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}" # Free-amount public template (hacktivism: goa-free on goa-demo-cp4zqk — any GOA amount) : "${LADDER_FREE_TEMPLATE:=goa-free}" : "${LADDER_FREE_TEMPLATE_INSTANCE:=}" # 1 = create pays via public free template POST (amount in body); 0 = private orders only : "${LADDER_USE_FREE_TEMPLATE:=1}" # classic | max (max = highest withdrawable + highest payable; phase alias max-ladder) : "${LADDER_MODE:=classic}" # max-search: probe budget (0 = run until convergence / LADDER_TIMEOUT_S only) : "${LADDER_MAX_PROBES:=32}" : "${LADDER_MAX_TOL_REL:=0.001}" # first N probes = random high (log-uniform); then log-bisect between lo/hi : "${LADDER_HIGH_RUNGS:=6}" # optional seed for reproducible max-search (empty = time-based) : "${LADDER_MAX_SEED:=}" # After withdraw, wait for spendable available>0 before pay (secs total; shows countdown) : "${LADDER_PAY_WAIT_AVAILABLE_S:=180}" # Historic libeufin-ish absolute ceiling used as default LADDER_MAX_AMOUNT on GOA LADDER_ABS_CEILING="4503599627370496" GOA_LADDER_CEILING="${LADDER_ABS_CEILING}" # back-compat alias # 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 # PATH wrapper last resort if command -v taler-wallet-cli >/dev/null 2>&1; then CLI_JS="" else err prereq "taler-wallet-cli not found" \ "problem: no CLI_JS / WALLET_CLI .mjs and taler-wallet-cli not on PATH — accept-uri/pay cannot run (payments stay stale). Install wallet-cli (e.g. ~/.local/bin/taler-wallet-cli.mjs) + taler-helper-sqlite3; set WALLET_CLI= in ~/.config/taler-monitoring/env" exit 1 fi fi # helper is required for real wallet DB (otherwise accept "works" empty / no coins) if ! command -v taler-helper-sqlite3 >/dev/null 2>&1; then err prereq "taler-helper-sqlite3 not on PATH" \ "problem: wallet-cli needs taler-helper-sqlite3 for sqlite backend — put it on PATH next to wallet-cli" exit 1 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}" = "${LADDER_ABS_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 # Normalize mode (max-ladder phase / aliases) case "${LADDER_MODE}" in max|MAX|max-search|maxsearch|find-max|findmax) LADDER_MODE=max ;; classic|CLASSIC|default|plan|"" ) LADDER_MODE=classic ;; *) warn ladder "unknown LADDER_MODE=${LADDER_MODE} — using classic" LADDER_MODE=classic ;; esac # max mode: withdraw + pay hunts (LADDER_PAY=0 still disables pay entirely) : "${LADDER_FREE_TEMPLATE_INSTANCE:=${MERCHANT_INSTANCE}}" export LADDER_MODE LADDER_MAX_PROBES LADDER_MAX_TOL_REL LADDER_HIGH_RUNGS LADDER_MAX_SEED LADDER_PAY export LADDER_FREE_TEMPLATE LADDER_FREE_TEMPLATE_INSTANCE LADDER_USE_FREE_TEMPLATE LADDER_PAY_WAIT_AVAILABLE_S 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() { # taler-helper-sqlite3 is Python ≥3.11; prefer Homebrew/local python on macOS # so Apple /usr/bin/python3 3.9 does not FATAL the sqlite backend (no reservePub). local _path="${PATH:-}" case ":${_path}:" in *:/opt/homebrew/bin:*) ;; *) _path="/opt/homebrew/bin:/usr/local/bin:${_path}" ;; esac if [ -n "${CLI_JS:-}" ] && [ -f "$CLI_JS" ]; then PATH="$_path" node "$CLI_JS" --wallet-db="$WDB" --no-throttle "$@" else PATH="$_path" 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() dec=json.JSONDecoder() i=t.find("{") if i<0: print("0"); raise SystemExit try: d,_=dec.raw_decode(t,i) except Exception: # last-resort: brace slice (may fail on trailing logs) try: d=json.loads(t[i:t.rfind("}")+1]) except Exception: print("0"); raise SystemExit 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" } # pendingIncoming for same currency (same wallet DB as withdraw) — "0" if missing wallet_pending_in() { wcli balance 2>/dev/null | python3 -c ' import json,sys t=sys.stdin.read() dec=json.JSONDecoder() i=t.find("{") if i<0: print("0"); raise SystemExit try: d,_=dec.raw_decode(t,i) except Exception: try: d=json.loads(t[i:t.rfind("}")+1]) except Exception: print("0"); raise SystemExit cur=sys.argv[1] for b in d.get("balances") or []: a=b.get("pendingIncoming") or b.get("pending_incoming") 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" # Payment module (classic + max-search payable) # shellcheck source=ladder/lib_pay.sh source "$ROOT/ladder/lib_pay.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 if [ "${LADDER_MODE}" = "max" ]; then section "ladder · ${CUR} max-search (find highest withdrawable)" else section "ladder · ${CUR} classic (0 → random → max · ${LADDER_STEPS} steps)" fi info "bank" "$BANK" info "exchange" "$EX" info "currency" "$CUR" info "ladder mode" "${LADDER_MODE}" # alt_unit_names for human amounts (Kilo-GOA / Mega-GOA / … + base 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") if [ "${LADDER_MODE}" = "max" ]; then info "max-search" "probes≤${LADDER_MAX_PROBES} high_rand=${LADDER_HIGH_RUNGS} tol=${LADDER_MAX_TOL_REL} ceiling=${CUR}:${LADDER_MAX_AMOUNT}" else 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)" fi 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 ok "wallet-cli" "$CLI_JS" else ok "wallet-cli" "PATH $(command -v taler-wallet-cli)" fi ok "wallet-helper" "$(command -v taler-helper-sqlite3)" # prove wcli can start (avoids minting 32 probes then SKIP_ACCEPT command-not-found) if ! wcli balance >"$SCRATCH/wcli-smoke.out" 2>&1; then err prereq "wallet-cli smoke failed" \ "problem: wcli balance failed — fix CLI_JS/PATH/helper. detail: $(tr '\n' ' ' <"$SCRATCH/wcli-smoke.out" | head -c 240)" exit 1 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)" : "${LADDER_MAX_RUNGS:=99}" if [ "${LADDER_MODE}" = "max" ]; then info "ladder mode" "max-search (highest withdrawable + highest payable ${CUR})" info "max-search params" "probes≤${LADDER_MAX_PROBES} high_rand=${LADDER_HIGH_RUNGS} tol_rel=${LADDER_MAX_TOL_REL} ceiling=${CUR}:${LADDER_MAX_AMOUNT}" info "max-search pay" "LADDER_PAY=${LADDER_PAY} free_template=${LADDER_FREE_TEMPLATE}@${LADDER_FREE_TEMPLATE_INSTANCE:-$INST}" LADDER_LIST="" PAY_LIST="" LADDER_N=0 set -- printf '%s\n' "# max-search (amounts chosen adaptively)" >"$SCRATCH/ladder-plan.txt" : >"$SCRATCH/ladder-wd-plan.txt" : >"$SCRATCH/ladder-pay-plan.txt" else info "ladder mode" "classic (0 → random mids → max-1 → max)" 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") # 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" # shellcheck disable=SC2086 printf '%s\n' $PAY_LIST >"$SCRATCH/ladder-pay-plan-lines.txt" 2>/dev/null || true fi OK_N=0 FAIL_N_L=0 PAY_OK_N=0 PAY_FAIL_N=0 STOP_REASON="" STOP_AMOUNT="" MAX_BEST_AMT="" MAX_LO="0" MAX_HI="${LADDER_MAX_AMOUNT}" declare -a RUNG_JSON=() # --------------------------------------------------------------------------- # One withdraw rung. Caller sets: AMT, range_note, rung. # Returns 0 = soft continue (ok/skip/ceiling); 1 = hard stop. # Sets: status, AMT_NUM, WID, note, ms_*. # --------------------------------------------------------------------------- ladder_withdraw_rung() { 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" return 1 fi # section header is printed by classic/max controller (avoids double banners) tag=$(printf '%s' "$AMT" | tr '.:' '__') # range_note set by caller (classic pins / max-search labels) 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) # libeufin often emits taler://withdraw/host:443/… — strip default ports (lib.sh) URI=$(python3 -c 'import json;print(json.load(open("'"$SCRATCH"'/wd-'"$tag"'.json")).get("taler_withdraw_uri") or "")' 2>/dev/null || true) URI=$(normalize_taler_uri "$URI") if taler_uri_has_default_port "$URI"; then warn bank "withdraw URI still has default port" "problem: $URI" fi # 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)) return 0 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" return 0 fi # max-1 can also hit the same libeufin SQL ceiling (5110/P0001) on live GOA — # treat as soft ceiling when mid rungs already passed; do not block the report. if [ "$IS_MAX_M1_PIN" = "1" ] && echo "$note" | grep -qE '5110|P0001'; then status="CEILING_REJECT" note="max-1 rejected (same ceiling as absolute max): $note" warn bank "mint $AMT rejected (max-1 ceiling)" \ "problem: bank rejects max-1 with SQL P0001/5110 (pool ceiling). Mid-rung OK_BANK/OK still count. 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" return 0 fi # max-search: any mint reject is "too high" / ceiling — never hard-stop the hunt if [ "${LADDER_MODE}" = "max" ]; then status="CEILING_REJECT" note="max-search too-high mint: $note" warn bank "mint $AMT rejected (max-search)" \ "problem: mint failed while hunting max withdrawable — treat as upper bound. 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" return 0 fi # Classic mid/high random rungs: same 5110/P0001 pool ceiling as pins — soft, not FAIL_MINT. if echo "$note" | grep -qE '5110|P0001'; then status="CEILING_REJECT" note="amount above live mint ceiling (5110/P0001): $note" warn bank "mint $AMT rejected (ceiling)" \ "problem: bank mint HTTP 500/5110 SQL P0001 on high amount (not only pin:max). Ladder continues; use max-ladder to bound hi. 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" return 0 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)) return 1 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" return 0 fi if [ "${LADDER_MODE}" = "max" ]; then status="SKIP_ACCEPT" warn wallet "accept $AMT skipped (max-search)" "$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" return 0 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)) return 1 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. # # Critical: wallet-cli dumps mix log lines + JSON. Never json.loads(rest_of_file) — # trailing "Shutdown requested" lines make that always fail. Use JSONDecoder.raw_decode # and regex on *both* accept and transactions output (reservePub lives in tx, not accept). extract_rpubs_for_wid() { # External module — never feed extract via bash heredoc (refactor tools # lives in ladder/extract_rpubs.py). local _ext="$ROOT/ladder/extract_rpubs.py" if [ ! -f "$_ext" ]; then warn bank "force-select extract missing" "problem: $_ext not found" return 0 fi python3 "$_ext" "$WID" "$AMT" \ "$SCRATCH/accept-$tag.out" "$SCRATCH/tx-$tag.json" "$SCRATCH/used-rpubs.txt" } 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" return 0 fi ok "confirm $AMT ${ms_confirm}ms (client, on selected)" # settle: poll wallet balance + bank transfer_done — never run-until-done. # Prefer real coin delta (status OK). If bank transfer_done first, keep polling # briefly for available coins (OK) before accepting OK_BANK (pay needs available). t0=$(now_ms) settled=0 xfer="?" bank_done=0 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 "?") if echo "$xfer" | grep -qi True; then bank_done=1 # nudge wallet to pick up wire (bounded; not run-until-done) wcli transactions >"$SCRATCH/tx-settle-$tag.out" 2>&1 || true if command -v timeout >/dev/null 2>&1 || command -v gtimeout >/dev/null 2>&1; then _to=$(command -v gtimeout 2>/dev/null || command -v timeout) "$_to" 8 wcli advanced run-pending >"$SCRATCH/run-pending-settle.out" 2>&1 || true fi # after bank done, still prefer coins; allow remaining rounds for available note="bank transfer_done waiting coins avail=${after} $xfer" fi sleep "$LADDER_SETTLE_SLEEP" done # If bank done but still no coin delta after all rounds, OK_BANK (pay-wait may finish) if [ "$settled" != "1" ] && [ "$bank_done" = "1" ]; then note="bank transfer_done avail=$(wallet_avail) $xfer (no run-until-done)" fi 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" if [ "${LADDER_MODE}" = "max" ]; then status="SKIP_SETTLE" warn wallet "settle $AMT skipped (max-search)" \ "problem: bank confirm ok but settle inconclusive — do not move max bounds. 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" return 0 fi 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 return 1 fi return 0 } set_group withdraw # --- next amount for max-search (prints: AMT_NUM|range_note|action) --- # action: probe | done max_search_next() { python3 - "$CUR" "$MAX_LO" "$MAX_HI" "$LADDER_MIN_AMOUNT" \ "$LADDER_MAX_TOL_REL" "$rung" "$LADDER_HIGH_RUNGS" "$LADDER_MAX_SEED" \ "${MAX_BEST_OK:-}" <<'PY' import math, random, sys from decimal import Decimal, ROUND_HALF_UP, ROUND_UP cur = sys.argv[1] lo = Decimal(sys.argv[2] or "0") hi = Decimal(sys.argv[3]) min_amt = Decimal(sys.argv[4]) tol = Decimal(sys.argv[5] or "0.001") probe_i = int(sys.argv[6]) high_n = max(1, int(sys.argv[7] or "4")) seed = sys.argv[8].strip() best = sys.argv[9].strip() if seed: random.seed(int(seed) + probe_i) else: random.seed() if min_amt <= 0: min_amt = Decimal("0.000001") if hi <= min_amt: print("0|max:done|done") raise SystemExit(0) if lo > 0 and hi > lo: gap = (hi - lo) / hi abs_step = Decimal(1) if hi >= 1 else min_amt if gap <= tol or (hi - lo) <= abs_step: print("%s|max:done|done" % (best or str(lo))) raise SystemExit(0) def fmt(v: Decimal) -> str: if v >= 1: q = v.quantize(Decimal(1), rounding=ROUND_HALF_UP) return format(int(q), "d") q = v.quantize(Decimal("0.00000001"), rounding=ROUND_UP) s = format(q, "f").rstrip("0").rstrip(".") return s or "0" def clamp(v: Decimal): if v <= lo and lo > 0: v = lo + (Decimal(1) if lo >= 1 else min_amt) if v >= hi: v = hi - (Decimal(1) if hi > 1 else min_amt) if v < min_amt: v = min_amt if v <= 0: v = min_amt if v >= hi: return None return v if probe_i < high_n: band_lo = max(lo if lo > 0 else min_amt, hi * Decimal("0.15")) band_hi = hi * Decimal("0.999") if band_hi <= band_lo: band_lo = max(min_amt, hi * Decimal("0.5")) band_hi = hi * Decimal("0.99") flo, fhi = float(band_lo), float(band_hi) if flo <= 0: flo = float(min_amt) if fhi <= flo: fhi = flo * 2 v = Decimal(str(math.exp(random.uniform(math.log(flo), math.log(fhi))))) v = clamp(v) if v is None: print("%s|max:done|done" % (best or str(lo))) raise SystemExit(0) print("%s|max:high-rand|probe" % fmt(v)) raise SystemExit(0) if lo <= 0: flo = float(max(min_amt, hi * Decimal("0.05"))) fhi = float(hi * Decimal("0.85")) if fhi <= flo: fhi = flo * 1.5 v = Decimal(str(math.exp(random.uniform(math.log(flo), math.log(fhi))))) label = "max:descent" else: v = (lo * hi).sqrt() label = "max:bisect" v = clamp(v) if v is None: print("%s|max:done|done" % (best or str(lo))) raise SystemExit(0) print("%s|%s|probe" % (fmt(v), label)) PY } if [ "${LADDER_MODE}" = "max" ]; then section "ladder · phase A · max-search (find highest withdrawable ${CUR})" info "max-search" "ceiling=${CUR}:${LADDER_MAX_AMOUNT} min=${CUR}:${LADDER_MIN_AMOUNT} probes≤${LADDER_MAX_PROBES} high_rand=${LADDER_HIGH_RUNGS} tol_rel=${LADDER_MAX_TOL_REL}" info "max-search" "pay phase $([ "${LADDER_PAY}" = "1" ] && echo enabled || echo disabled)" MAX_LO="0" MAX_HI="${LADDER_MAX_AMOUNT}" MAX_BEST_OK="" MAX_BEST_AMT="" rung=0 # LADDER_MAX_PROBES=0 → no probe cap (stop on converge / timeout only) while [ "${LADDER_MAX_PROBES}" = "0" ] || [ "$rung" -lt "${LADDER_MAX_PROBES}" ]; do 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 during max-search after ${OK_N} ok; best=${MAX_BEST_AMT:-none} · $(format_amount_alt "${MAX_BEST_AMT:-${CUR}:0}")" break fi _next=$(max_search_next) _anum=$(printf '%s' "$_next" | cut -d'|' -f1) range_note=$(printf '%s' "$_next" | cut -d'|' -f2) _act=$(printf '%s' "$_next" | cut -d'|' -f3) if [ "$_act" = "done" ]; then info "max-search" "converged best=$(format_amount_alt "${MAX_BEST_AMT:-${CUR}:0}") · lo=$(format_amount_alt "${CUR}:${MAX_LO}") · hi=$(format_amount_alt "${CUR}:${MAX_HI}") · probes=${rung}" break fi AMT="${CUR}:${_anum}" if [ -n "${_LAST_MAX_AMT:-}" ] && [ "$AMT" = "$_LAST_MAX_AMT" ]; then info "max-search" "repeat amount $(format_amount_alt "$AMT") — tightening hi" MAX_HI=$(python3 -c 'from decimal import Decimal; a=Decimal("'"${_anum}"'"); print(a-(1 if a>=1 else Decimal("'"${LADDER_MIN_AMOUNT}"'")))') continue fi _LAST_MAX_AMT="$AMT" rung=$((rung + 1)) section "ladder · max probe $rung $AMT · $(format_amount_alt "$AMT") [lo=$(format_amount_alt "${CUR}:${MAX_LO}") · hi=$(format_amount_alt "${CUR}:${MAX_HI}")]" status="FAIL" if ! ladder_withdraw_rung; then break fi AMT_NUM=$(python3 -c 'import sys; print(sys.argv[1].split(":",1)[-1])' "$AMT") case "$status" in OK|OK_BANK) MAX_LO="$AMT_NUM" MAX_BEST_OK="$AMT_NUM" MAX_BEST_AMT="$AMT" info "max-search" "success bound lo=$(format_amount_alt "${CUR}:${MAX_LO}") (best so far)" ;; CEILING_REJECT|FAIL_MINT) MAX_HI=$(python3 -c ' from decimal import Decimal a=Decimal("'"$AMT_NUM"'"); h=Decimal("'"$MAX_HI"'") print(min(a,h)) ') info "max-search" "too-high hi:=$(format_amount_alt "${CUR}:${MAX_HI}")" ;; SKIP_DENOM|ZERO_SKIP|ZERO_REJECT) info "max-search" "denom-skip $(format_amount_alt "$AMT") (bounds unchanged)" ;; SKIP_CONFIRM|SKIP_SETTLE|SKIP_ACCEPT) info "max-search" "inconclusive $status $(format_amount_alt "$AMT") (bounds unchanged)" ;; *) info "max-search" "status=$status $(format_amount_alt "$AMT") (bounds unchanged)" ;; esac if python3 -c 'from decimal import Decimal; import sys; sys.exit(0 if Decimal(sys.argv[1])>0 and Decimal(sys.argv[2])<=Decimal(sys.argv[1]) else 1)' "$MAX_LO" "$MAX_HI" 2>/dev/null; then info "max-search" "bounds crossed lo=$(format_amount_alt "${CUR}:${MAX_LO}") · hi=$(format_amount_alt "${CUR}:${MAX_HI}") — stop" break fi done if [ -n "${MAX_BEST_AMT:-}" ]; then ok "max-search best withdrawable $(format_amount_alt "$MAX_BEST_AMT") · lo=$(format_amount_alt "${CUR}:${MAX_LO}") · hi=$(format_amount_alt "${CUR}:${MAX_HI}") · probes=${rung}" else warn ladder "max-search no successful withdraw" \ "problem: no OK/OK_BANK within ${rung} probes; hi=$(format_amount_alt "${CUR}:${MAX_HI}")" fi { echo "# max-search mode (withdraw)" echo "best_wd=${MAX_BEST_AMT:-}" echo "best_wd_alt=$(format_amount_alt "${MAX_BEST_AMT:-${CUR}:0}")" echo "lo=${MAX_LO}" echo "lo_alt=$(format_amount_alt "${CUR}:${MAX_LO}")" echo "hi=${MAX_HI}" echo "hi_alt=$(format_amount_alt "${CUR}:${MAX_HI}")" echo "probes=${rung}" } >"$SCRATCH/ladder-plan.txt" printf '%s\n' "${MAX_BEST_AMT:-${CUR}:0}" >"$SCRATCH/ladder-wd-plan.txt" : >"$SCRATCH/ladder-pay-plan.txt" # pay phase uses max-search (not classic PAY_LIST) PAY_LIST="" else 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 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 section "ladder · rung $rung $AMT · $(format_amount_alt "$AMT")" if ! ladder_withdraw_rung; then break fi done fi # --------------------------------------------------------------------------- # Phase B — payment (ladder/lib_pay.sh) # classic: planned pay list · max: highest payable ≤ available (goa-free) # --------------------------------------------------------------------------- PAY_OK_N=0 PAY_FAIL_N=0 MAX_PAY_BEST_AMT="" MAX_PAY_LO="0" MAX_PAY_HI="0" ladder_run_pay_phase 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:-}" export LADDER_REPORT_MODE="${LADDER_MODE:-classic}" export LADDER_REPORT_MAX_BEST="${MAX_BEST_AMT:-}" export LADDER_REPORT_MAX_PAY_BEST="${MAX_PAY_BEST_AMT:-}" export LADDER_REPORT_MAX_PAY_LO="${MAX_PAY_LO:-}" export LADDER_REPORT_MAX_PAY_HI="${MAX_PAY_HI:-}" export LADDER_REPORT_MAX_PAY_BEST_ALT="$(format_amount_alt "${MAX_PAY_BEST_AMT:-${CUR}:0}" 2>/dev/null || true)" export LADDER_REPORT_MAX_LO="${MAX_LO:-}" export LADDER_REPORT_MAX_HI="${MAX_HI:-}" # Human alt_unit_names (e.g. 8.15 Tera-GOA (GOA:…)) for report + final lines export LADDER_REPORT_MAX_BEST_ALT="$(format_amount_alt "${MAX_BEST_AMT:-${CUR}:0}" 2>/dev/null || true)" export LADDER_REPORT_MAX_LO_ALT="$(format_amount_alt "${CUR}:${MAX_LO:-0}" 2>/dev/null || true)" export LADDER_REPORT_MAX_HI_ALT="$(format_amount_alt "${CUR}:${MAX_HI:-0}" 2>/dev/null || true)" export LADDER_REPORT_ALT_UNITS="${ALT_UNITS_FILE:-}" # Report must never abort the ladder (set -e): soft-fail print/JSON only. python3 - "$TSV" "$PAY_TSV" "$JSON" "$OK_N" "$FAIL_N_L" "$PAY_OK_N" "$PAY_FAIL_N" "$ms_phase" "$ACCT_USER" "$CUR" <<'PY' || true import csv, json, os, sys, statistics, traceback from decimal import Decimal, InvalidOperation, ROUND_HALF_UP try: 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 mode = os.environ.get("LADDER_REPORT_MODE") or "classic" max_best = os.environ.get("LADDER_REPORT_MAX_BEST") or None max_lo = os.environ.get("LADDER_REPORT_MAX_LO") or None max_hi = os.environ.get("LADDER_REPORT_MAX_HI") or None max_best_alt = os.environ.get("LADDER_REPORT_MAX_BEST_ALT") or None max_lo_alt = os.environ.get("LADDER_REPORT_MAX_LO_ALT") or None max_hi_alt = os.environ.get("LADDER_REPORT_MAX_HI_ALT") or None max_pay_best = os.environ.get("LADDER_REPORT_MAX_PAY_BEST") or None max_pay_lo = os.environ.get("LADDER_REPORT_MAX_PAY_LO") or None max_pay_hi = os.environ.get("LADDER_REPORT_MAX_PAY_HI") or None max_pay_best_alt = os.environ.get("LADDER_REPORT_MAX_PAY_BEST_ALT") or None alt_path = os.environ.get("LADDER_REPORT_ALT_UNITS") or "" alt = {} if alt_path: try: _loaded = json.load(open(alt_path)) if isinstance(_loaded, dict): alt = {str(k): str(v) for k, v in _loaded.items()} except Exception: alt = {} if not isinstance(alt, dict) or not alt: alt = {"0": cur or "GOA"} elif "0" not in alt: alt = dict(alt) alt["0"] = cur or "GOA" def fmt_num(v): try: if v == v.to_integral(): return format(int(v), "d") s = format(v.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP), "f") return s.rstrip("0").rstrip(".") except Exception: return str(v) def format_alt(amt): """Human alt_unit_names; never raises.""" try: if not amt: return "-" s = str(amt).strip() try: if ":" in s: c, v = s.split(":", 1) val = Decimal(v) else: c, val = cur, Decimal(s) except (InvalidOperation, ValueError, TypeError): return s au = alt if isinstance(alt, dict) else {"0": cur or "GOA"} base_name = au.get("0") or c or cur or "GOA" base_s = "%s:%s" % (c, fmt_num(val)) if val == 0: return "0 %s (%s)" % (base_name, base_s) scales = [] for k, name in au.items(): try: scales.append((int(k), str(name))) except Exception: continue scales.sort(key=lambda x: -x[0]) absval = abs(val) chosen = None for sc, name in scales: try: unit = Decimal(10) ** sc if unit <= 0: continue coeff = absval / unit if coeff >= 1: chosen = (sc, name, coeff if val >= 0 else -coeff) break except Exception: continue if chosen is None: return "%s %s (%s)" % (fmt_num(val), base_name, base_s) sc, name, coeff = chosen if sc == 0: return "%s %s" % (fmt_num(val), name) return "%s %s (%s)" % (fmt_num(coeff), name, base_s) except Exception: return str(amt) if amt else "-" def as_amt(s): try: if s is None or s == "": return None s = str(s).strip() if ":" in s: return s return "%s:%s" % (cur, s) except Exception: return 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)), } _max_block = None if mode == "max": _best = as_amt(max_best) if max_best else None _lo = as_amt(max_lo) if max_lo is not None and str(max_lo) != "" else None _hi = as_amt(max_hi) if max_hi is not None and str(max_hi) != "" else None _pb = as_amt(max_pay_best) if max_pay_best else None _max_block = { "best": _best, "best_alt": max_best_alt or (format_alt(_best) if _best else None), "lo": max_lo, "lo_alt": max_lo_alt or (format_alt(_lo) if _lo else None), "hi": max_hi, "hi_alt": max_hi_alt or (format_alt(_hi) if _hi else None), "best_pay": _pb, "best_pay_alt": max_pay_best_alt or (format_alt(_pb) if _pb else None), "pay_lo": max_pay_lo, "pay_hi": max_pay_hi, } report = { "currency": cur, "mode": mode, "auto_account": acct, "ok_rungs": int(ok_n), "fail_rungs": int(fail_n), "pay_ok": int(pay_ok), "pay_fail": int(pay_fail), "max_search": _max_block, "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, } try: json.dump(report, open(jpath, "w"), indent=2) print("JSON", jpath) except Exception as e: print("JSON write error:", e) 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: try: amt = row.get("amount") or "" aalt = format_alt(amt) if amt else "" print(" %2s %-18s %-14s total=%sms %s" % ( row.get("rung"), amt, row.get("status"), row.get("ms_total"), aalt)) except Exception as e: print(" ? row-print-error %s" % (e,)) print("--- pay rungs ---") for row in prows: try: amt = row.get("amount") or "" aalt = format_alt(amt) if amt else "" print(" %2s %-18s %-14s total=%sms oid=%s %s" % ( row.get("rung"), amt, row.get("status"), row.get("ms_total"), row.get("oid"), aalt)) except Exception as e: print(" ? pay-row-print-error %s" % (e,)) if mode == "max": print("--- max-search ---") try: ba = (max_best_alt or format_alt(as_amt(max_best) or "")) if max_best else "-" la = (max_lo_alt or format_alt(as_amt(max_lo) or "")) if max_lo is not None and str(max_lo) != "" else "-" ha = (max_hi_alt or format_alt(as_amt(max_hi) or "")) if max_hi is not None and str(max_hi) != "" else "-" print(" best=%s" % (max_best or "-")) print(" best_alt=%s" % ba) print(" lo=%s" % (max_lo if max_lo is not None else "-")) print(" lo_alt=%s" % la) print(" hi=%s" % (max_hi if max_hi is not None else "-")) print(" hi_alt=%s" % ha) print(" best_pay=%s" % (max_pay_best or "-")) print(" best_pay_alt=%s" % (max_pay_best_alt or (format_alt(as_amt(max_pay_best) or "") if max_pay_best else "-"))) except Exception as e: print(" max-search print error: %s" % (e,)) if stop_amt: try: print("STOPPED at %s · %s: %s" % (stop_amt, format_alt(stop_amt), stop_reason)) except Exception: print("STOPPED at %s: %s" % (stop_amt, stop_reason)) else: print("Completed without hard failure (or budget stop without fail).") except Exception: traceback.print_exc() sys.exit(0) 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/amount-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 if [ "${LADDER_MODE}" = "max" ] && [ -n "${MAX_BEST_AMT:-}" ]; then ok "ladder finished mode=max wd_best=$(format_amount_alt "$MAX_BEST_AMT") · pay_best=$(format_amount_alt "${MAX_PAY_BEST_AMT:-${CUR}:0}") · withdraw_ok=$OK_N pay_ok=$PAY_OK_N phase=${ms_phase}ms" else ok "ladder finished mode=${LADDER_MODE} withdraw_ok=$OK_N pay_ok=$PAY_OK_N phase=${ms_phase}ms" fi exit 0