UX fix: after bank OK_BANK, available is often still 0 while pendingIncoming ticks up. The silent wait looked hung. Show OK/OK_BANK withdraws in the order they started, match wallet txs, and update pendingIncoming→done with a timer on the same wallet DB. Short run-pending only; default wait 90s.
1540 lines
60 KiB
Bash
Executable file
1540 lines
60 KiB
Bash
Executable file
#!/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:=90}"
|
||
|
||
# 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
|
||
# 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}" = "${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
|
||
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)"
|
||
|
||
: "${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)
|
||
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))
|
||
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 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"
|
||
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
|