diff --git a/scripts/check_balance.sh b/scripts/check_balance.sh new file mode 100644 index 000000000..3b358d1b4 --- /dev/null +++ b/scripts/check_balance.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# RustChain wallet balance check with consistent error handling. +# +# Exit codes (documented scheme): +# 0 = success (balance fetched and printed) +# 1 = usage error (missing/invalid wallet address) +# 2 = network error (DNS/connect/timeout) +# 3 = bad response (non-200 HTTP, malformed JSON, missing field) +# 4 = wallet not found (HTTP 404 from the RPC) +# +# The script NEVER prints a balance it did not actually receive. Every +# failure path prints a distinct error to stderr and exits non-zero. +set -euo pipefail + +NODE_URL="${RUSTCHAIN_NODE_URL:-https://rustchain.org}" +CURL_TIMEOUT="${RUSTCHAIN_CURL_TIMEOUT:-15}" + +usage() { + echo "Usage: $0 " >&2 + echo "Checks the RTC balance of a wallet via the RustChain RPC." >&2 + echo "Exit codes: 0 ok, 1 usage, 2 network, 3 bad response, 4 wallet not found." >&2 + exit 1 +} + +[ $# -eq 1 ] || usage +WALLET="$1" + +# Basic address sanity check (RTC addresses are 32-64 base58-ish chars). +if ! [[ "$WALLET" =~ ^[A-Za-z0-9]{20,64}$ ]]; then + echo "ERROR: invalid wallet address format: '$WALLET'" >&2 + exit 1 +fi + +err_file="$(mktemp)" +code_file="$(mktemp)" +trap 'rm -f "$err_file" "$code_file"' EXIT + +# 1) Network layer: curl connection failures are distinct network errors (exit 2). +body_file="$(mktemp)" +trap 'rm -f "$err_file" "$code_file" "$body_file"' EXIT + +if ! http_out="$(curl -sS --max-time "$CURL_TIMEOUT" -w '%{http_code}' -o "$body_file" "$NODE_URL/wallet/balance?miner_id=$WALLET" 2>"$err_file")"; then + rc=$? + msg="$(cat "$err_file")" + if grep -qiE 'could not resolve|connection refused|connection reset|timed out|timeout|no route|failed to connect|could not connect|server' "$err_file" 2>/dev/null; then + echo "ERROR: network error - ${msg:-curl exit $rc}" >&2 + exit 2 + fi + echo "ERROR: request failed (curl exit $rc) - $msg" >&2 + exit 3 +fi + +code="$http_out" + +# 2) HTTP layer: 404 = wallet not found (exit 4); other non-200 = bad response (exit 3). +if [ "$code" = "404" ]; then + echo "ERROR: wallet '$WALLET' not found (HTTP 404)" >&2 + exit 4 +fi +if [ "$code" != "200" ]; then + echo "ERROR: server returned HTTP $code" >&2 + exit 3 +fi + +# 3) Payload layer: strict JSON parse + required field check (exit 3). +if ! parsed="$(python3 -c ' +import json, sys +d = json.load(open(sys.argv[1])) +if "amount_rtc" not in d and "balance_rtc" not in d: + raise SystemExit("missing balance field") +print(json.dumps({"wallet_id": sys.argv[2], "amount_rtc": d.get("amount_rtc", d.get("balance_rtc"))})) +' "$body_file" "$WALLET" 2>"$code_file")"; then + echo "ERROR: bad response - $(cat "$code_file")" >&2 + exit 3 +fi + +echo "$parsed" +exit 0 diff --git a/scripts/test_check_balance.sh b/scripts/test_check_balance.sh new file mode 100644 index 000000000..1d533d49c --- /dev/null +++ b/scripts/test_check_balance.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Tests for scripts/check_balance.sh - mocked HTTP, no real network. +# Run from the repo root: bash scripts/test_check_balance.sh +set -uo pipefail +cd "$(dirname "$0")/.." + +PORT="${TEST_PORT:-18111}" +FAILURES=0 +check() { # check + if [ "$3" -ne "$2" ]; then + echo "FAIL: $1 (expected rc=$2, got rc=$3)" >&2 + FAILURES=$((FAILURES + 1)) + else + echo "PASS: $1 (rc=$3)" + fi +} + +# Mock RPC on $PORT +python3 - "$PORT" <<'EOF' & +import http.server, socketserver, sys +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if "goodwallet1234567890" in self.path: + body = b'{"amount_rtc": "42.5"}'; self.send_response(200) + elif "missingwallet12345678" in self.path: + body = b''; self.send_response(404) + elif "malformedwallet123456" in self.path: + body = b'not json'; self.send_response(200) + elif "emptyfieldwallet12345" in self.path: + body = b'{"hello": "world"}'; self.send_response(200) + else: + body = b''; self.send_response(500) + self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body) + def log_message(self, *a): pass +class T(socketserver.ThreadingMixIn, http.server.HTTPServer): pass +T.allow_reuse_address = True +T(("127.0.0.1", int(sys.argv[1])), H).serve_forever() +EOF +SERVER_PID=$! +trap 'kill $SERVER_PID 2>/dev/null || true' EXIT +sleep 1 + +export RUSTCHAIN_NODE_URL="http://127.0.0.1:$PORT" +export RUSTCHAIN_CURL_TIMEOUT="3" +S=scripts/check_balance.sh +chmod +x "$S" + +"$S" >/dev/null 2>&1; check "no args -> usage" 1 $? +"$S" '!!bad' >/dev/null 2>&1; check "bad address format" 1 $? +"$S" missingwallet12345678 >/dev/null 2>&1; check "404 -> wallet not found" 4 $? +"$S" malformedwallet123456 >/dev/null 2>&1; check "malformed JSON -> bad response" 3 $? +"$S" emptyfieldwallet12345 >/dev/null 2>&1; check "missing field -> bad response" 3 $? +out="$("$S" goodwallet1234567890 2>/dev/null)"; check "success -> rc 0" 0 $? +echo "$out" | grep -q '"amount_rtc": "42.5"' && echo "PASS: balance output" || { echo "FAIL: balance output" >&2; FAILURES=$((FAILURES + 1)); } +RUSTCHAIN_NODE_URL="http://127.0.0.1:1" "$S" goodwallet1234567890 >/dev/null 2>&1; check "unreachable -> network error" 2 $? + +echo +if [ "$FAILURES" -eq 0 ]; then echo "ALL TESTS PASSED"; else echo "$FAILURES TEST(S) FAILED"; exit 1; fi