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.
559 lines
23 KiB
Bash
559 lines
23 KiB
Bash
# 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:=90}"
|
|
: "${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"
|
|
}
|