From 027fb5bd5de22ec7ea7b2fb951071dd667d3deb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 9 Jul 2026 14:54:43 +0200 Subject: [PATCH] exchange: landing-stats + no-terms stubs (TERMS_ETAG no-terms-v0) --- .../terms-src/no-privacy-v0.en.rst | 15 + .../terms-src/no-terms-v0.en.rst | 16 + scripts/taler-exchange/install_no_terms.sh | 263 +++++++++++++++ .../taler-exchange/landing-stats-exchange.sh | 300 ++++++++++++++++++ 4 files changed, 594 insertions(+) create mode 100644 configs/taler-exchange/terms-src/no-privacy-v0.en.rst create mode 100644 configs/taler-exchange/terms-src/no-terms-v0.en.rst create mode 100644 scripts/taler-exchange/install_no_terms.sh create mode 100644 scripts/taler-exchange/landing-stats-exchange.sh diff --git a/configs/taler-exchange/terms-src/no-privacy-v0.en.rst b/configs/taler-exchange/terms-src/no-privacy-v0.en.rst new file mode 100644 index 0000000..9a3706e --- /dev/null +++ b/configs/taler-exchange/terms-src/no-privacy-v0.en.rst @@ -0,0 +1,15 @@ +No Privacy Policy Required +========================== + +This is an **experimental / exploration** GNU Taler exchange for **GOA**. + +**No formal privacy policy is required** for this demo service. + +Note (high level): + +* Wire transfers via the regional bank may identify bank account holders. +* The exchange processes withdrawals, deposits, and related operations + as required by the GNU Taler protocol. +* Logs may be kept for operation and debugging. + +Do not use this service if that is unacceptable. diff --git a/configs/taler-exchange/terms-src/no-terms-v0.en.rst b/configs/taler-exchange/terms-src/no-terms-v0.en.rst new file mode 100644 index 0000000..ff6b131 --- /dev/null +++ b/configs/taler-exchange/terms-src/no-terms-v0.en.rst @@ -0,0 +1,16 @@ +No Terms Required +================= + +This is an **experimental / exploration** GNU Taler exchange for the +currency **GOA** (hacktivism.ch). + +**No formal terms of service are required** to use this service. + +By withdrawing or using GOA coins you acknowledge that: + +* This service is for exploration and testing only. +* There is no guaranteed availability, support, or redemption. +* Operators may reset balances or change configuration without notice. +* Do not use real money you cannot afford to lose. + +If you do not agree, do not use this exchange. diff --git a/scripts/taler-exchange/install_no_terms.sh b/scripts/taler-exchange/install_no_terms.sh new file mode 100644 index 0000000..2a79c6a --- /dev/null +++ b/scripts/taler-exchange/install_no_terms.sh @@ -0,0 +1,263 @@ +#!/bin/bash +# Install "No Terms Required" ToS + privacy for the exchange (styled like merchant terms). +# Run as root inside the exchange container. +set -euo pipefail +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" + +CONF="${TALER_EXCHANGE_CONFIG:-/etc/taler-exchange/taler-exchange.conf}" +DATA_HOME=$(taler-exchange-config -c "$CONF" -f -s PATHS -o TALER_DATA_HOME 2>/dev/null || true) +DATA_HOME="${DATA_HOME:-/var/lib/taler-exchange/}" +TERMS_DIR="${DATA_HOME%/}/terms" +LANG_DIR="$TERMS_DIR/en" +mkdir -p "$LANG_DIR" + +# Bump when HTML/style changes (long Cache-Control on /terms). +TOS_ETAG="${TERMS_ETAG:-no-terms-v0}" +# Prefer Swiss FADP privacy install (install_swiss_privacy.sh); fallback etag here +PP_ETAG="${PRIVACY_ETAG:-exchange-pp-swiss-v0}" + +# Shared palette with merchant-tos-dual / bank terms pages +COMMON_CSS=' + :root { color-scheme: dark light; } + body { + font-family: system-ui, -apple-system, sans-serif; + max-width: 40rem; margin: 2rem auto; padding: 0 1.1rem 3rem; + line-height: 1.5; color: #e8e6e3; background: #1a1520; + } + h1 { font-size: 1.35rem; font-weight: 800; margin: 0 0 1rem; color: #f5f0ea; } + h2 { font-size: 1.05rem; margin: 1.4rem 0 0.5rem; color: #e8c878; } + p, li { font-size: 0.98rem; } + ul { padding-left: 1.2rem; } + code, a { color: #5eead4; } + a { text-decoration: none; } + a:hover { text-decoration: underline; } + .badge { + display: inline-block; font-size: 0.72rem; font-weight: 700; + letter-spacing: 0.06em; text-transform: uppercase; + color: #c4b5fd; border: 1px solid rgba(196,181,253,0.35); + border-radius: 999px; padding: 0.2rem 0.65rem; margin-bottom: 0.85rem; + } + .cur { + border-radius: 12px; padding: 0.75rem 0.9rem; margin: 0.85rem 0 1rem; + border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); + } + .cur strong { display: block; font-size: 1.05rem; margin-bottom: 0.25rem; color: #5eead4; } + .muted { color: #a39e98; font-size: 0.88rem; } + footer { margin-top: 2rem; font-size: 0.85rem; color: #a39e98; } +' + +write_tos() { + local base="$1" + local title="No Terms Required" + local body_txt body_md + + body_txt='No Terms Required. + +This is an experimental / exploration GNU Taler exchange for the currency GOA (hacktivism.ch). + +No formal terms of service are required to use this service. + +By withdrawing or using GOA coins you acknowledge that: +- This service is for exploration and testing only. +- GOA is not legal tender and has no guaranteed real-world value or redemption. +- There is no guaranteed availability, support, or uptime. +- Operators may reset balances or change configuration without notice. +- Do not use real money you cannot afford to lose. + +If you do not agree, do not use this exchange. + +Related: +- Bank: https://bank.hacktivism.ch/intro/ +- Merchant terms: https://taler.hacktivism.ch/terms +- Bank terms: https://bank.hacktivism.ch/terms + +Privacy: +Processing under Swiss FADP (revDSG). What data is retained is listed at +https://exchange.hacktivism.ch/privacy +' + + body_md='# No Terms Required + +This is an **experimental / exploration** GNU Taler exchange for the currency **GOA** (hacktivism.ch). + +**No formal terms of service** are required to use this service. + +By withdrawing or using GOA coins you acknowledge that: + +- This service is for exploration and testing only. +- GOA is not legal tender and has no guaranteed real-world value or redemption. +- There is no guaranteed availability, support, or uptime. +- Operators may reset balances or change configuration without notice. +- Do not use real money you cannot afford to lose. + +If you do not agree, do not use this exchange. + +## Related + +- [Bank intro](https://bank.hacktivism.ch/intro/) +- [Bank terms](https://bank.hacktivism.ch/terms) +- [Merchant terms](https://taler.hacktivism.ch/terms) +- [Exchange privacy](https://exchange.hacktivism.ch/privacy) + +## Privacy + +Processing under Swiss FADP (revDSG). What data is retained is listed on +[exchange.hacktivism.ch/privacy](https://exchange.hacktivism.ch/privacy). +' + + printf '%s\n' "$body_txt" >"$LANG_DIR/${base}.txt" + printf '%s\n' "$body_md" >"$LANG_DIR/${base}.md" + cat >"$LANG_DIR/${base}.html" < + + + + + ${title} + + + +
exchange.hacktivism.ch · GOA
+

${title}

+

This is an experimental / exploration GNU Taler exchange for the + currency GOA at exchange.hacktivism.ch (hacktivism.ch).

+

No formal terms of service are required to use this service.

+ +
+ GOA · explorational + Not legal tender. No guaranteed real-world value, redemption, or convertibility. + Issued only for exploration and testing on this stack. +
+ +

By withdrawing or using GOA coins you acknowledge

+ +

If you do not agree, do not use this exchange.

+ +

Related

+ + +

Privacy

+

Processing under Swiss FADP (revDSG). What data is retained + (reserves, wire-in, coins, logs, …) is listed on + /privacy.

+ + + +HTML +} + +write_pp() { + # Legacy short PP only if not using install_swiss_privacy.sh etag + local base="$1" + local title="Privacy notice · GOA Exchange" + local body_txt body_md + + body_txt='No Privacy Policy Required. + +This is an experimental / exploration GNU Taler exchange for GOA. + +No formal privacy policy is required for this demo service. + +High-level notes: +- Wire transfers via the regional bank may identify bank account holders. +- The exchange processes withdrawals, deposits, and related protocol operations. +- Logs may be kept for operation and debugging. + +Do not use this service if that is unacceptable. + +Related: +- Exchange terms: https://exchange.hacktivism.ch/terms +' + + body_md='# No Privacy Policy Required + +This is an **experimental / exploration** GNU Taler exchange for **GOA**. + +**No formal privacy policy** is required for this demo service. + +## High-level notes + +- Wire transfers via the regional bank may identify bank account holders. +- The exchange processes withdrawals, deposits, and related protocol operations. +- Logs may be kept for operation and debugging. + +Do not use this service if that is unacceptable. + +## Related + +- [Exchange terms](https://exchange.hacktivism.ch/terms) +' + + printf '%s\n' "$body_txt" >"$LANG_DIR/${base}.txt" + printf '%s\n' "$body_md" >"$LANG_DIR/${base}.md" + cat >"$LANG_DIR/${base}.html" < + + + + + ${title} + + + +
exchange.hacktivism.ch · privacy
+

${title}

+

This is an experimental / exploration GNU Taler exchange for + GOA at exchange.hacktivism.ch.

+

No formal privacy policy is required for this demo service.

+ +

High-level notes

+ +

Do not use this service if that is unacceptable.

+ +

Related

+ + + + +HTML +} + +write_tos "$TOS_ETAG" +# Prefer Swiss FADP privacy installer when present (precise retention tables) +if [ -x /usr/local/bin/install_swiss_privacy.sh ]; then + PRIVACY_ETAG="$PP_ETAG" /usr/local/bin/install_swiss_privacy.sh +else + write_pp "$PP_ETAG" +fi + +chmod -R a+rX "$TERMS_DIR" +if id taler-exchange-httpd >/dev/null 2>&1; then + chown -R taler-exchange-httpd: "$TERMS_DIR" 2>/dev/null || true +fi + +echo "Installed under $LANG_DIR:" +ls -la "$LANG_DIR"/${TOS_ETAG}.* "$LANG_DIR"/${PP_ETAG}.* 2>/dev/null || ls -la "$LANG_DIR" +echo "Config should set:" +echo " TERMS_ETAG = ${TOS_ETAG}" +echo " PRIVACY_ETAG = ${PP_ETAG}" +echo " TERMS_DIR / PRIVACY_DIR = \${TALER_DATA_HOME}terms/" diff --git a/scripts/taler-exchange/landing-stats-exchange.sh b/scripts/taler-exchange/landing-stats-exchange.sh new file mode 100644 index 0000000..35452d4 --- /dev/null +++ b/scripts/taler-exchange/landing-stats-exchange.sh @@ -0,0 +1,300 @@ +#!/bin/bash +# Run INSIDE taler-hacktivism-exchange-ansible. +# Writes /var/www/exchange-landing/stats.json +# +# Data lives in Postgres DB taler-exchange, schema exchange.* +# (reserves, reserves_in, known_coins, withdraw, denominations, …) +# +# IMPORTANT: never use psql -F$'\t' -v ON_ERROR_STOP=1 +# If the tab arg is lost, -F eats -v and ON_ERROR_STOP=1 becomes the +# *username* → peer auth fails → silent empty counts (all zeros). +# Never overwrite stats.json on failure — write stats-run.json instead. +set -euo pipefail +export TZ="${TZ:-Europe/Zurich}" +export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:$PATH}" +LANDING_DIR="${LANDING_DIR:-/var/www/exchange-landing}" +OUT="$LANDING_DIR/stats.json" +RUN="$LANDING_DIR/stats-run.json" +TMP="${OUT}.tmp.$$" +DB="${EXCHANGE_DB:-taler-exchange}" +BASE_URL="${EXCHANGE_BASE_URL:-https://exchange.hacktivism.ch}" +ERRLOG="${LANDING_STATS_ERRLOG:-/var/log/landing-stats-exchange.err}" +mkdir -p "$LANDING_DIR" + +now_iso() { date +%Y-%m-%dT%H:%M%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/'; } +now_human() { date +"%Y-%m-%d %H:%M %Z"; } +json_str() { + printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n\r\t' ' ')" +} + +write_run() { + local ok_json="$1" msg="${2:-}" + cat >"$RUN" <&2 + rm -f "$TMP" 2>/dev/null || true + exit 1 +} + +# Prefer runuser; fall back to su. Always absolute-ish via PATH. +as_postgres() { + if command -v runuser >/dev/null 2>&1; then + runuser -u postgres -- "$@" + elif command -v su >/dev/null 2>&1; then + su -s /bin/bash postgres -c "$*" + else + return 127 + fi +} + +# Single value or one row with '|' separators (no -F, no -v flags). +psqlq() { + local sql="$1" out ec + set +e + out=$(as_postgres psql -d "$DB" -At -c "$sql" 2>>"$ERRLOG") + ec=$? + set -e + if [ "$ec" -ne 0 ]; then + abort "psql failed (ec=$ec): ${sql:0:120}" + fi + printf '%s' "$out" +} + +fmt_goa() { + local v="${1:-0}" f="${2:-0}" + v=${v//[^0-9-]/}; f=${f//[^0-9-]/}; v=${v:-0}; f=${f:-0} + if [ "$f" = "0" ] || [ -z "$f" ]; then + printf 'GOA:%s' "$v" + return + fi + awk -v v="$v" -v f="$f" 'BEGIN{ + frac = sprintf("%08d", f+0); sub(/0+$/, "", frac) + if (frac == "") printf "GOA:%d", v+0 + else printf "GOA:%d.%s", v+0, frac + }' +} + +q_count() { + local n + n=$(psqlq "SELECT count(*)::text FROM $1;") + # strip whitespace/newlines + n=$(printf '%s' "$n" | tr -d '[:space:]') + [[ "$n" =~ ^[0-9]+$ ]] || abort "bad count for $1: '$n'" + printf '%s' "$n" +} + +# --- preflight --- +command -v psql >/dev/null 2>&1 || abort "psql not in PATH ($PATH)" +command -v runuser >/dev/null 2>&1 || command -v su >/dev/null 2>&1 || abort "neither runuser nor su in PATH" +PROBE=$(psqlq "SELECT 1;") +[ "$PROBE" = "1" ] || abort "postgres not reachable (SELECT 1 → '$PROBE')" + +# --- coin lifecycle --- +KNOWN_COINS=$(q_count "exchange.known_coins") +COINS_LIVE=$(psqlq "SELECT count(*)::text FROM exchange.known_coins WHERE (remaining).val > 0 OR (remaining).frac > 0;") +COINS_LIVE=$(printf '%s' "${COINS_LIVE:-0}" | tr -d '[:space:]') +COINS_LIVE=${COINS_LIVE:-0} +COINS_SPENT=$(( KNOWN_COINS - COINS_LIVE )) + +REM_ROW=$(psqlq "SELECT coalesce(sum((remaining).val),0)::text || '|' || coalesce(sum((remaining).frac),0)::text FROM exchange.known_coins;") +IFS='|' read -r REM_VAL REM_FRAC <<<"${REM_ROW:-0|0}" +REMAINING_AMT=$(fmt_goa "${REM_VAL:-0}" "${REM_FRAC:-0}") + +DENOMS_TOTAL=$(q_count "exchange.denominations") +DENOM_VALUES=$(psqlq "SELECT count(DISTINCT ((coin).val, (coin).frac))::text FROM exchange.denominations;") +DENOM_VALUES=$(printf '%s' "${DENOM_VALUES:-0}" | tr -d '[:space:]') + +NOW_US=$(date +%s)000000 +DENOMS_WITHDRAWABLE=$(psqlq "SELECT count(*)::text FROM exchange.denominations WHERE valid_from <= ${NOW_US} AND expire_withdraw > ${NOW_US};") +DENOMS_WITHDRAWABLE=$(printf '%s' "${DENOMS_WITHDRAWABLE:-0}" | tr -d '[:space:]') + +RESERVES=$(q_count "exchange.reserves") +RESERVES_IN=$(q_count "exchange.reserves_in") +WIN_ROW=$(psqlq "SELECT coalesce(sum((credit).val),0)::text || '|' || coalesce(sum((credit).frac),0)::text FROM exchange.reserves_in;") +IFS='|' read -r WIN_VAL WIN_FRAC <<<"${WIN_ROW:-0|0}" +WIRE_IN_AMT=$(fmt_goa "${WIN_VAL:-0}" "${WIN_FRAC:-0}") + +WITHDRAW_OPS=$(q_count "exchange.withdraw") +WOUT_ROW=$(psqlq "SELECT coalesce(sum((amount_with_fee).val),0)::text || '|' || coalesce(sum((amount_with_fee).frac),0)::text FROM exchange.withdraw;") +IFS='|' read -r WO_VAL WO_FRAC <<<"${WOUT_ROW:-0|0}" +WITHDRAW_AMT=$(fmt_goa "${WO_VAL:-0}" "${WO_FRAC:-0}") + +REFRESH_OPS=$(q_count "exchange.refresh") +RECOUP=$(q_count "exchange.recoup") +REFUNDS=$(q_count "exchange.refunds") +COIN_DEPOSITS=$(q_count "exchange.coin_deposits") +BATCH_DEPOSITS=$(q_count "exchange.batch_deposits") +WIRE_OUT=$(q_count "exchange.wire_out") +COIN_HISTORY=$(q_count "exchange.coin_history") +WIRE_ACCTS=$(q_count "exchange.wire_accounts") + +# known coins by denom value (pipe-separated) +BY_DENOM_TSV=$(psqlq " +SELECT (d.coin).val::text || '|' || (d.coin).frac::text || '|' || count(*)::text || '|' || + count(*) FILTER (WHERE (k.remaining).val > 0 OR (k.remaining).frac > 0)::text +FROM exchange.known_coins k +JOIN exchange.denominations d ON d.denominations_serial = k.denominations_serial +GROUP BY (d.coin).val, (d.coin).frac +ORDER BY (d.coin).val, (d.coin).frac; +") + +BY_DENOM_JSON="[" +bf=1 +while IFS='|' read -r dv df cnt live; do + [ -z "${dv:-}" ] && continue + amt=$(fmt_goa "$dv" "$df") + if [ "$bf" = 1 ]; then bf=0; else BY_DENOM_JSON="${BY_DENOM_JSON},"; fi + BY_DENOM_JSON="${BY_DENOM_JSON} + {\"value\": $(json_str "$amt"), \"coins\": ${cnt:-0}, \"live\": ${live:-0}}" +done <<<"$BY_DENOM_TSV" +BY_DENOM_JSON="${BY_DENOM_JSON} + ]" + +LADDER_TSV=$(psqlq " +SELECT (coin).val::text || '|' || (coin).frac::text || '|' || count(*)::text +FROM exchange.denominations +GROUP BY (coin).val, (coin).frac +ORDER BY (coin).val, (coin).frac; +") +LADDER_JSON="[" +lf=1 +while IFS='|' read -r dv df nkeys; do + [ -z "${dv:-}" ] && continue + amt=$(fmt_goa "$dv" "$df") + if [ "$lf" = 1 ]; then lf=0; else LADDER_JSON="${LADDER_JSON},"; fi + LADDER_JSON="${LADDER_JSON} + {\"value\": $(json_str "$amt"), \"keys\": ${nkeys:-0}}" +done <<<"$LADDER_TSV" +LADDER_JSON="${LADDER_JSON} + ]" + +# recent wire-in / withdraw activity (no personal names — reserve_pub hex truncated) +RECENT_JSON="[" +rf=1 +while IFS='|' read -r ts val frac; do + [ -z "${ts:-}" ] && continue + sec=$(awk -v t="$ts" 'BEGIN{printf "%d", int(t/1000000)}') + human=$(date -d "@${sec}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$sec") + amt=$(fmt_goa "${val:-0}" "${frac:-0}") + if [ "$rf" = 1 ]; then rf=0; else RECENT_JSON="${RECENT_JSON},"; fi + RECENT_JSON="${RECENT_JSON} + {\"kind\": \"wire_in\", \"amount\": $(json_str "$amt"), \"ts_human\": $(json_str "$human"), \"ts_us\": ${ts:-0}}" +done < <(psqlq " +SELECT execution_date::text || '|' || (credit).val::text || '|' || (credit).frac::text +FROM exchange.reserves_in +ORDER BY execution_date DESC +LIMIT 8; +") +while IFS='|' read -r ts val frac; do + [ -z "${ts:-}" ] && continue + sec=$(awk -v t="$ts" 'BEGIN{printf "%d", int(t/1000000)}') + human=$(date -d "@${sec}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$sec") + amt=$(fmt_goa "${val:-0}" "${frac:-0}") + if [ "$rf" = 1 ]; then rf=0; else RECENT_JSON="${RECENT_JSON},"; fi + RECENT_JSON="${RECENT_JSON} + {\"kind\": \"withdraw\", \"amount\": $(json_str "$amt"), \"ts_human\": $(json_str "$human"), \"ts_us\": ${ts:-0}}" +done < <(psqlq " +SELECT execution_date::text || '|' || (amount_with_fee).val::text || '|' || (amount_with_fee).frac::text +FROM exchange.withdraw +ORDER BY execution_date DESC +LIMIT 6; +") +RECENT_JSON="${RECENT_JSON} + ]" + +# live performance +measure_ms() { + local url="$1" t + t=$(curl -sS -o /dev/null -m 8 -w '%{time_total}' "$url" 2>/dev/null || echo "") + [ -z "$t" ] && { echo "null"; return; } + awk -v t="$t" 'BEGIN{printf "%d", (t+0)*1000}' +} +KEYS_MS=$(measure_ms "${BASE_URL}/keys") +CONFIG_MS=$(measure_ms "${BASE_URL}/config") +KEYS_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BASE_URL}/keys" 2>/dev/null || echo "000") +CONFIG_HTTP=$(curl -sS -o /dev/null -m 8 -w '%{http_code}' "${BASE_URL}/config" 2>/dev/null || echo "000") +LOADAVG="" +[ -r /proc/loadavg ] && LOADAVG=$(awk '{print $1","$2","$3}' /proc/loadavg) + +WW="false" +pgrep -f taler-exchange-wirewatch >/dev/null 2>&1 && WW="true" + +STAT_EVENTS=$(q_count "exchange.exchange_statistic_counter_event") + +# Fail closed: zero denoms usually means broken query path (table always has keys) +if [ "${DENOMS_TOTAL:-0}" = "0" ]; then + abort "denominations count is 0 — refusing to publish (check peer auth / PATH)" +fi + +GEN=$(now_iso) +HUMAN=$(now_human) +num_or_null() { case "${1:-}" in ''|null) echo null ;; *) echo "$1" ;; esac; } + +MEM_JSON='"container_rss_human": "—"' +MEM_HELPER="${MEM_HELPER:-/usr/local/lib/landing-mem-snapshot.sh}" +if [ -f "$MEM_HELPER" ]; then + # shellcheck disable=SC1090 + . "$MEM_HELPER" + mem_snapshot_json || true +fi + +cat >"$TMP" < $OUT"