# 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:=60}" : "${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 } ladder_wait_available() { local wait_s="${1:-$LADDER_PAY_WAIT_AVAILABLE_S}" local end bal 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 wcli transactions >"$SCRATCH/tx-wait-pay.out" 2>&1 || true sleep 2 bal=$(wallet_avail) done 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 wallet_prepare "main" 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" }