taler-monitoring/check_goa_ladder.sh
2026-07-18 13:56:54 +02:00

1364 lines
52 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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