fix 1.22.1: pay-wait withdraw board (start order + pendingIncoming)

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.
This commit is contained in:
Hernâni Marques 2026-07-19 14:34:47 +02:00
parent 35cd86bd43
commit fd5ac80566
No known key found for this signature in database
6 changed files with 232 additions and 6 deletions

View file

@ -289,6 +289,7 @@ If only a few changes land, these unlock the most automation:
| force-select | multi-rpub try; soft-skip confirm; empty scrape → WARN not balance fail |
| higher amounts | mint 5110/P0001 common above live ceiling (§3b); classic soft `CEILING_REJECT`; max-ladder bounds hi; report uses alt_unit_names (Tera-GOA …) |
| pay vs balance | never order more than wallet **available** (soft `SKIP_BALANCE`); pendingIncoming does not count as spendable |
| pay wait UX | after withdraw, board lists OK/OK_BANK in **start order** with live wallet `pendingIncoming`/`done` + timer (`LADDER_PAY_WAIT_AVAILABLE_S`, default 90s); same wallet DB as withdraw |
| wallet-db path | Prefer `*.sqlite3` (never `*.json` as db path) |
| helper / python | mon PATH must include helper + python≥3.11 |

View file

@ -1 +1 @@
1.22.0
1.22.1

View file

@ -17,6 +17,7 @@ Git tags: `vMAJOR.FEATURE.FIX` (e.g. `v1.8.0`). File `VERSION` omits the `v` pre
| Tag | Date (UTC) | Notes |
|-----|------------|--------|
| **v1.22.1** | 2026-07-19 | **Bugfix (UX):** pay wait after withdraw no longer looks hung — shows **withdraw board in start order** (OK/OK_BANK from TSV) with live wallet status (`pendingIncoming``done`) + timer + aggregate available/pendingIncoming on the **same** wallet DB. Short `run-pending` nudge only (no run-until-done). Default wait 90s (`LADDER_PAY_WAIT_AVAILABLE_S`). |
| **v1.22.0** | 2026-07-19 | **Feature:** modular amount ladder — `ladder/lib_pay.sh` (pay) + withdraw in `check_amount_ladder.sh`; `ladder/extract_rpubs.py`. **max-ladder** hunts highest **withdrawable** and highest **payable** (≤ wallet available). GOA pays use public free-amount template **`goa-free`** (`LADDER_FREE_TEMPLATE`). Classic still default 23-rung wd+pay. Phase alias `goa-ladder` → amount ladder (shim removed). |
| **v1.21.0** | 2026-07-19 | **Feature:** generic **amount ladder** (`check_amount_ladder.sh`; compat shim `check_goa_ladder.sh`). Default **classic** 23-rung withdraw (+ pay when enabled); **max-search** (`max-ladder` / `LADDER_MODE=max`) hunts highest mintable amount (probes default 32, `0`=until timeout). **Pay:** never overspend — soft `SKIP_BALANCE` if amount > wallet available. **Alt units:** report/logs show human names (e.g. `8.15 Tera-GOA (GOA:…)`) + raw. **Fixes bundled:** external extract (no stdin SyntaxError); force-select multi-rpub; soft `CEILING_REJECT` on mint 5110/P0001; crash-proof report. CLI-AUTOMATION-NOTES §3/3a/3b/§1518. |
| **v1.20.1** | 2026-07-19 | **Docs:** CLI-AUTOMATION-NOTES — force-select empty scrape + ladder pins (§3/3a); wallet-db must not be `*.json` (§15); `taler-helper-sqlite3` needs Python ≥3.11 (§16); portable wall-clock (§17); GOA withdraw checklist (§18). No code change. *(Superseded notes folded into **v1.21.0** feature release.)* |

View file

@ -87,8 +87,8 @@ elapsed_ms() {
: "${LADDER_HIGH_RUNGS:=6}"
# optional seed for reproducible max-search (empty = time-based)
: "${LADDER_MAX_SEED:=}"
# After withdraw, wait for spendable available>0 before pay (secs total)
: "${LADDER_PAY_WAIT_AVAILABLE_S:=60}"
# After withdraw, wait for spendable available>0 before pay (secs total; shows countdown)
: "${LADDER_PAY_WAIT_AVAILABLE_S:=90}"
# Historic libeufin-ish absolute ceiling used as default LADDER_MAX_AMOUNT on GOA
LADDER_ABS_CEILING="4503599627370496"
@ -308,6 +308,31 @@ print("0")
' "$CUR" 2>/dev/null || echo "0"
}
# pendingIncoming for same currency (same wallet DB as withdraw) — "0" if missing
wallet_pending_in() {
wcli balance 2>/dev/null | python3 -c '
import json,sys
t=sys.stdin.read()
dec=json.JSONDecoder()
i=t.find("{")
if i<0:
print("0"); raise SystemExit
try:
d,_=dec.raw_decode(t,i)
except Exception:
try:
d=json.loads(t[i:t.rfind("}")+1])
except Exception:
print("0"); raise SystemExit
cur=sys.argv[1]
for b in d.get("balances") or []:
a=b.get("pendingIncoming") or b.get("pending_incoming") or ""
if a.startswith(cur+":"):
print(a.split(":",1)[1]); raise SystemExit
print("0")
' "$CUR" 2>/dev/null || echo "0"
}
# Build paired pay + withdraw ladders (same step count, shared random shape).
# Pay: [0] + log-uniform mids + [max-1] + [max]
# Wd: [0] + max(mid, mid×scale) mids + [max-1] + [max] (mids higher → enough to spend)

View file

@ -29,3 +29,4 @@ There is **no** `check_goa_ladder.sh` — removed; use the phases above.
| `LADDER_USE_FREE_TEMPLATE` | `1` | public POST template with `{"amount":…}` |
| `LADDER_FREE_TEMPLATE` | `goa-free` | template id (hacktivism) |
| `LADDER_FREE_TEMPLATE_INSTANCE` | merchant instance | e.g. `goa-demo-cp4zqk` |
| `LADDER_PAY_WAIT_AVAILABLE_S` | `90` | after withdraw, wait for spendable **available**>0; logs **withdraw board in start order** (OK/OK_BANK) with live `pendingIncoming``done` + timer (same wallet DB) |

View file

@ -9,7 +9,7 @@
: "${LADDER_FREE_TEMPLATE:=goa-free}"
: "${LADDER_FREE_TEMPLATE_INSTANCE:=}"
: "${LADDER_USE_FREE_TEMPLATE:=1}"
: "${LADDER_PAY_WAIT_AVAILABLE_S:=60}"
: "${LADDER_PAY_WAIT_AVAILABLE_S:=90}"
: "${LADDER_PAY_SETTLE_ROUNDS:=6}"
MAX_PAY_BEST_AMT="${MAX_PAY_BEST_AMT:-}"
@ -188,17 +188,213 @@ except Exception:
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 bal
local end now left bal pend tick=0
end=$(( $(date +%s) + wait_s ))
bal=$(wallet_avail)
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
[ "$(date +%s)" -ge "$end" ] && break
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"
}
@ -346,7 +542,9 @@ ladder_run_pay_phase() {
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"