# ladder/lib_pay.sh — payment side of amount ladder (sourced by check_amount_ladder.sh) # Expects: lib.sh + metrics.sh already sourced; CUR BANK MER INST EX SCRATCH WDB # wcli wallet_avail format_amount_alt ladder_over now_ms elapsed_ms # LADDER_* env, AUTH optional, PAY_TSV PAY_OK_N PAY_FAIL_N # # Public free-amount template (hacktivism): goa-free @ goa-demo-cp4zqk # Never spends more than wallet available. : "${LADDER_FREE_TEMPLATE:=goa-free}" : "${LADDER_FREE_TEMPLATE_INSTANCE:=}" : "${LADDER_USE_FREE_TEMPLATE:=1}" : "${LADDER_PAY_WAIT_AVAILABLE_S:=180}" : "${LADDER_PAY_SETTLE_ROUNDS:=6}" MAX_PAY_BEST_AMT="${MAX_PAY_BEST_AMT:-}" MAX_PAY_LO="${MAX_PAY_LO:-0}" MAX_PAY_HI="${MAX_PAY_HI:-0}" ladder_pay_resolve_merchant() { # Sets AUTH (may be empty if free template only) MPW="${E2E_MERCHANT_TOKEN:-${MERCHANT_TOKEN:-}}" if [ -z "$MPW" ]; then MPW=$(read_secret "taler-merchant/merchant-${INST}-password.txt" 2>/dev/null || true) fi if [ -z "$MPW" ]; then MPW=$(read_secret "taler-merchant/merchant-goa-demo-cp4zqk-password.txt" 2>/dev/null || true) fi if [ -z "$MPW" ] && [ "${CUR}" = "TESTPAYSAN" ]; then if [ -n "${FRANCPAYSAN_SECRETS:-}" ] && [ -f "${FRANCPAYSAN_SECRETS}/stage/default-instance-token.txt" ]; then MPW=$(tr -d '\n\r' <"${FRANCPAYSAN_SECRETS}/stage/default-instance-token.txt") fi fi AUTH="" if [ -n "$MPW" ]; then case "$MPW" in secret-token:*) AUTH="Authorization: Bearer ${MPW}" ;; *) AUTH="Authorization: Bearer secret-token:${MPW}" ;; esac fi : "${LADDER_FREE_TEMPLATE_INSTANCE:=${INST}}" } # Create order for PAMT → OID OTOK PAYURI. Prefers public free template. ladder_create_order() { local pamt="$1" ptag="$2" local inst mh body tpl_https sum_json OID=""; OTOK=""; PAYURI="" inst="${LADDER_FREE_TEMPLATE_INSTANCE:-$INST}" mh=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"${MER}"'").hostname or "taler.hacktivism.ch")' 2>/dev/null || echo "taler.hacktivism.ch") if [ "${LADDER_USE_FREE_TEMPLATE}" = "1" ] && [ -n "${LADDER_FREE_TEMPLATE:-}" ]; then tpl_https="${MER}/instances/${inst}/templates/${LADDER_FREE_TEMPLATE}" body=$(python3 -c 'import json,sys; print(json.dumps({"amount":sys.argv[1]}))' "$pamt" 2>/dev/null || printf '{"amount":"%s"}' "$pamt") curl -skS -m 20 -o "$SCRATCH/ord-$ptag.json" -X POST \ -H 'Content-Type: application/json' \ -d "$body" \ "$tpl_https" 2>"$SCRATCH/ord-$ptag.err" || true 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 [ -n "$OID" ] && [ -n "$OTOK" ]; then PAYURI="taler://pay/${mh}/instances/${inst}/${OID}/?c=${OTOK}" PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g') return 0 fi fi [ -n "${AUTH:-}" ] || return 1 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 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) [ -n "$OID" ] || return 1 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 PAYURI="taler://pay/${mh}/instances/${INST}/${OID}/?c=${OTOK}" fi PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g') [ -n "$PAYURI" ] } # One pay attempt; never overspends. Always soft (no hard ladder stop). ladder_pay_attempt() { local pamt="$1" prange="$2" prung="$3" local ptag pnum bal t_pay t0 ms_order ms_handle ms_psettle after settled r pstatus pnote ptag=$(printf '%s' "$pamt" | tr '.:' '__') pnum=$(python3 -c 'import sys; print(sys.argv[1].split(":",1)[-1])' "$pamt") t_pay=$(now_ms) ms_order=0; ms_handle=0; ms_psettle=0 pnote=""; pstatus="FAIL"; OID="-" bal=$(wallet_avail) if ! python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])>0 and Decimal(sys.argv[1])>=Decimal(sys.argv[2]) else 1)' "$bal" "$pnum" 2>/dev/null; then pstatus="SKIP_BALANCE" pnote="skip: would spend more than available avail=${CUR}:${bal} need=${pamt}" warn pay "pay $pamt skipped (balance)" \ "problem: never overspend. $(format_amount_alt "${CUR}:${bal}") free vs $(format_amount_alt "$pamt")" echo -e "${prung}\t${prange}\t${pamt}\t${pstatus}\t0\t0\t0\t$(elapsed_ms "$t_pay")\t-\t${pnote}" >>"$PAY_TSV" return 0 fi t0=$(now_ms) if ! ladder_create_order "$pamt" "$ptag"; then ms_order=$(elapsed_ms "$t0") pstatus="FAIL_ORDER" pnote="order create failed $(head -c 100 "$SCRATCH/ord-$ptag.json" 2>/dev/null | tr '\n\"' ' ')" warn pay "order $pamt" "$pnote" echo -e "${prung}\t${prange}\t${pamt}\t${pstatus}\t${ms_order}\t0\t0\t$(elapsed_ms "$t_pay")\t-\t${pnote}" >>"$PAY_TSV" return 0 fi ms_order=$(elapsed_ms "$t0") ok "order $OID ($pamt) ${ms_order}ms · $(format_amount_alt "$pamt")" t0=$(now_ms) if ! wcli handle-uri --yes "$PAYURI" >"$SCRATCH/pay-$ptag.out" 2>&1; then ms_handle=$(elapsed_ms "$t0") pstatus="FAIL_HANDLE" pnote="handle-uri failed $(tail -c 100 "$SCRATCH/pay-$ptag.out" | tr '\n\"' ' ')" warn pay "handle $pamt" "$pnote" echo -e "${prung}\t${prange}\t${pamt}\t${pstatus}\t${ms_order}\t${ms_handle}\t0\t$(elapsed_ms "$t_pay")\t${OID}\t${pnote}" >>"$PAY_TSV" return 0 fi ms_handle=$(elapsed_ms "$t0") ok "handle-uri $pamt ${ms_handle}ms" t0=$(now_ms) settled=0 r=0 while [ "$r" -lt "${LADDER_PAY_SETTLE_ROUNDS}" ]; do r=$((r + 1)) wcli transactions >"$SCRATCH/tx-pay-$ptag.out" 2>&1 || true if [ -n "${AUTH:-}" ]; then curl -skS -m 8 -o "$SCRATCH/ord-paid-$ptag.json" -H "$AUTH" \ "${MER}/instances/${INST}/private/orders/${OID}" 2>/dev/null || true fi if grep -qiE 'payment|paid|Payment' "$SCRATCH/tx-pay-$ptag.out" 2>/dev/null \ || python3 -c 'import json,sys try: 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) except Exception: sys.exit(1) ' "$SCRATCH/ord-paid-$ptag.json" 2>/dev/null; then settled=1 break fi sleep 0.5 done ms_psettle=$(elapsed_ms "$t0") after=$(wallet_avail) if [ "$settled" = "1" ]; then pstatus="OK" pnote="avail=${CUR}:${after}" ok "pay settled $pamt · $(format_amount_alt "$pamt") → bal ${CUR}:${after}" 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$(elapsed_ms "$t_pay")\t${OID}\t${pnote}" >>"$PAY_TSV" metrics_report_coins "after-pay-${ptag}" || true metrics_record_flow spent "$pamt" || true else pstatus="FAIL_SETTLE" pnote="not settled order=$OID avail=${CUR}:${after}" warn pay "settle $pamt" "$pnote" echo -e "${prung}\t${prange}\t${pamt}\t${pstatus}\t${ms_order}\t${ms_handle}\t${ms_psettle}\t$(elapsed_ms "$t_pay")\t${OID}\t${pnote}" >>"$PAY_TSV" fi return 0 } # Print withdraw board: OK/OK_BANK rungs in *start order* + wallet tx state. # Uses TSV (ladder order) + transactions dump. Call after refreshing tx dump. # Args: left_secs available_num pendingIncoming_num ladder_print_wd_board() { local left="${1:-?}" bal="${2:-0}" pend="${3:-0}" local tsv="${TSV:-}" txf="${SCRATCH:-/tmp}/tx-wait-pay.out" local board board=$( python3 - "$tsv" "$txf" "${CUR:-GOA}" "$left" "$bal" "$pend" <<'PY' 2>/dev/null || true import json, sys from pathlib import Path tsv, txf, cur, left, bal, pend = sys.argv[1:7] dec = json.JSONDecoder() def num(a: str) -> str: a = (a or "").strip() if ":" in a: return a.split(":", 1)[1] return a def iter_json(text: str): i, n = 0, len(text) while i < n: if text[i] == "{": try: o, end = dec.raw_decode(text, i) yield o i = end continue except Exception: pass i += 1 # TSV rows in start order (OK / OK_BANK only — successful bank path) rows = [] if tsv and Path(tsv).is_file(): for ln in open(tsv, errors="replace"): if not ln.strip() or ln.startswith("rung"): continue p = ln.rstrip("\n").split("\t") if len(p) < 10: continue status = p[3] if status not in ("OK", "OK_BANK"): continue rows.append({ "rung": p[0], "amt": p[2], "bank": status, "wid": (p[9] if len(p) > 9 else "")[:12], }) # Wallet withdrawal txs (chronological if timestamp present) wds = [] if txf and Path(txf).is_file(): try: text = open(txf, errors="replace").read() except Exception: text = "" for o in iter_json(text): txs = o.get("transactions") if isinstance(o, dict) else None if not isinstance(txs, list): continue for t in txs: if not isinstance(t, dict): continue typ = str(t.get("type") or t.get("Type") or "").lower() if "withdraw" not in typ: continue amt = t.get("amountRaw") or t.get("amountEffective") or t.get("amount") or "" st = ( t.get("txState") or t.get("status") or t.get("pendingMajor") or t.get("txMajorState") or "" ) st = str(st).lower() det = t.get("withdrawalDetails") or t.get("withdrawal_details") or {} if not isinstance(det, dict): det = {} conf = det.get("confirmed") if conf is True and not st: st = "done" elif conf is False and not st: st = "pending" # pending* / dialog / kyc → still incoming; done/abort final if any(x in st for x in ("done", "abort", "fail", "delete")): phase = "done" if "done" in st else st elif st in ("", "none", "null"): phase = "unknown" else: phase = "pendingIncoming" ts = 0 for k in ("timestamp", "timestamp_ms", "age"): v = t.get(k) if isinstance(v, dict): v = v.get("t_s") or v.get("t_ms") or v.get("t_usec") try: ts = int(v or 0) except Exception: ts = 0 if ts: break wds.append({"amt": str(amt), "n": num(str(amt)), "phase": phase, "st": st or "?", "ts": ts}) wds.sort(key=lambda x: x["ts"]) used = [False] * len(wds) lines = [ f"withdraw board (start order) · timer {left}s left · available={cur}:{bal} · pendingIncoming={cur}:{pend}" ] if not rows: lines.append(" (no OK/OK_BANK rows in TSV yet)") else: for i, r in enumerate(rows, 1): n = num(r["amt"]) match = None for j, w in enumerate(wds): if used[j]: continue if w["n"] == n or w["amt"] == r["amt"] or w["amt"].endswith(":" + n): used[j] = True match = w break if match is None: wlabel = "wallet=? (no tx match)" elif match["phase"] == "pendingIncoming": wlabel = f"wallet=pendingIncoming ({match['st']})" elif match["phase"] == "done": wlabel = f"wallet=done ({match['st']})" else: wlabel = f"wallet={match['phase']} ({match['st']})" wid = r["wid"] or "-" lines.append( f" #{i:<3} rung={r['rung']:<4} {r['amt']:<22} bank={r['bank']:<8} {wlabel} wid={wid}" ) pending_n = sum(1 for w in wds if w["phase"] == "pendingIncoming") done_n = sum(1 for w in wds if w["phase"] == "done") lines.append( f" summary: tsv_ok={len(rows)} · wallet_wd={len(wds)} (pendingIncoming={pending_n} done={done_n})" ) print("\n".join(lines)) PY ) if [ -n "$board" ]; then while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] && info pay "$line" done <<<"$board" else info pay "timer ${left}s left · available=${CUR}:${bal} · pendingIncoming=${CUR}:${pend} · same wallet (board n/a)" fi } # Wait for spendable coins on the *same* wallet DB as withdraw (WDB). # After OK_BANK, available is often still 0 while pendingIncoming > 0 — pay needs available. # Shows withdraw board in start order + countdown (never looks hung). ladder_wait_available() { local wait_s="${1:-$LADDER_PAY_WAIT_AVAILABLE_S}" local end now left bal pend tick=0 end=$(( $(date +%s) + wait_s )) bal=$(wallet_avail) pend=$(wallet_pending_in 2>/dev/null || echo "0") if python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])>0 else 1)' "$bal" 2>/dev/null; then printf '%s' "$bal" return 0 fi info pay "same wallet as withdraw (db=$(basename "${WDB:-wallet}")) · available=${CUR}:${bal} · pendingIncoming=${CUR}:${pend}" info pay "waiting up to ${wait_s}s for spendable available>0 — board lists OK/OK_BANK withdraws in start order" # initial board before loop wcli transactions >"$SCRATCH/tx-wait-pay.out" 2>&1 || true ladder_print_wd_board "$wait_s" "$bal" "$pend" while python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])<=0 else 1)' "$bal" 2>/dev/null; do now=$(date +%s) left=$(( end - now )) if [ "$left" -le 0 ]; then wcli transactions >"$SCRATCH/tx-wait-pay.out" 2>&1 || true ladder_print_wd_board "0" "$bal" "$pend" info pay "timer 0s left — stop waiting (available still ${CUR}:0)" break fi # nudge wallet (same DB); do not run-until-done (can hang) wcli transactions >"$SCRATCH/tx-wait-pay.out" 2>&1 || true if command -v timeout >/dev/null 2>&1 || command -v gtimeout >/dev/null 2>&1; then local _to _to=$(command -v gtimeout 2>/dev/null || command -v timeout) "$_to" 8 wcli advanced run-pending >"$SCRATCH/run-pending.out" 2>&1 || true fi # progress board every ~5s (tick every 2s → every 3rd) if [ $((tick % 3)) -eq 0 ]; then ladder_print_wd_board "$left" "$bal" "$pend" fi sleep 2 tick=$((tick + 1)) bal=$(wallet_avail) pend=$(wallet_pending_in 2>/dev/null || echo "0") done if python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])>0 else 1)' "$bal" 2>/dev/null; then wcli transactions >"$SCRATCH/tx-wait-pay.out" 2>&1 || true ladder_print_wd_board "ready" "$bal" "$pend" ok pay "spendable coins ready available=${CUR}:${bal} · $(format_amount_alt "${CUR}:${bal}") (same wallet)" else warn pay "no spendable coins after ${wait_s}s" \ "problem: available still ${CUR}:0 (pendingIncoming=${CUR}:${pend}). Board above shows which withdraws still pendingIncoming. Pay will SKIP_BALANCE / skip max-pay until wirewatch+wallet finish. Same wallet as withdraw." fi printf '%s' "$bal" } # Classic planned pays (PAY_LIST) ladder_run_pay_classic() { # shellcheck disable=SC2086 set -- $PAY_LIST local pay_n=$# prung=0 PAMT prange PNUM if [ "$pay_n" -eq 0 ]; then info pay "empty pay plan — skip" return 0 fi info "pay rungs" "$pay_n · $*" for PAMT in "$@"; do prung=$((prung + 1)) ladder_over && break 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 section "ladder · pay rung $prung $PAMT · $(format_amount_alt "$PAMT")" PNUM=$(python3 -c 'import sys; print(sys.argv[1].split(":",1)[-1])' "$PAMT") if python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])==0 else 1)' "$PNUM" 2>/dev/null; then echo -e "${prung}\t${prange}\t${PAMT}\tZERO_SKIP\t0\t0\t0\t0\t-\tzero" >>"$PAY_TSV" PAY_OK_N=$((PAY_OK_N + 1)) continue fi ladder_pay_attempt "$PAMT" "$prange" "$prung" done } # Max-search: highest payable ≤ available (uses max_search_next from withdraw module) ladder_run_pay_max_search() { local bal prung _next _anum prange _act _last _slo _shi section "ladder · phase B · max-search pay (highest payable ≤ available)" bal=$(ladder_wait_available "$LADDER_PAY_WAIT_AVAILABLE_S") MAX_PAY_LO="0" MAX_PAY_HI="$bal" MAX_PAY_BEST_AMT="" if ! python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])>0 else 1)' "$MAX_PAY_HI" 2>/dev/null; then warn pay "max-search pay skipped" \ "problem: wallet available is ${CUR}:0 — need spendable coins (pendingIncoming is not enough)" return 0 fi info "max-search pay" "hi=available $(format_amount_alt "${CUR}:${MAX_PAY_HI}") · template=${LADDER_FREE_TEMPLATE}@${LADDER_FREE_TEMPLATE_INSTANCE}" info "pay budget" "never order more than $(format_amount_alt "${CUR}:${MAX_PAY_HI}")" prung=0 _last="" while [ "${LADDER_MAX_PROBES}" = "0" ] || [ "$prung" -lt "${LADDER_MAX_PROBES}" ]; do ladder_over && break # reuse withdraw amount picker with pay bounds _slo=${MAX_LO:-0}; _shi=${MAX_HI:-0} MAX_LO=$MAX_PAY_LO MAX_HI=$MAX_PAY_HI if ! type max_search_next >/dev/null 2>&1; then warn pay "max_search_next missing — cannot run pay max-search" MAX_LO=$_slo; MAX_HI=$_shi return 0 fi _next=$(max_search_next) MAX_LO=$_slo; MAX_HI=$_shi _anum=$(printf '%s' "$_next" | cut -d'|' -f1) prange=$(printf '%s' "$_next" | cut -d'|' -f2 | sed 's/^max:/pay:/') _act=$(printf '%s' "$_next" | cut -d'|' -f3) if [ "$_act" = "done" ]; then info "max-search pay" "converged best=$(format_amount_alt "${MAX_PAY_BEST_AMT:-${CUR}:0}") · lo=$(format_amount_alt "${CUR}:${MAX_PAY_LO}") · hi=$(format_amount_alt "${CUR}:${MAX_PAY_HI}")" break fi if ! python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])<=Decimal(sys.argv[2]) else 1)' "$_anum" "$MAX_PAY_HI" 2>/dev/null; then _anum=$MAX_PAY_HI fi if ! python3 -c 'import sys; from decimal import Decimal; sys.exit(0 if Decimal(sys.argv[1])>0 else 1)' "$_anum" 2>/dev/null; then break fi PAMT="${CUR}:${_anum}" if [ -n "$_last" ] && [ "$PAMT" = "$_last" ]; then MAX_PAY_HI=$(python3 -c 'from decimal import Decimal; a=Decimal("'"$_anum"'"); print(max(Decimal(0), a-(1 if a>=1 else Decimal("0.000001"))))') continue fi _last=$PAMT prung=$((prung + 1)) section "ladder · max pay probe $prung $PAMT · $(format_amount_alt "$PAMT") [lo=$(format_amount_alt "${CUR}:${MAX_PAY_LO}") · hi=$(format_amount_alt "${CUR}:${MAX_PAY_HI}")]" ladder_pay_attempt "$PAMT" "$prange" "$prung" _last_st=$(tail -1 "$PAY_TSV" 2>/dev/null | cut -f4) case "$_last_st" in OK) MAX_PAY_LO=$_anum MAX_PAY_BEST_AMT=$PAMT info "max-search pay" "success bound lo=$(format_amount_alt "${CUR}:${MAX_PAY_LO}")" ;; SKIP_BALANCE|FAIL_ORDER|FAIL_HANDLE|FAIL_SETTLE) MAX_PAY_HI=$(python3 -c 'from decimal import Decimal; a=Decimal("'"$_anum"'"); h=Decimal("'"$MAX_PAY_HI"'"); print(min(a,h))') info "max-search pay" "too-high/fail hi:=$(format_amount_alt "${CUR}:${MAX_PAY_HI}") status=${_last_st}" ;; 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_PAY_LO" "$MAX_PAY_HI" 2>/dev/null; then info "max-search pay" "bounds crossed — stop" break fi done if [ -n "${MAX_PAY_BEST_AMT:-}" ]; then ok "max-search best payable $(format_amount_alt "$MAX_PAY_BEST_AMT") · lo=$(format_amount_alt "${CUR}:${MAX_PAY_LO}") · hi=$(format_amount_alt "${CUR}:${MAX_PAY_HI}")" else warn pay "max-search no successful pay" "available was ${CUR}:$(wallet_avail)" fi { echo "best_pay=${MAX_PAY_BEST_AMT:-}" echo "best_pay_alt=$(format_amount_alt "${MAX_PAY_BEST_AMT:-${CUR}:0}" 2>/dev/null || true)" echo "pay_lo=${MAX_PAY_LO}" echo "pay_hi=${MAX_PAY_HI}" } >>"${SCRATCH}/ladder-plan.txt" 2>/dev/null || true printf '%s\n' "${MAX_PAY_BEST_AMT:-}" >"${SCRATCH}/ladder-pay-plan.txt" 2>/dev/null || true } # Entry: glue from check_amount_ladder.sh after withdraw phase ladder_run_pay_phase() { PAY_OK_N=0 PAY_FAIL_N=0 if [ "${LADDER_PAY}" != "1" ]; then info pay "LADDER_PAY=0 — pay phase skipped" return 0 fi if [ "${FAIL_N_L:-0}" -gt 0 ]; then warn pay "skipped pay ladder" "withdraw phase already failed" return 0 fi set_group pay section "ladder · phase B · pay" metrics_report_coins "before-pay-ladder" || true ladder_pay_resolve_merchant if [ -z "${AUTH:-}" ] && ! { [ "${LADDER_USE_FREE_TEMPLATE}" = "1" ] && [ -n "${LADDER_FREE_TEMPLATE:-}" ]; }; then warn pay "no merchant token and free template off — skip pay" return 0 fi if [ -n "${AUTH:-}" ]; then ok "merchant token" "instance ${INST}" else info pay "public free template ${LADDER_FREE_TEMPLATE}@${LADDER_FREE_TEMPLATE_INSTANCE} (no merchant token)" fi # Same cumulative wallet as withdraw (WDB=wallet-main.sqlite3) wallet_prepare "main" info pay "using same wallet as withdraw · WDB=${WDB:-?}" local bal0 bal0=$(ladder_wait_available "$LADDER_PAY_WAIT_AVAILABLE_S") info "pay budget" "wallet available ${CUR}:${bal0} · $(format_amount_alt "${CUR}:${bal0}") — never order more than this" if [ "${LADDER_MODE}" = "max" ]; then ladder_run_pay_max_search else ladder_run_pay_classic fi metrics_report_coins "after-pay-ladder" || true info "pay summary" "ok=$PAY_OK_N fail=$PAY_FAIL_N tsv=$PAY_TSV" }