#!/usr/bin/env bash # Systematic GOA withdraw ladder on bank.hacktivism.ch # # Per landing / intro docs: # 1) GET /intro/auto-account.json → personal goa-account-* (+ first pool URI) # 2) Shared pool withdrawals (explorer) with server auto-confirm # 3) wallet-cli: ToS + accept-uri + run-until-done each rung # 4) amounts from atomic-GOA up until bank/wallet fails # # Prefer the monitoring phase (random increasing ranges + timings + report): # ../taler-monitoring/taler-monitoring.sh ladder # LADDER_MAX_RUNGS=10 ../taler-monitoring/check_goa_ladder.sh # # Usage (standalone, fixed ladder): # ./goa-withdraw-ladder.sh # MAX_RUNGS=12 ./goa-withdraw-ladder.sh # AMOUNTS='GOA:0.000001 GOA:0.01 GOA:1 GOA:10 GOA:100' ./goa-withdraw-ladder.sh # # Env: # BANK default https://bank.hacktivism.ch # EXCHANGE default https://exchange.hacktivism.ch/ # EXP_USER default explorer # EXP_PW_FILE explorer password file (for mint + confirm) # WALLET_CLI path to taler-wallet-cli.mjs or binary # WDB wallet sqlite path # SHOTDIR result directory set -euo pipefail export PATH="/tmp/py313bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH}" BANK="${BANK:-https://bank.hacktivism.ch}" BANK="${BANK%/}" EXCHANGE="${EXCHANGE:-https://exchange.hacktivism.ch/}" EXP_USER="${EXP_USER:-explorer}" EXP_PW_FILE="${EXP_PW_FILE:-/Users/newkamek/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt}" SHOTDIR="${SHOTDIR:-/tmp/goa-ladder-$(date +%Y%m%d-%H%M%S)}" WDB="${WDB:-$SHOTDIR/wallet.db}" MAX_RUNGS="${MAX_RUNGS:-24}" CLI_JS="${WALLET_CLI_JS:-/Users/newkamek/src/taler-typescript-core/packages/taler-wallet-cli/bin/taler-wallet-cli.mjs}" mkdir -p "$SHOTDIR" LOG="$SHOTDIR/ladder.log" RESULTS="$SHOTDIR/results.tsv" echo -e "rung\tamount\tstatus\twid\tnote" >"$RESULTS" log() { printf '%s\n' "$*" | tee -a "$LOG"; } die() { log "ERROR: $*"; exit 1; } wcli() { if [ -f "$CLI_JS" ]; then node "$CLI_JS" --wallet-db="$WDB" --no-throttle "$@" else taler-wallet-cli --wallet-db="$WDB" --no-throttle "$@" fi } # Default ladder via Python: fixed 0 + random low picks + random high ranges + fixed max. # Prefer taler-monitoring check_goa_ladder.sh for full control (LADDER_* env). default_amounts() { python3 - <<'PY' import math, random from decimal import Decimal, ROUND_HALF_UP MAX = Decimal("4503599627370496") HIGH_FROM = Decimal("1000000") HIGH_RUNGS = 12 CUR = "GOA" def fmt(v: Decimal) -> str: q = v.quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) if q == q.to_integral(): return "%s:%s" % (CUR, format(int(q), "d")) return "%s:%s" % (CUR, format(q, "f").rstrip("0").rstrip(".")) def logu(lo, hi): lo = max(lo, 1e-12) if hi <= lo: return hi return math.exp(random.uniform(math.log(lo), math.log(hi))) out = [fmt(Decimal(0))] prev = Decimal(0) # low bands (same idea as monitoring LADDER_RANGES without 0:0) bands = [ (1e-6, 9e-6), (1e-5, 9e-5), (1e-4, 9e-4), (1e-3, 9e-3), (0.01, 0.09), (0.1, 0.9), (1, 9), (10, 49), (50, 99), (100, 499), (500, 999), (1000, 4999), (5000, 9999), (1e4, 5e4), (5e4, 1e5), (1e5, 5e5), (5e5, 2e6), ] for lo, hi in bands: lo2 = max(lo, float(prev) * 1.0000001 if prev > 0 else lo) if lo2 > hi: continue v = Decimal(str(logu(lo2, hi))).quantize(Decimal("0.00000001")) if v <= prev: continue if v >= MAX: continue out.append(fmt(v)) prev = v band_lo = max(float(HIGH_FROM), float(prev) * 1.0000001) band_hi = float(MAX - 1) if band_lo < band_hi and HIGH_RUNGS > 0: cuts = [band_lo, band_hi] for _ in range(HIGH_RUNGS - 1): cuts.append(logu(band_lo, band_hi)) cuts = sorted(set(cuts)) while len(cuts) < HIGH_RUNGS + 1: cuts.append(logu(band_lo, band_hi)) cuts = sorted(set(cuts)) segs = [(cuts[i], cuts[i + 1]) for i in range(len(cuts) - 1) if cuts[i + 1] > cuts[i] * 1.001] segs.sort(key=lambda t: t[0]) for r_lo, r_hi in segs[:HIGH_RUNGS]: floor = max(r_lo, float(prev) * 1.0000001) if floor >= r_hi: continue v = Decimal(str(logu(floor, r_hi))).quantize(Decimal(1)) if v <= prev or v >= MAX: continue out.append(fmt(v)) prev = v out.append(fmt(MAX)) print("\n".join(out)) PY } if [ -n "${AMOUNTS:-}" ]; then # shellcheck disable=SC2206 LADDER=($AMOUNTS) else LADDER=() while IFS= read -r line; do [ -n "$line" ] || continue LADDER+=("$line") [ "${#LADDER[@]}" -ge "$MAX_RUNGS" ] && break done < <(default_amounts) fi [ -f "$EXP_PW_FILE" ] || die "missing explorer password: $EXP_PW_FILE" EXP_PW="$(tr -d '\n' <"$EXP_PW_FILE")" explorer_token() { curl -sS -m 20 -u "${EXP_USER}:${EXP_PW}" \ -H 'Content-Type: application/json' \ -d '{"scope":"readwrite","duration":{"d_us":3600000000}}' \ "${BANK}/accounts/${EXP_USER}/token" \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])' } confirm_when_selected() { local wid="$1" tok="$2" local i st for i in $(seq 1 30); do st=$(curl -sS -m 12 "${BANK}/taler-integration/withdrawal-operation/${wid}" \ | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status",""))' 2>/dev/null || true) case "$st" in selected) code=$(curl -sS -m 20 -o "$SHOTDIR/conf-${wid}.json" -w '%{http_code}' -X POST \ -H "Authorization: Bearer ${tok}" \ -H 'Content-Type: application/json' -d '{}' \ "${BANK}/accounts/${EXP_USER}/withdrawals/${wid}/confirm") log " auto-confirm HTTP $code (status was selected)" return 0 ;; confirmed) log " already confirmed" return 0 ;; aborted) log " aborted" return 1 ;; esac sleep 1 done log " timeout waiting for selected (last=$st)" return 1 } mint_withdraw() { local amount="$1" curl -sS -m 30 -H "Authorization: Bearer ${TOK}" \ -H 'Content-Type: application/json' \ -d "{\"amount\":\"${amount}\"}" \ "${BANK}/accounts/${EXP_USER}/withdrawals" } wallet_avail() { wcli balance 2>/dev/null | python3 -c ' import json,sys,re t=sys.stdin.read() i=t.find("{") if i<0: print("0"); raise SystemExit d=json.loads(t[i:t.rfind("}")+1]) for b in d.get("balances") or []: a=b.get("available") or "" if a.startswith("GOA:"): print(a.split(":",1)[1]); raise SystemExit print("0") ' 2>/dev/null || echo "0" } # --- main --- log "SHOTDIR=$SHOTDIR" log "BANK=$BANK EXCHANGE=$EXCHANGE" log "ladder (${#LADDER[@]} rungs): ${LADDER[*]}" log "=== 1) auto-account (bank.hacktivism.ch/intro) ===" curl -sS -m 30 "${BANK}/intro/auto-account.json" | tee "$SHOTDIR/auto-account.json" | python3 -m json.tool | tee -a "$LOG" | head -40 ACCT_USER=$(python3 -c 'import json; print(json.load(open("'"$SHOTDIR"'/auto-account.json"))["username"])') ACCT_PASS=$(python3 -c 'import json; print(json.load(open("'"$SHOTDIR"'/auto-account.json"))["password"])') log "personal account: $ACCT_USER (balance GOA:0 — pool withdraws use explorer + auto-confirm)" echo "$ACCT_USER" >"$SHOTDIR/account-user.txt" echo "$ACCT_PASS" >"$SHOTDIR/account-pass.txt" log "=== 2) explorer token + wallet init ===" TOK=$(explorer_token) log "explorer token ok" wcli exchanges add "$EXCHANGE" 2>&1 | tee "$SHOTDIR/ex-add.out" | tail -5 || true wcli exchanges update "$EXCHANGE" 2>&1 | tee "$SHOTDIR/ex-upd.out" | tail -5 || true wcli exchanges accept-tos "$EXCHANGE" 2>&1 | tee "$SHOTDIR/ex-tos.out" | tail -5 || true n=0 fail_rung="" for amt in "${LADDER[@]}"; do n=$((n + 1)) log "" log "======== rung $n / ${#LADDER[@]} amount=$amt ========" WD=$(mint_withdraw "$amt" 2>&1) || true echo "$WD" | tee "$SHOTDIR/wd-${n}.json" >/dev/null if ! echo "$WD" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then log "FAIL mint: $WD" echo -e "${n}\t${amt}\tFAIL_MINT\t-\t$(echo "$WD" | tr '\n' ' ' | head -c 200)" >>"$RESULTS" fail_rung="$amt" break fi WID=$(echo "$WD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("withdrawal_id",""))') URI=$(echo "$WD" | python3 -c 'import json,sys; u=json.load(sys.stdin).get("taler_withdraw_uri",""); print(u.replace(":443/","/"))') [ -n "$WID" ] && [ -n "$URI" ] || { log "FAIL parse mint: $WD" echo -e "${n}\t${amt}\tFAIL_PARSE\t-\t" >>"$RESULTS" fail_rung="$amt" break } log " WID=$WID" log " URI=$URI" before=$(wallet_avail) if ! wcli withdraw accept-uri --exchange "$EXCHANGE" "$URI" 2>&1 | tee "$SHOTDIR/accept-${n}.out" | tail -20; then # still try confirm if selected true fi if ! confirm_when_selected "$WID" "$TOK"; then log "FAIL confirm $amt" echo -e "${n}\t${amt}\tFAIL_CONFIRM\t${WID}\t" >>"$RESULTS" fail_rung="$amt" # keep going? user asked until it no longer works — stop break fi settled=0 for r in $(seq 1 20); do wcli run-until-done 2>&1 | tee -a "$SHOTDIR/rud-${n}.out" >/dev/null || true after=$(wallet_avail) # progress if available increased (float-safe string compare via python) if python3 -c "import sys; sys.exit(0 if float(sys.argv[1])>float(sys.argv[2]) else 1)" "$after" "$before" 2>/dev/null; then settled=1 log " settled avail GOA:$after (was $before)" break fi sleep 1 done after=$(wallet_avail) if [ "$settled" = "1" ]; then log "OK $amt → wallet GOA:$after" echo -e "${n}\t${amt}\tOK\t${WID}\tavail=${after}" >>"$RESULTS" else # bank may be confirmed but wire lag — check transfer_done xfer=$(curl -sS -m 10 "${BANK}/taler-integration/withdrawal-operation/${WID}" \ | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("transfer_done"), d.get("status"))') log " not settled yet transfer_done/status=$xfer avail=$after" if echo "$xfer" | grep -q True; then echo -e "${n}\t${amt}\tOK_BANK_LAG\t${WID}\tavail=${after}" >>"$RESULTS" log "OK bank confirmed (wallet lag) $amt" else echo -e "${n}\t${amt}\tFAIL_SETTLE\t${WID}\tavail=${after} $xfer" >>"$RESULTS" fail_rung="$amt" break fi fi done log "" log "=== final balance ===" wcli balance 2>&1 | tee "$SHOTDIR/balance-final.out" log "" log "=== results ===" column -t -s $'\t' "$RESULTS" 2>/dev/null || cat "$RESULTS" log "SHOTDIR=$SHOTDIR" if [ -n "$fail_rung" ]; then log "STOPPED at first failure: $fail_rung" exit 2 fi log "All rungs OK" exit 0