#!/usr/bin/env bash # Phase zero-pay: public-template pays (CHF:0 stickers / TESTPAYSAN shop). # Same path as e2e_one_pay_public_template. Never run-until-done. # Wrapper: pay_talersticker_zero.sh (COUNT default 100). # # Env: COUNT (phase default 1) GAP_S PARALLEL MERCHANT INST TEMPLATE AMOUNT # EXCHANGE ZERO_PAY_POST=amount|empty HANDLE_SECS SETTLE_TRIES SETTLE_SLEEP # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) # shellcheck source=lib.sh source "$ROOT/lib.sh" # shellcheck source=metrics.sh source "$ROOT/metrics.sh" set_area zero-pay # Homebrew python3 (>=3.11) must beat /usr/bin/python3 3.9 — taler-helper-sqlite3. export PATH="/opt/homebrew/bin:${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" die() { echo "ERROR: $*" >&2; fail "zero-pay" "$*" 2>/dev/null || true; exit 1; } COUNT="${COUNT:-${ZERO_PAY_COUNT:-1}}" GAP_S="${GAP_S:-0}" PARALLEL="${PARALLEL:-1}" MERCHANT="${MERCHANT:-${MERCHANT_PUBLIC:-https://stage.my.taler-ops.ch}}" MERCHANT="${MERCHANT%/}" CUR="${EXPECT_CURRENCY:-${CUR:-}}" if [ "$CUR" = "TESTPAYSAN" ]; then INST="${INST:-${ZERO_PAY_INST:-jardin-du-creux}}" TEMPLATE="${TEMPLATE:-${ZERO_PAY_TEMPLATE:-oeufs-6}}" AMOUNT="${AMOUNT:-${ZERO_PAY_AMOUNT:-TESTPAYSAN:5}}" EXCHANGE="${EXCHANGE:-${EXCHANGE_PUBLIC:-https://stage.exchange.lefrancpaysan.ch/}}" ZERO_PAY_POST="${ZERO_PAY_POST:-empty}" else INST="${INST:-${ZERO_PAY_INST:-admin}}" TEMPLATE="${TEMPLATE:-${ZERO_PAY_TEMPLATE:-stickers}}" AMOUNT="${AMOUNT:-${ZERO_PAY_AMOUNT:-CHF:0}}" EXCHANGE="${EXCHANGE:-${EXCHANGE_PUBLIC:-https://exchange.stage.taler-ops.ch/}}" ZERO_PAY_POST="${ZERO_PAY_POST:-amount}" fi EXCHANGE="${EXCHANGE%/}/" HANDLE_SECS="${HANDLE_SECS:-25}" SETTLE_TRIES="${SETTLE_TRIES:-40}" SETTLE_SLEEP="${SETTLE_SLEEP:-0.15}" case "$GAP_S" in ''|*[!0-9.]*|*.*.*) die "GAP_S must be a non-negative number (got $GAP_S)" ;; esac case "$COUNT" in ''|*[!0-9]*) die "COUNT must be a positive integer (got $COUNT)" ;; esac [ "$COUNT" -ge 1 ] || die "COUNT must be >= 1" case "$PARALLEL" in all|ALL) PARALLEL="$COUNT" ;; ''|*[!0-9]*) die "PARALLEL must be a positive integer or all (got $PARALLEL)" ;; esac [ "$PARALLEL" -ge 1 ] || die "PARALLEL must be >= 1" if [ "$PARALLEL" -gt "$COUNT" ]; then PARALLEL="$COUNT" fi OUTDIR="${OUTDIR:-/tmp/talersticker-zero-$(date +%Y%m%d-%H%M%S)}" mkdir -p "$OUTDIR" TSV="$OUTDIR/results.tsv" LOG="$OUTDIR/run.log" SCRATCH="$OUTDIR/scratch" mkdir -p "$SCRATCH" SERVE_PIDFILE="$OUTDIR/serve.pids" if [ -z "${TALER_MON_STATE:-}" ]; then exec > >(tee -a "$LOG") 2>&1 fi now_ms() { python3 -c 'import time; print(int(time.time()*1000))'; } if [ -n "${WALLET_DB:-}" ] && [ "$PARALLEL" -gt 1 ]; then die "--wallet-db / WALLET_DB needs PARALLEL=1" fi slot_db() { if [ -n "${WALLET_DB:-}" ] && [ "$1" = "0" ]; then taler_mon_resolve_wallet_db "$OUTDIR/wallet-0.sqlite3" || exit 2 else echo "$OUTDIR/wallet-$1.sqlite3" fi } slot_sock() { echo "$OUTDIR/wallet-$1.sock"; } slot_log() { echo "$OUTDIR/wallet-serve-$1.log"; } stop_wallet_serve() { if [ -f "$SERVE_PIDFILE" ]; then while read -r pid; do [ -n "$pid" ] || continue kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true done <"$SERVE_PIDFILE" rm -f "$SERVE_PIDFILE" fi rm -f "$OUTDIR"/wallet-*.sock 2>/dev/null || true } trap 'stop_wallet_serve' EXIT _wcli_exec() { local cap="$1" shift local -a cmd cmd=( "$NODE_BIN" "$WALLET_CLI" "$@" ) if command -v timeout >/dev/null 2>&1; then timeout -k 2 "$cap" "${cmd[@]}" elif command -v gtimeout >/dev/null 2>&1; then gtimeout --kill-after=2 "$cap" "${cmd[@]}" else perl -e 'alarm shift; exec @ARGV' "$cap" "${cmd[@]}" fi } wcli_slot() { local slot="$1" local cap="$2" shift 2 local sock db sock=$(slot_sock "$slot") db=$(slot_db "$slot") if [ -S "$sock" ]; then _wcli_exec "$cap" --wallet-connection="$sock" --no-throttle "$@" else _wcli_exec "$cap" --wallet-db="$db" --no-throttle --skip-defaults "$@" fi } start_wallet_slot() { local slot="$1" local sock db log sock=$(slot_sock "$slot") db=$(slot_db "$slot") log=$(slot_log "$slot") rm -f "$sock" "$NODE_BIN" "$WALLET_CLI" --wallet-db="$db" --no-throttle --skip-defaults \ advanced serve --unix-path "$sock" \ >"$log" 2>&1 & local pid=$! echo "$pid" >>"$SERVE_PIDFILE" local i for i in $(seq 1 50); do if [ -S "$sock" ] && kill -0 "$pid" 2>/dev/null; then info "wallet serve slot=$slot pid=$pid" return 0 fi sleep 0.1 done echo "WARN: wallet serve slot=$slot socket not ready" return 1 } find_wallet_cli() { if [ -n "${WALLET_CLI:-}" ] && [ -f "$WALLET_CLI" ]; then printf '%s\n' "$WALLET_CLI" return 0 fi local c for c in \ "${HOME}/src/llm-qwythos/taler/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs" \ "${HOME}/src/taler/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs" \ "${HOME}/.local/bin/taler-wallet-cli.mjs" \ "${HOME}/src/llm-qwythos/taler/taler-typescript-core/packages/taler-wallet-cli/dist/taler-wallet-cli-bundled.cjs" do [ -f "$c" ] && { printf '%s\n' "$c"; return 0; } done return 1 } NODE_BIN=$(command -v node) || die "node not found" python3 - <<'PY' || die "python3 >= 3.11 required (taler-helper-sqlite3)" import sys raise SystemExit(0 if sys.version_info >= (3, 11) else 1) PY command -v taler-helper-sqlite3 >/dev/null || die "taler-helper-sqlite3 not on PATH" WALLET_CLI=$(find_wallet_cli) || die "taler-wallet-cli .mjs not found (set WALLET_CLI=)" json_get() { python3 -c 'import json,sys p,k=sys.argv[1],sys.argv[2] try: d=json.load(open(p)) v=d for part in k.split("."): if isinstance(v, dict): v=v.get(part) else: v=None break print("" if v is None else v) except Exception: print("") ' "$@" } create_template_order() { local summary="$1" out="$2" local body url if [ "${ZERO_PAY_POST}" = "empty" ]; then body='{}' else body=$(python3 -c 'import json,sys; print(json.dumps({"amount":sys.argv[1],"summary":sys.argv[2]}))' "$AMOUNT" "$summary") fi url="${MERCHANT}/instances/${INST}/templates/${TEMPLATE}" if [ "$INST" = "admin" ] || [ "$INST" = "default" ] || [ -z "$INST" ]; then url="${MERCHANT}/templates/${TEMPLATE}" fi curl -sS -m 20 -X POST -H "Content-Type: application/json" -d "$body" "$url" >"$out" } order_public() { local oid="$1" tok="$2" out="$3" local qoid qtok base qoid=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$oid") qtok=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$tok") if [ "$INST" = "admin" ] || [ "$INST" = "default" ] || [ -z "$INST" ]; then base="${MERCHANT}/orders/${qoid}?token=${qtok}" else base="${MERCHANT}/instances/${INST}/orders/${qoid}?token=${qtok}" fi curl -sS -m 12 "$base" >"$out" } order_is_paid() { merchant_order_json_is_paid "$1" } wallet_handle_done() { grep -q "Finished with status .done." "$1" 2>/dev/null } write_row() { # n oid create handle settle total status payuri printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$@" } # One payment. Args: n slot. Always writes scratch/row.N.tsv and scratch/msg.N.txt. do_one_pay() { local n="$1" slot="$2" local tag tag=$(printf '%03d' "$n") local nonce summary create_f status_f pay_f row_f msg_f nonce="${n}-$(date +%s)-$RANDOM" summary="taler-monitoring zero-pay #${n} ${nonce}" create_f="$SCRATCH/ord-$tag.json" status_f="$SCRATCH/st-$tag.json" pay_f="$SCRATCH/pay-$tag.out" row_f="$SCRATCH/row.$n.tsv" msg_f="$SCRATCH/msg.$n.txt" local t0 t1 t_create t_h0 t_h1 t_s0 t_s1 local create_ms handle_ms settle_ms total_ms local oid tok payuri hint hc status local settled merchant_paid i fail_tail t0=$(now_ms) if ! create_template_order "$summary" "$create_f"; then t1=$(now_ms) create_ms=$((t1-t0)) write_row "$n" "" "$create_ms" "" "" "$create_ms" "CREATE_HTTP_FAIL" "" >"$row_f" echo "[$tag] CREATE_HTTP_FAIL ${create_ms}ms slot=$slot" >"$msg_f" return 0 fi oid=$(json_get "$create_f" order_id) tok=$(json_get "$create_f" token) t_create=$(now_ms) create_ms=$((t_create-t0)) if [ -z "$oid" ] || [ -z "$tok" ]; then hint=$(json_get "$create_f" hint) write_row "$n" "" "$create_ms" "" "" "$create_ms" "CREATE_FAIL" "" >"$row_f" echo "[$tag] CREATE_FAIL ${create_ms}ms hint=${hint} slot=$slot" >"$msg_f" return 0 fi order_public "$oid" "$tok" "$status_f" || true payuri=$(json_get "$status_f" taler_pay_uri) if [ -z "$payuri" ]; then local mh mh=$(python3 -c 'from urllib.parse import urlparse; print(urlparse("'"$MERCHANT"'").hostname or "")') payuri="taler://pay/${mh}/instances/${INST}/${oid}/?c=${tok}" fi payuri=$(printf '%s' "$payuri" | sed 's/:443\//\//g; s/:443?/?/g') t_h0=$(now_ms) set +e wcli_slot "$slot" "$HANDLE_SECS" handle-uri --yes --non-interactive "$payuri" >"$pay_f" 2>&1 hc=$? set -e t_h1=$(now_ms) handle_ms=$((t_h1-t_h0)) if grep -q "Payment failed" "$pay_f" 2>/dev/null; then hc=1 fi settled=0 merchant_paid=0 t_s0=$(now_ms) i=1 while [ "$i" -le "$SETTLE_TRIES" ]; do order_public "$oid" "$tok" "$status_f" || true if order_is_paid "$status_f"; then merchant_paid=1 settled=1 break fi if wallet_handle_done "$pay_f"; then settled=1 fi if [ "$settled" = "1" ] && [ "$i" -ge 3 ]; then break fi sleep "$SETTLE_SLEEP" i=$((i+1)) done t_s1=$(now_ms) settle_ms=$((t_s1-t_s0)) total_ms=$((t_s1-t0)) if [ "$merchant_paid" = "1" ] || [ "$settled" = "1" ]; then status=PAID elif [ "$hc" = "0" ]; then status=HANDLE_OK_UNSETTLED else status=HANDLE_FAIL fi write_row "$n" "$oid" "$create_ms" "$handle_ms" "$settle_ms" "$total_ms" "$status" "$payuri" >"$row_f" echo "[$tag] $status oid=$oid create=${create_ms}ms handle=${handle_ms}ms settle=${settle_ms}ms total=${total_ms}ms slot=$slot" >"$msg_f" if [ "$status" != "PAID" ]; then fail_tail=$(tail -c 180 "$pay_f" | tr '\n' ' ') echo " handle-exit=${hc} tail=${fail_tail}" >>"$msg_f" fi } prep_slot_exchange() { local slot="$1" wcli_slot "$slot" 40 exchanges add "$EXCHANGE" >"$OUTDIR/exchanges-add-$slot.out" 2>&1 || true wcli_slot "$slot" 40 exchanges tos "$EXCHANGE" >"$OUTDIR/exchanges-tos-$slot.out" 2>&1 || true wcli_slot "$slot" 40 exchanges accept-tos "$EXCHANGE" >"$OUTDIR/exchanges-accept-tos-$slot.out" 2>&1 || true } section "zero-pay · ${AMOUNT} x ${COUNT} · ${INST}/${TEMPLATE}" info "merchant $MERCHANT" info "instance $INST" info "template $TEMPLATE amount=$AMOUNT" info "wallet $WALLET_CLI" info "python $(command -v python3) $(python3 -c 'import sys; print(".".join(map(str,sys.version_info[:3])))')" info "helper $(command -v taler-helper-sqlite3)" info "outdir $OUTDIR" info "gap ${GAP_S}s between blocks (not in total_ms)" info "parallel $PARALLEL per block (own wallet serve per slot)" if [ "$PARALLEL" -gt 20 ]; then echo "WARN: PARALLEL=$PARALLEL starts that many wallet-cli node processes" fi wcli_slot 0 25 version >"$OUTDIR/wallet-version.txt" 2>&1 || true info "wallet-cli $(head -1 "$OUTDIR/wallet-version.txt" 2>/dev/null || echo '?')" : >"$SERVE_PIDFILE" slot=0 while [ "$slot" -lt "$PARALLEL" ]; do start_wallet_slot "$slot" || true prep_slot_exchange "$slot" slot=$((slot+1)) done wcli_slot 0 20 balance >"$OUTDIR/balance-before.txt" 2>&1 || true info "balance before: $(tr '\n' ' ' < "$OUTDIR/balance-before.txt" | tail -c 200)" printf 'n\torder_id\tcreate_ms\thandle_ms\tsettle_ms\ttotal_ms\tstatus\tpay_uri\n' >"$TSV" ok_n=0 fail_n=0 RUN_START_MS=$(now_ms) n=1 while [ "$n" -le "$COUNT" ]; do block_end=$((n + PARALLEL - 1)) if [ "$block_end" -gt "$COUNT" ]; then block_end="$COUNT" fi slot=0 k="$n" pay_pids="" while [ "$k" -le "$block_end" ]; do do_one_pay "$k" "$slot" & pay_pids="$pay_pids $!" slot=$((slot+1)) k=$((k+1)) done set +e for pid in $pay_pids; do wait "$pid" done set -e k="$n" while [ "$k" -le "$block_end" ]; do if [ -f "$SCRATCH/msg.$k.txt" ]; then cat "$SCRATCH/msg.$k.txt" else echo "[$(printf '%03d' "$k")] MISSING (worker died)" fi if [ -f "$SCRATCH/row.$k.tsv" ]; then cat "$SCRATCH/row.$k.tsv" >>"$TSV" else write_row "$k" "" "" "" "" "" "WORKER_DIED" "" >>"$TSV" fi k=$((k+1)) done n=$((block_end + 1)) if [ "$n" -le "$COUNT" ]; then case "$GAP_S" in 0|0.0|0.00) ;; *) sleep "$GAP_S" ;; esac fi done RUN_END_MS=$(now_ms) wall_ms=$((RUN_END_MS-RUN_START_MS)) ok_n=$(awk -F'\t' 'NR>1 && $7=="PAID" {c++} END {print c+0}' "$TSV") fail_n=$((COUNT - ok_n)) section "zero-pay · summary" info "ok=$ok_n fail=$fail_n count=$COUNT parallel=$PARALLEL gap_s=$GAP_S wall_ms=$wall_ms out=$TSV" python3 - "$TSV" "$ok_n" "$fail_n" "$COUNT" "$wall_ms" "$OUTDIR/summary.txt" "$GAP_S" "$PARALLEL" <<'PY' import sys tsv, ok, fail, count, wall, outp, gap_s, parallel = sys.argv[1:] ok, fail, count, wall = int(ok), int(fail), int(count), int(wall) rows=[] with open(tsv) as f: next(f) for line in f: p=line.rstrip('\n').split('\t') if len(p)<7: continue rows.append(p) paid=[r for r in rows if r[6]=='PAID'] def nums(idx, rs): xs=[int(r[idx]) for r in rs if r[idx].isdigit()] return xs def fmt(xs): if not xs: return "n/a" xs=sorted(xs) med=xs[len(xs)//2] return "n=%d min=%d med=%d max=%d mean=%.1f" % (len(xs), xs[0], med, xs[-1], sum(xs)/len(xs)) lines=[] lines.append("ok=%d fail=%d count=%d parallel=%s wall_ms=%d wall_s=%.1f gap_s=%s" % ( ok, fail, count, parallel, wall, wall/1000.0, gap_s)) lines.append("create_ms " + fmt(nums(2, paid))) lines.append("handle_ms " + fmt(nums(3, paid))) lines.append("settle_ms " + fmt(nums(4, paid))) lines.append("total_ms " + fmt(nums(5, paid))) if paid: lines.append("first_paid_oid=%s last_paid_oid=%s" % (paid[0][1], paid[-1][1])) text="\n".join(lines)+"\n" open(outp,"w").write(text) print(text) PY wcli_slot 0 20 balance >"$OUTDIR/balance-after.txt" 2>&1 || true info "balance after: $(tr '\n' ' ' < "$OUTDIR/balance-after.txt" | tail -c 200)" if [ "$ok_n" -lt "$COUNT" ]; then fail "zero-pay" "ok=$ok_n fail=$fail_n count=$COUNT out=$TSV" summary exit 1 fi ok "zero-pay" "ok=$ok_n count=$COUNT wall_ms=$wall_ms out=$TSV" summary exit 0