The same daemon structure that watches liveness watches governance. Change the endpoint; the discipline holds.
The governance watch discipline from today's Ops lesson describes what a validator must monitor. This lesson makes it executable. Three scripts cover the operational surface: a proposal-status daemon that polls active proposals and fires when a deadline approaches, an idempotent vote-submission wrapper that checks for an existing vote before sending a transaction, and a deposit guard that alerts when a target proposal risks expiring below min_deposit.
All three follow the strict-mode and trap-cleanup pattern from the 2026-06-13 Bash lesson. The governance watch loop extends the polling architecture from the 2026-06-20 liveness watchdog: same daemon posture, same idempotent-guard discipline, different endpoint.
Bash governance scripts share three structural properties with the liveness watchdogs from 2026-06-20.
First, they run as daemons. The main loop is an infinite while with a configurable sleep interval. The script stays alive between polls; it does not fire and exit.
Second, they call external CLI tools for state queries. Where the liveness watchdog queries the slashing module's sign-info endpoint, governance scripts query the gov proposals and gov vote endpoints. The gaiad call is the unit of state retrieval; Bash orchestrates when to call it and what to do with the result.
Third, they guard before acting. The idempotent check runs before any transaction. Check state; act only when the action is needed. A script that submits a vote without first verifying that no vote exists wastes fees and produces noise logs.
set -euo pipefail
IFS=$'\n\t'
set -e exits on any nonzero command exit. set -u treats unset variables as errors. set -o pipefail propagates failures through pipes — without it, gaiad query ... | python3 ... succeeds even if gaiad failed. The IFS reset narrows word-splitting to newlines and tabs, preventing whitespace in RPC responses from splitting unexpectedly.
TMPFILE=$(mktemp /tmp/gov_watch.XXXXXX)
trap 'rm -f "$TMPFILE"' EXIT INT TERM
The Linux Command Line (Shotts, pp. 486–487) formalizes this pattern: a trap registered to the EXIT signal runs whenever the script exits, whether by normal completion, error, or interrupt. This guarantees the temp file clears even if systemd restarts the process mid-poll. The same pattern appeared in the 2026-06-13 Bash lesson; it carries unchanged into every operational script that writes temporary state.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
NODE="${NODE:-https://rpc.cosmos.directory:443/cosmoshub}"
CHAIN_ID="${CHAIN_ID:-cosmoshub-4}"
ALERT_HOURS="${ALERT_HOURS:-6}"
POLL_INTERVAL="${POLL_INTERVAL:-300}"
ALERT_CMD="${ALERT_CMD:-echo}"
TMPFILE=$(mktemp /tmp/gov_watch.XXXXXX)
trap 'rm -f "$TMPFILE"' EXIT INT TERM
alert() {
local msg="$1"
"$ALERT_CMD" "[GOV WATCH] $msg"
}
query_voting_proposals() {
gaiad query gov proposals \
--status voting_period \
--output json \
--node "$NODE" > "$TMPFILE" 2>&1
}
check_deadline() {
local proposal_id="$1"
local end_time="$2"
local now_epoch end_epoch hours_remaining
now_epoch=$(date -u +%s)
end_epoch=$(date -u -d "$end_time" +%s 2>/dev/null \
|| date -u -jf "%Y-%m-%dT%H:%M:%SZ" "$end_time" +%s)
hours_remaining=$(( (end_epoch - now_epoch) / 3600 ))
if (( hours_remaining <= ALERT_HOURS )); then
alert "Proposal $proposal_id voting ends in ${hours_remaining}h — cast vote now"
fi
}
while true; do
if query_voting_proposals; then
proposals=$(python3 -c "
import json, sys
data = json.load(open('$TMPFILE'))
for p in data.get('proposals', []):
pid = p.get('id') or p.get('proposal_id', '')
end = p.get('voting_end_time', '')
if pid and end:
print(pid, end)
" 2>/dev/null || true)
while IFS=' ' read -r pid end_time; do
[[ -z "$pid" ]] && continue
check_deadline "$pid" "$end_time"
done <<< "$proposals"
else
alert "RPC query failed — check node connectivity (node: $NODE)"
fi
sleep "$POLL_INTERVAL"
done
ALERT_HOURS sets the vote-deadline horizon: the number of hours before voting_end_time at which the daemon fires an alert. At six hours the validator has time to assess and cast without urgency. ALERT_CMD accepts any command that takes a message string — a curl call to a Slack webhook, a mail invocation, or a Telegram bot call. The plug-in alerting architecture is the same pattern from 2026-06-20; only the query endpoint changes.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
NODE="${NODE:-https://rpc.cosmos.directory:443/cosmoshub}"
CHAIN_ID="${CHAIN_ID:-cosmoshub-4}"
VOTER_ADDRESS="${VOTER_ADDRESS:?VOTER_ADDRESS must be set}"
FROM_KEY="${FROM_KEY:?FROM_KEY must be set}"
PROPOSAL_ID="${1:?Usage: $0 <proposal-id> <yes|no|no_with_veto|abstain>}"
VOTE_OPTION="${2:?Usage: $0 <proposal-id> <yes|no|no_with_veto|abstain>}"
already_voted() {
local vote_json
vote_json=$(gaiad query gov vote "$PROPOSAL_ID" "$VOTER_ADDRESS" \
--output json --node "$NODE" 2>&1) || return 1
python3 -c "
import json, sys
try:
d = json.loads(sys.argv[1])
v = d.get('vote', {})
opts = v.get('options') or ([{'option': v.get('option')}] if v.get('option') else [])
sys.exit(0 if opts else 1)
except Exception:
sys.exit(1)
" "$vote_json"
}
if already_voted; then
existing=$(gaiad query gov vote "$PROPOSAL_ID" "$VOTER_ADDRESS" \
--output json --node "$NODE" \
| python3 -c "
import json, sys
d = json.load(sys.stdin)
v = d.get('vote', {})
opts = v.get('options') or [{'option': v.get('option','')}]
print(opts[0].get('option', 'unknown'))
")
echo "Already voted on proposal $PROPOSAL_ID: $existing — skipping"
exit 0
fi
gaiad tx gov vote "$PROPOSAL_ID" "$VOTE_OPTION" \
--from "$FROM_KEY" \
--chain-id "$CHAIN_ID" \
--node "$NODE" \
--fees 2000uatom \
--gas auto \
--yes
already_voted function queries the on-chain vote for the exact (proposal, voter) pair. The chain returns an error if no vote exists; || return 1 captures that. A vote that exists produces JSON with a non-empty options field. The pattern mirrors the auto-unjail guard from 2026-06-20: query state, act only when the action has not already taken effect.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
NODE="${NODE:-https://rpc.cosmos.directory:443/cosmoshub}"
PROPOSAL_ID="${1:?Usage: $0 <proposal-id>}"
MIN_DEPOSIT_UATOM="${MIN_DEPOSIT_UATOM:-512000000}"
ALERT_HOURS="${ALERT_HOURS:-12}"
TMPFILE=$(mktemp /tmp/deposit_guard.XXXXXX)
trap 'rm -f "$TMPFILE"' EXIT INT TERM
gaiad query gov proposal "$PROPOSAL_ID" \
--output json --node "$NODE" > "$TMPFILE"
python3 - << EOF
import json, sys
from datetime import datetime, timezone
with open('$TMPFILE') as f:
raw = json.load(f)
proposal = raw.get('proposal', raw)
status = proposal.get('status', '')
if status != 'PROPOSAL_STATUS_DEPOSIT_PERIOD':
print(f"Status: {status} — not in deposit period")
sys.exit(0)
deposit_end = proposal.get('deposit_end_time', '')
total = sum(
int(c['amount'])
for c in proposal.get('total_deposit', [])
if c.get('denom') == 'uatom'
)
shortfall = max(0, int('$MIN_DEPOSIT_UATOM') - total)
now = datetime.now(timezone.utc)
if deposit_end:
end_dt = datetime.fromisoformat(deposit_end.replace('Z', '+00:00'))
hours_left = (end_dt - now).total_seconds() / 3600
print(f"Proposal $PROPOSAL_ID: {total/1e6:.2f} ATOM deposited, "
f"shortfall {shortfall/1e6:.2f} ATOM, {hours_left:.1f}h remaining")
if shortfall > 0 and hours_left < float('$ALERT_HOURS'):
print("ALERT: deposit deadline approaching with shortfall")
sys.exit(2)
EOF
The script exits 0 when the proposal is not in deposit period or when shortfall is zero. It exits 2 when both conditions are active. Exit code 2 makes it composable with cron: a cron job can check the exit code and send an alert only when deposit action is needed.
The Ops lesson names the four-gate passage and describes what the validator owes each gate. This lesson encodes three of those four gates as executable scripts: the vote-deadline watcher covers the voting gate, the idempotent vote wrapper covers vote submission, and the deposit guard covers the deposit gate. The gaiad CLI calls in all three scripts are structurally identical to the operator-key transactions the Ops lesson specified. The alignment is intentional: the Bash script is a guard-and-wrapper around the same CLI the operator would run manually. It adds the idempotent check, the logging, and the daemon loop — not a different tool.
The 2026-06-13 Bash lesson established three patterns that carry unchanged into all three scripts: strict-mode header, trap cleanup (The Linux Command Line, pp. 486–487 — the canonical authority for the trap EXIT/INT/TERM idiom), and idempotent-guard structure. The 2026-06-20 Bash lesson introduced the daemon polling loop with configurable interval, the plug-in alerting architecture (ALERT_CMD variable), and the idempotent auto-unjail guard. Script 1's governance watch loop is the liveness watchdog with the gov proposals endpoint substituted for sign-info. Script 2's vote wrapper is the vote equivalent of the unjail guard: same structure, different CLI command, different state check.
A vote deadline is a hard cutoff. When voting_end_time passes, the chain runs tally on whatever state it finds. There is no late entry. A validator without a watcher treats a public obligation as a passive one.
Build the watch loop. Run it as a systemd service alongside the liveness watchdog. Set ALERT_CMD to the same alerting channel used for missed-block alerts. Governance deadlines carry the same operational weight as uptime events; the daemon infrastructure should treat them the same way.