diff --git a/scripts/test_validate_mainnet_roles.sh b/scripts/test_validate_mainnet_roles.sh new file mode 100644 index 00000000..c8ceb0f4 --- /dev/null +++ b/scripts/test_validate_mainnet_roles.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# +# Tests for validate_mainnet_roles.sh +# Runs entirely offline: the `stellar` CLI is replaced by a shell stub whose +# per-method output is driven by environment variables. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET="$SCRIPT_DIR/validate_mainnet_roles.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +PASS=0 +FAIL=0 + +GOOD_ADDR="GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA" +GOOD_ADDR2="GB7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDB" + +make_stub() { + cat > "$TMP/stellar" <<'STUB' +#!/usr/bin/env bash +method="" +seen_dashdash=0 +for a in "$@"; do + if [ "$seen_dashdash" = "1" ] && [ -z "$method" ]; then + method="$a" + fi + if [ "$a" = "--" ]; then + seen_dashdash=1 + fi +done +case "$method" in + get_admin) printf '%s' "${STUB_ADMIN-}" ;; + get_admin_quorum_weight) printf '%s' "${STUB_QUORUM-}" ;; + get_multisig_owners) printf '%s' "${STUB_OWNERS-}" ;; + get_multisig_threshold) printf '%s' "${STUB_THRESHOLD-}" ;; + get_pending_dao_rotation) printf '%s' "${STUB_DAO_PENDING-}" ;; + *) echo "unknown method: $method" >&2; exit 1 ;; +esac +STUB + chmod +x "$TMP/stellar" +} + +run_validator() { + PATH="$TMP:$PATH" bash "$TARGET" \ + --contract-id CDUMMYCONTRACTIDCDUMMYCONTRACTIDCDUMMYCONTRACTIDCDUMY \ + --source "$GOOD_ADDR" \ + --network local \ + > "$TMP/out.txt" 2>&1 && echo 0 || echo $? +} + +assert_eq() { + if [ "$1" = "$2" ]; then + PASS=$((PASS + 1)); echo " ok: $3" + else + FAIL=$((FAIL + 1)); echo " FAIL: $3 (expected '$1', got '$2')" + echo "----- validator output -----"; cat "$TMP/out.txt"; echo "----------------------------" + fi +} + +assert_contains() { + if grep -q "$1" "$TMP/out.txt"; then + PASS=$((PASS + 1)); echo " ok: output contains '$1'" + else + FAIL=$((FAIL + 1)); echo " FAIL: output missing '$1'" + echo "----- validator output -----"; cat "$TMP/out.txt"; echo "----------------------------" + fi +} + +reset_defaults() { + export STUB_ADMIN="\"$GOOD_ADDR\"" + export STUB_QUORUM='3' + export STUB_OWNERS="[\"$GOOD_ADDR\",\"$GOOD_ADDR2\"]" + export STUB_THRESHOLD='2' + export STUB_DAO_PENDING='null' +} + +make_stub + +reset_defaults +echo "test: all checks pass" +assert_eq 0 "$(run_validator)" "exit 0 on healthy contract" +assert_contains "RESULT: PASS" + +reset_defaults +export STUB_ADMIN='""' +echo "test: null admin fails" +assert_eq 1 "$(run_validator)" "exit 1 on null admin" + +reset_defaults +export STUB_ADMIN='"NOTANADDRESS"' +echo "test: malformed admin fails" +assert_eq 1 "$(run_validator)" "exit 1 on malformed admin" + +reset_defaults +export STUB_QUORUM='0' +echo "test: zero quorum weight fails" +assert_eq 1 "$(run_validator)" "exit 1 on zero quorum" + +reset_defaults +export STUB_OWNERS='[]' +echo "test: empty multisig owners fails" +assert_eq 1 "$(run_validator)" "exit 1 on empty owners" + +reset_defaults +export STUB_THRESHOLD='9' +echo "test: threshold exceeds owner count fails" +assert_eq 1 "$(run_validator)" "exit 1 on threshold > owners" + +reset_defaults +export STUB_DAO_PENDING='{"new_dao":"'"$GOOD_ADDR"'"}' +echo "test: pending DAO rotation fails" +assert_eq 1 "$(run_validator)" "exit 1 on pending DAO rotation" + +reset_defaults +echo "test: ROLE_* parser finds all four known roles" +ROLES_OUT="$(grep -oE 'pub const (ROLE_[A-Z_]+)' "$SCRIPT_DIR/../contracts/attestation/src/access_control.rs" | awk '{print $3}' | grep -vE 'ROLE_VALID_MASK' | sort -u | tr '\n' ' ')" +for r in ROLE_ADMIN ROLE_ATTESTOR ROLE_BUSINESS ROLE_OPERATOR; do + case " $ROLES_OUT " in + *" $r "*) PASS=$((PASS+1)); echo " ok: parser found $r" ;; + *) FAIL=$((FAIL+1)); echo " FAIL: parser missed $r" ;; + esac +done + +echo +echo "==============================================" +echo " Results: $PASS passed, $FAIL failed" +echo "==============================================" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/scripts/validate_mainnet_roles.sh b/scripts/validate_mainnet_roles.sh new file mode 100644 index 00000000..8a24880c --- /dev/null +++ b/scripts/validate_mainnet_roles.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# +# validate_mainnet_roles.sh +# +# Post-deployment hardening check for the Veritasor attestation contract. +# Queries a freshly deployed contract and asserts that privileged roles and +# governance bindings are set, so an ownerless or half-configured contract +# cannot silently go live. +# +# Checks performed (each prints PASS/FAIL; any FAIL => non-zero exit): +# 1. ROLE coverage - every ROLE_* constant in access_control.rs is +# accounted for by this validator. +# 2. Admin bound - get_admin() returns a real, non-null address. +# 3. Admin quorum - get_admin_quorum_weight() > 0. +# 4. Multisig initialized - get_multisig_owners() non-empty AND +# 1 <= get_multisig_threshold() <= owner count. +# 5. DAO wiring - get_pending_dao_rotation() is None. +# +# Known limitation: the contract exposes no read-only getter for the ACTIVE +# DAO controller address. This script validates the absence of a pending +# rotation and reports the gap. +# +# Usage: +# scripts/validate_mainnet_roles.sh \ +# --contract-id \ +# --source \ +# [--network mainnet] \ +# [--access-control ] +# +# Exit codes: 0 all passed | 1 a check failed | 2 usage/environment error + +set -euo pipefail + +NETWORK="mainnet" +CONTRACT_ID="" +SOURCE="" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ACCESS_CONTROL="$SCRIPT_DIR/../contracts/attestation/src/access_control.rs" + +while [ $# -gt 0 ]; do + case "$1" in + --network) NETWORK="$2"; shift 2 ;; + --contract-id) CONTRACT_ID="$2"; shift 2 ;; + --source) SOURCE="$2"; shift 2 ;; + --access-control) ACCESS_CONTROL="$2"; shift 2 ;; + -h|--help) sed -n '2,40p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) echo "unrecognized argument: $1" >&2; exit 2 ;; + esac +done + +if [ -z "$CONTRACT_ID" ]; then + echo "error: --contract-id is required" >&2; exit 2 +fi +if [ -z "$SOURCE" ]; then + echo "error: --source is required" >&2; exit 2 +fi +if ! command -v stellar >/dev/null 2>&1; then + echo "error: the 'stellar' CLI is required" >&2 + exit 2 +fi +if [ ! -f "$ACCESS_CONTROL" ]; then + echo "error: access_control.rs not found at: $ACCESS_CONTROL" >&2; exit 2 +fi + +FAILURES=0 +REPORT=() + +record() { + local status="$1"; shift + REPORT+=("$status $*") + if [ "$status" = "FAIL" ]; then + FAILURES=$((FAILURES + 1)) + fi +} + +invoke() { + local method="$1"; shift + stellar contract invoke \ + --id "$CONTRACT_ID" \ + --source "$SOURCE" \ + --network "$NETWORK" \ + -- "$method" "$@" +} + +parse_role_constants() { + grep -oE 'pub const (ROLE_[A-Z_]+)' "$ACCESS_CONTROL" \ + | awk '{print $3}' \ + | grep -vE 'ROLE_VALID_MASK' \ + | sort -u +} + +KNOWN_ROLES="ROLE_ADMIN ROLE_ATTESTOR ROLE_BUSINESS ROLE_OPERATOR" + +check_role_coverage() { + local uncovered="" + local role + while IFS= read -r role; do + [ -z "$role" ] && continue + case " $KNOWN_ROLES " in + *" $role "*) : ;; + *) uncovered="$uncovered $role" ;; + esac + done < <(parse_role_constants) + + if [ -n "$uncovered" ]; then + record FAIL "ROLE coverage: unhandled role constant(s):$uncovered" + else + record PASS "ROLE coverage: all ROLE_* constants accounted for" + fi +} + +check_admin_bound() { + local admin + if ! admin="$(invoke get_admin 2>/dev/null)"; then + record FAIL "Admin bound: get_admin() call failed" + return + fi + admin="$(printf '%s' "$admin" | tr -d '"[:space:]')" + if [ -z "$admin" ] || ! printf '%s' "$admin" | grep -qE '^[GC][A-Z0-9]{55}$'; then + record FAIL "Admin bound: admin address is null or malformed ('$admin')" + else + record PASS "Admin bound: $admin" + fi +} + +check_admin_quorum() { + local weight + if ! weight="$(invoke get_admin_quorum_weight 2>/dev/null)"; then + record FAIL "Admin quorum: get_admin_quorum_weight() call failed" + return + fi + weight="$(printf '%s' "$weight" | tr -d '"[:space:]')" + if ! printf '%s' "$weight" | grep -qE '^[0-9]+$'; then + record FAIL "Admin quorum: non-numeric weight ('$weight')" + elif [ "$weight" -le 0 ]; then + record FAIL "Admin quorum: weight is zero (no active admins)" + else + record PASS "Admin quorum: weight = $weight" + fi +} + +check_multisig() { + local owners_raw threshold owner_count + if ! owners_raw="$(invoke get_multisig_owners 2>/dev/null)"; then + record FAIL "Multisig: get_multisig_owners() call failed" + return + fi + owner_count="$(printf '%s' "$owners_raw" | grep -oE '[GC][A-Z0-9]{55}' | wc -l | tr -d '[:space:]')" + + if [ "$owner_count" -eq 0 ]; then + record FAIL "Multisig: owner set is empty (not initialized)" + return + fi + + if ! threshold="$(invoke get_multisig_threshold 2>/dev/null)"; then + record FAIL "Multisig: get_multisig_threshold() call failed" + return + fi + threshold="$(printf '%s' "$threshold" | tr -d '"[:space:]')" + if ! printf '%s' "$threshold" | grep -qE '^[0-9]+$'; then + record FAIL "Multisig: non-numeric threshold ('$threshold')" + elif [ "$threshold" -lt 1 ]; then + record FAIL "Multisig: threshold < 1" + elif [ "$threshold" -gt "$owner_count" ]; then + record FAIL "Multisig: threshold ($threshold) exceeds owner count ($owner_count)" + else + record PASS "Multisig: $owner_count owner(s), threshold $threshold" + fi +} + +check_dao_wiring() { + local pending + if ! pending="$(invoke get_pending_dao_rotation 2>/dev/null)"; then + record FAIL "DAO wiring: get_pending_dao_rotation() call failed" + return + fi + pending="$(printf '%s' "$pending" | tr -d '[:space:]')" + if [ "$pending" = "null" ] || [ "$pending" = "void" ] || [ -z "$pending" ]; then + record PASS "DAO wiring: no pending DAO rotation (active-DAO getter not exposed; see notes)" + else + record FAIL "DAO wiring: a DAO rotation is pending on a fresh deploy ('$pending')" + fi +} + +check_role_coverage +check_admin_bound +check_admin_quorum +check_multisig +check_dao_wiring + +echo "==============================================" +echo " Mainnet role validation: $CONTRACT_ID" +echo " Network: $NETWORK" +echo "==============================================" +for line in "${REPORT[@]}"; do + echo " $line" +done +echo "==============================================" + +if [ "$FAILURES" -gt 0 ]; then + echo "RESULT: FAIL ($FAILURES check(s) failed)" + exit 1 +fi +echo "RESULT: PASS (all checks passed)" +exit 0 \ No newline at end of file