#!/bin/bash # Amount-ladder benchmark — drive from the local machine, measure on koopa (inside containers). # See README.md in this directory. set -eu BENCH_DIR=$(cd "$(dirname "$0")" && pwd) ROOT=$(cd "$BENCH_DIR/../.." && pwd) SECRETS="${SECRETS_DIR:-}" if [ -z "$SECRETS" ]; then if [ -d "$ROOT/../koopa-admin-secrets" ]; then SECRETS=$(cd "$ROOT/../koopa-admin-secrets" && pwd) else SECRETS="$ROOT/../koopa-admin-secrets" fi fi SCRATCH="${ROOT}/.tmp/amount-bench-$(date -u +%Y%m%d-%H%M%S)" mkdir -p "$SCRATCH" chmod 700 "$SCRATCH" MONO="${MONO:-/Users/newkamek/src/taler/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs}" WDB="$SCRATCH/wallet.sqlite3" EX="${EXCHANGE_URL:-https://exchange.hacktivism.ch/}" KOOPA="${KOOPA_HOST:-koopa}" INST="${MERCHANT_INSTANCE:-goa-demo-cp4zqk}" TPL="${TEMPLATE_ID:-goa-free}" MER_HOST="${MER_HOST:-taler.hacktivism.ch}" FAT_USER="${FAT_USER:-bench-fat}" # ~20 payments: small → medium → large (atomic/tiny through multi-kilo) # Override: AMOUNTS='GOA:1 GOA:10' ./run.sh AMOUNTS="${AMOUNTS:-GOA:0.000001 GOA:0.00001 GOA:0.0001 GOA:0.001 GOA:0.01 GOA:0.05 GOA:0.1 GOA:0.5 GOA:1 GOA:2 GOA:5 GOA:10 GOA:25 GOA:50 GOA:100 GOA:250 GOA:500 GOA:1000 GOA:2500 GOA:5000}" # sum of default ladder ≈ GOA:9433 → withdraw headroom for multi-coin + fees WITHDRAW_AMT="${WITHDRAW_AMT:-GOA:50000}" CREDIT_AMT="${CREDIT_AMT:-GOA:200000}" BANK_ADMIN_PW_FILE="${BANK_ADMIN_PW_FILE:-$SECRETS/koopa/host-root/taler-bank/bank-admin-password.txt}" BANK_FAT_PW_FILE="${BANK_FAT_PW_FILE:-$SECRETS/koopa/host-root/taler-bank/bank-bench-fat-password.txt}" MER_PW_FILE="${MER_PW_FILE:-$SECRETS/koopa/host-root/taler-merchant/merchant-${INST}-password.txt}" wcli() { node "$MONO" --wallet-db="$WDB" --no-throttle --skip-defaults "$@"; } log() { echo "$(date -u +%H:%M:%S) $*" | tee -a "$SCRATCH/run.log"; } now() { python3 -c 'import time;print(time.time())'; } die() { log "FAIL $*"; exit 1; } [ -f "$MONO" ] || die "wallet cli missing: $MONO" [ -f "$BANK_ADMIN_PW_FILE" ] || die "missing $BANK_ADMIN_PW_FILE" [ -f "$MER_PW_FILE" ] || die "missing $MER_PW_FILE" ADMIN_PW=$(tr -d '\n' <"$BANK_ADMIN_PW_FILE") MER_PW=$(tr -d '\n' <"$MER_PW_FILE") if [ -f "$BANK_FAT_PW_FILE" ]; then FAT_PW=$(tr -d '\n' <"$BANK_FAT_PW_FILE") else FAT_PW=$(openssl rand -hex 12) mkdir -p "$(dirname "$BANK_FAT_PW_FILE")" printf '%s\n' "$FAT_PW" >"$BANK_FAT_PW_FILE" chmod 600 "$BANK_FAT_PW_FILE" log "generated fat password -> $BANK_FAT_PW_FILE (secrets repo)" fi # --- remote: run short script as hernani, capture stdout --- run_koopa() { local id="$1" shift local script_local="$SCRATCH/remote-$id.sh" local mark="BENCH_${id}_OK" { echo '#!/bin/bash' echo 'set +e' printf '%s\n' "$@" echo "echo $mark" } >"$script_local" scp -o BatchMode=yes -q "$script_local" "${KOOPA}:/tmp/bench-run-$id.sh" ssh -o BatchMode=yes "$KOOPA" "bash /tmp/bench-run-$id.sh; rm -f /tmp/bench-run-$id.sh" \ >"$SCRATCH/koopa-$id.out" 2>&1 || true if ! grep -q "$mark" "$SCRATCH/koopa-$id.out" 2>/dev/null; then log "WARN run_koopa $id incomplete (see koopa-$id.out)" return 1 fi return 0 } # Measure **inside** containers (and host loadavg) sample_load() { local tag="$1" scp -o BatchMode=yes -q "$BENCH_DIR/sample-load-inside.sh" "${KOOPA}:/tmp/sample-load-inside.sh" ssh -o BatchMode=yes "$KOOPA" "bash /tmp/sample-load-inside.sh $(printf %q "$tag"); rm -f /tmp/sample-load-inside.sh" \ >"$SCRATCH/koopa-load-$tag.out" 2>&1 || true } log "scratch=$SCRATCH" log "AMOUNTS=$AMOUNTS" # --- preflight (external, public HTTPS) --- for url in \ "https://taler.hacktivism.ch/config" \ "https://exchange.hacktivism.ch/keys" \ "https://taler.hacktivism.ch/instances/${INST}/templates/${TPL}" do code=$(curl -sk -o /dev/null -w '%{http_code}' -m 10 "$url" || echo err) log "preflight $url -> $code" [ "$code" = "200" ] || die "preflight failed $url" done # --- bank setup on koopa (API localhost, orchestrated from the local machine) --- log "=== bank setup $FAT_USER credit=$CREDIT_AMT withdraw=$WITHDRAW_AMT ===" { echo '#!/bin/bash' echo 'set -euo pipefail' echo "export ADMIN_PW=$(printf %q "$ADMIN_PW")" echo "export FAT_PW=$(printf %q "$FAT_PW")" echo "export FAT_USER=$(printf %q "$FAT_USER")" echo "export CREDIT_AMT=$(printf %q "$CREDIT_AMT")" echo "export WITHDRAW_AMT=$(printf %q "$WITHDRAW_AMT")" cat <<'EOS' BANK=http://127.0.0.1:9012 AT=$(curl -sS -u "admin:${ADMIN_PW}" -H "Content-Type: application/json" -d '{"scope":"readwrite"}' \ "$BANK/accounts/admin/token" | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])") echo admin_tok_len=${#AT} curl -sS -o /tmp/acc.json -w "create:%{http_code}\n" -X POST -H "Authorization: Bearer $AT" \ -H "Content-Type: application/json" \ -d "{\"username\":\"${FAT_USER}\",\"password\":\"${FAT_PW}\",\"name\":\"Bench Fat\",\"is_public\":false,\"is_taler_exchange\":false,\"debit_threshold\":\"GOA:0\"}" \ "$BANK/accounts" || true echo create_body=$(head -c 160 /tmp/acc.json) python3 - <<'PY' import json, os A="0123456789ABCDEFGHJKMNPQRSTVWXYZ" def c32(b): v=bits=0; o=[] for x in b: v=(v<<8)|x; bits+=8 while bits>=5: bits-=5; o.append(A[(v>>bits)&31]) if bits: o.append(A[(v<<(5-bits))&31]) return "".join(o) uid=c32(os.urandom(32)) user=os.environ["FAT_USER"] amt=os.environ["CREDIT_AMT"] payto=f"payto://x-taler-bank/bank.hacktivism.ch/{user}?receiver-name={user}&message=bench-credit" json.dump({"payto_uri":payto,"amount":amt,"request_uid":uid}, open("/tmp/tx.json","w")) print("credit", amt, "->", user, "uid", uid) PY curl -sS -o /tmp/cred.json -w "credit:%{http_code}\n" -X POST -H "Authorization: Bearer $AT" \ -H "Content-Type: application/json" -d @/tmp/tx.json "$BANK/accounts/admin/transactions" echo credit_body=$(head -c 200 /tmp/cred.json) BT=$(curl -sS -u "${FAT_USER}:${FAT_PW}" -H "Content-Type: application/json" -d '{"scope":"readwrite"}' \ "$BANK/accounts/${FAT_USER}/token" | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])") echo fat_tok_len=${#BT} curl -sS -H "Authorization: Bearer $BT" "$BANK/accounts/${FAT_USER}" -o /tmp/fatbal.json echo fat_account=$(head -c 280 /tmp/fatbal.json) curl -sS -o /tmp/wd.json -w "wd:%{http_code}\n" -X POST -H "Authorization: Bearer $BT" \ -H "Content-Type: application/json" -d "{\"amount\":\"${WITHDRAW_AMT}\"}" \ "$BANK/accounts/${FAT_USER}/withdrawals" python3 - <<'PY' import json d=json.load(open("/tmp/wd.json")) print("wd_keys", list(d.keys())[:20]) uri=d.get("taler_withdraw_uri") or d.get("withdrawal_uri") or "" wid=d.get("withdrawal_id") or d.get("id") or "" if not uri: for v in d.values(): if isinstance(v, str) and v.startswith("taler"): uri=v open("/tmp/bench-wuri.txt","w").write(uri) open("/tmp/bench-wid.txt","w").write(wid) print("WURI", uri) print("WID", wid) assert uri and wid, d PY # cleanup setup temps on host (not durable) rm -f /tmp/acc.json /tmp/tx.json /tmp/cred.json /tmp/fatbal.json /tmp/wd.json echo BENCH_banksetup_OK EOS } >"$SCRATCH/remote-banksetup.sh" scp -o BatchMode=yes -q "$SCRATCH/remote-banksetup.sh" "${KOOPA}:/tmp/bench-run-banksetup.sh" ssh -o BatchMode=yes "$KOOPA" 'bash /tmp/bench-run-banksetup.sh; rm -f /tmp/bench-run-banksetup.sh' \ | tee "$SCRATCH/koopa-banksetup.out" grep -q BENCH_banksetup_OK "$SCRATCH/koopa-banksetup.out" || die "bank setup failed" scp -o BatchMode=yes -q "${KOOPA}:/tmp/bench-wuri.txt" "${KOOPA}:/tmp/bench-wid.txt" "$SCRATCH/" ssh -o BatchMode=yes "$KOOPA" 'rm -f /tmp/bench-wuri.txt /tmp/bench-wid.txt' || true WURI=$(tr -d '\n' <"$SCRATCH/bench-wuri.txt") WID=$(tr -d '\n' <"$SCRATCH/bench-wid.txt") [ -n "$WURI" ] || die "missing withdraw URI" log "WURI=$WURI WID=$WID" log "=== load baseline (internal) ===" sample_load baseline log "=== wallet: exchange + withdraw (external) ===" wcli exchanges add "$EX" 2>&1 | tee "$SCRATCH/ex-add.out" || true wcli exchanges update "$EX" 2>&1 | tee "$SCRATCH/ex-upd.out" || true wcli exchanges tos "$EX" 2>&1 | tee "$SCRATCH/ex-tos.out" || true wcli exchanges accept-tos "$EX" 2>&1 | tee "$SCRATCH/ex-accept.out" || true TW0=$(now) # 1) Wallet selects exchange (status → selected). Must happen BEFORE bank confirm. wcli withdraw accept-uri --exchange "$EX" "$WURI" 2>&1 | tee "$SCRATCH/wd-accept.out" || true # 2) Wait until bank sees selection, then confirm once (libeufin rejects confirm if still unselected). log "=== bank: wait selected → confirm ===" run_koopa wdconfirm \ "export FAT_USER=$(printf %q "$FAT_USER")" \ "export FAT_PW=$(printf %q "$FAT_PW")" \ "export WID=$(printf %q "$WID")" \ 'BANK=http://127.0.0.1:9012' \ 'BT=$(curl -sS -u "${FAT_USER}:${FAT_PW}" -H "Content-Type: application/json" -d "{\"scope\":\"readwrite\"}" "$BANK/accounts/${FAT_USER}/token" | python3 -c "import sys,json;print(json.load(sys.stdin).get(\"access_token\",\"\"))")' \ 'echo WID=$WID tok_len=${#BT}' \ 'st=""' \ 'for i in $(seq 1 60); do curl -sS "$BANK/taler-integration/withdrawal-operation/${WID}" -o /tmp/wdst.json || true st=$(python3 -c "import json;print(json.load(open(\"/tmp/wdst.json\")).get(\"status\",\"\"))" 2>/dev/null || true) echo "wait_select #$i status=$st" case "$st" in selected|confirmed) break ;; esac sleep 1 done' \ 'case "$st" in selected) echo confirming... curl -sS -o /tmp/wdconf.out -w "confirm_http:%{http_code}\n" -X POST \ -H "Authorization: Bearer $BT" -H "Content-Type: application/json" -d "{}" \ "$BANK/accounts/${FAT_USER}/withdrawals/${WID}/confirm" head -c 120 /tmp/wdconf.out; echo ;; confirmed) echo already_confirmed ;; *) echo "WARN never selected (last=$st)" ;; esac' \ 'for i in $(seq 1 30); do curl -sS "$BANK/taler-integration/withdrawal-operation/${WID}" -o /tmp/wdst.json || true python3 -c "import json;d=json.load(open(\"/tmp/wdst.json\"));print(\"post\",d.get(\"status\"),\"transfer\",d.get(\"transfer_done\"))" st=$(python3 -c "import json;print(json.load(open(\"/tmp/wdst.json\")).get(\"status\",\"\"))" 2>/dev/null || true) td=$(python3 -c "import json;print(json.load(open(\"/tmp/wdst.json\")).get(\"transfer_done\"))" 2>/dev/null || true) case "$st" in confirmed) break ;; esac [ "$td" = "True" ] || [ "$td" = "true" ] && break sleep 1 done rm -f /tmp/wdst.json /tmp/wdconf.out' || true # 3) Wallet finishes planchets (may take minutes for large withdraws) log "=== wallet: run-until-done after confirm ===" for round in $(seq 1 30); do log "withdraw run-until-done round $round" # timeout each round so we do not hang forever on long-poll set +e # 120s max per round perl -e 'alarm shift; exec @ARGV' 120 \ node "$MONO" --wallet-db="$WDB" --no-throttle --skip-defaults run-until-done \ 2>&1 | tee -a "$SCRATCH/wd-rud.out" set -e wcli balance 2>&1 | tee "$SCRATCH/bal-wd-$round.out" if python3 - "$SCRATCH/bal-wd-$round.out" <<'PY' import re,sys t=open(sys.argv[1]).read() vals=[float(x) for x in re.findall(r"GOA:([0-9.]+)", t)] sys.exit(0 if vals and max(vals) >= 100 else 1) PY then log "withdraw OK" break fi sleep 1 done TW1=$(now) python3 -c "print('withdraw_wall_s', round(float('$TW1')-float('$TW0'), 3))" | tee "$SCRATCH/withdraw-time.txt" wcli balance 2>&1 | tee "$SCRATCH/balance-before-pays.out" sample_load after-withdraw CSV="$SCRATCH/payments.csv" echo "idx,amount,handle_s,rud_s,total_s,status,order_id,loadavg,notes" >"$CSV" idx=0 for AMT in $AMOUNTS; do idx=$((idx + 1)) # no trailing slash; amount only (template has fixed summary) URI="taler://pay-template/${MER_HOST}/instances/${INST}/${TPL}?amount=${AMT}" echo "$URI" >"$SCRATCH/uri-$idx.txt" log "=== pay #$idx $AMT ===" log "uri=$URI" load_before=$(ssh -o BatchMode=yes "$KOOPA" 'cut -d" " -f1 /proc/loadavg' 2>/dev/null || echo "?") sample_load "pay$idx" & SPID=$! T0=$(now) set +e wcli handle-uri --yes --non-interactive "$URI" 2>&1 | tee "$SCRATCH/pay-$idx-handle.out" T1=$(now) wcli run-until-done 2>&1 | tee "$SCRATCH/pay-$idx-rud.out" T2=$(now) set -e handle_s=$(python3 -c "print(round(float('$T1')-float('$T0'),3))") rud_s=$(python3 -c "print(round(float('$T2')-float('$T1'),3))") total_s=$(python3 -c "print(round(float('$T2')-float('$T0'),3))") wait "$SPID" 2>/dev/null || true load_after=$(ssh -o BatchMode=yes "$KOOPA" 'cut -d" " -f1 /proc/loadavg' 2>/dev/null || echo "?") run_koopa "ord$idx" \ "export MPW=$(printf %q "$MER_PW")" \ "export INST=$(printf %q "$INST")" \ "export AMT=$(printf %q "$AMT")" \ 'AUTH="Authorization: Bearer secret-token:${MPW}"' \ 'curl -sk -H "$AUTH" "https://127.0.0.1:9010/instances/${INST}/private/orders?paid=YES&delta=-30" -o /tmp/ol.json' \ 'python3 - </dev/null || true)' \ 'if [ -n "$OID" ]; then curl -sk -H "$AUTH" "https://127.0.0.1:9010/instances/${INST}/private/orders/${OID}" -o /tmp/od.json python3 -c "import json;d=json.load(open(\"/tmp/od.json\"));print(\"DETAIL paid\",d.get(\"paid\"),\"status\",d.get(\"order_status\"),\"amount\",(d.get(\"contract_terms\")or{}).get(\"amount\"))" fi rm -f /tmp/ol.json /tmp/od.json /tmp/oid.txt' || true status=unknown if grep -qE 'DETAIL paid True|DETAIL paid true' "$SCRATCH/koopa-ord$idx.out" 2>/dev/null; then status=paid elif grep -qiE 'insufficient balance' "$SCRATCH/pay-$idx-handle.out" 2>/dev/null; then status=insufficient elif grep -qiE 'error|fail|invalid|404|409|Unexpected HTTP' "$SCRATCH/pay-$idx-handle.out" 2>/dev/null; then status=error elif grep -qi 'paid' "$SCRATCH/pay-$idx-rud.out" 2>/dev/null; then status=maybe fi oid=$(awk '/^ORDER /{print $2; exit}' "$SCRATCH/koopa-ord$idx.out" 2>/dev/null || true) notes="load ${load_before}->${load_after}" echo "$idx,$AMT,$handle_s,$rud_s,$total_s,$status,$oid,$load_after,$notes" >>"$CSV" log "pay #$idx $AMT total=${total_s}s status=$status oid=$oid load=$load_after" wcli balance 2>&1 | tee "$SCRATCH/bal-after-$idx.out" || true done sample_load final wcli balance 2>&1 | tee "$SCRATCH/balance-final.out" || true python3 - "$CSV" "$SCRATCH" <<'PY' | tee "$SCRATCH/RESULTS.md" import csv, sys csv_path, scratch = sys.argv[1], sys.argv[2] rows = list(csv.DictReader(open(csv_path))) print("# Amount ladder payment benchmark") print() print(f"- Scratch: `{scratch}`") print(f"- Drive: Local-machine wallet-cli (external) · Measure: koopa podman (internal)") print(f"- Template: `goa-free` on `goa-demo-cp4zqk`") print(f"- URI: `taler://pay-template/…/instances/…/goa-free?amount=…` (no trailing slash, no summary)") print() print("| # | Amount | handle (s) | run-until-done (s) | **total (s)** | status | order | loadavg |") print("|--:|--------|----------:|-------------------:|--------------:|--------|-------|--------:|") for r in rows: print(f"| {r['idx']} | `{r['amount']}` | {r['handle_s']} | {r['rud_s']} | **{r['total_s']}** | {r['status']} | `{r.get('order_id','')}` | {r.get('loadavg','')} |") print() paid = [r for r in rows if r["status"] == "paid"] print(f"Paid **{len(paid)}/{len(rows)}**.") if paid: totals = [float(r["total_s"]) for r in paid if r["total_s"]] if totals: print(f"**Min** {min(totals):.3f}s · **Max** {max(totals):.3f}s · **Avg** {sum(totals)/len(totals):.3f}s") print() print("Inside samples: `koopa-load-*.out` in scratch.") PY # durable copy of last RESULTS into benchmarks tree (no secrets) cp "$SCRATCH/RESULTS.md" "$BENCH_DIR/LAST_RESULTS.md" cp "$CSV" "$BENCH_DIR/LAST_payments.csv" log "DONE -> $SCRATCH/RESULTS.md (also benchmarks/amount-ladder/LAST_*)" cat "$SCRATCH/RESULTS.md"