| name | ops-check-balances |
| description | Internal — for Boundless team members only. Audit native ETH, market deposit, prover collateral, and distributor ZKC reserve balances for every operator-managed address (provers, distributor, order generators, signal). Use when the user wants to know which addresses need topping up, asks about the balance of provers/OGs/distributor/signal signers, says something is "running low" or "out of gas", or wants a periodic operational health check on operator wallets. Defaults to prod env (mainnets + prod testnets) — pass `--all` to also include staging. |
Check Operator Balances
Audit balances on every Boundless-operated address: native ETH, market deposit (balanceOf), prover collateral (balanceOfCollateral), and the distributor's bridged-ZKC ERC20 reserve.
The list of addresses, per-role thresholds, and which checks apply (gas vs market deposit) are loaded at runtime from the runbooks repo. The skill itself contains only public protocol data (chain → market/collateral-token addresses, public RPCs).
Prerequisites
-
CLI tools: cast (from foundry), jq, python3, curl, and standard Unix utilities. python3 is used for wei → human-readable conversion (awk silently truncates large integers) and for parsing deployment.toml. curl fetches the contract registry from the public boundless repo.
-
gh CLI authenticated against boundless-xyz (required). The canonical address list and thresholds live in the private boundless-xyz/runbooks repo and are fetched at runtime via gh api — no local clone needed. Verify with:
gh api -H "Accept: application/vnd.github.raw" \
repos/boundless-xyz/runbooks/contents/addresses/operator_addresses.json | head -5
If that fails with a 401/403, run gh auth login and ensure your account has access to boundless-xyz/runbooks.
-
network_secrets.toml at the repo root (optional). Public RPCs are usually sufficient. When a balance comes back as exactly 0 it may be rate-limited rather than actually empty — in that case retry against the private QuikNode endpoint from [networks.<chain>.rpc]. The runbook has setup instructions.
What to check
For each address in operator_addresses.json, on every chain it operates on:
- Native ETH balance (when
needs_gas: true) — cast balance --rpc-url <RPC> <addr>. Required to pay gas.
- Market deposit (when
needs_market_check: true) — cast call <market> "balanceOf(address)(uint256)" <addr>. Funds available to pay for orders (requestor) or to claim as rewards (prover).
- Prover collateral (when
role: prover) — cast call <market> "balanceOfCollateral(address)(uint256)" <addr>. Stake the prover has put up.
- Distributor ZKC reserve (when
role: distributor) — cast call <collateral_token> "balanceOf(address)(uint256)" <distributor_addr>. The distributor's raw ERC20 balance of bridged ZKC, used to top up provers' collateral. Not the same as balanceOfCollateral — that's deposited stake; this is the unstaked reserve.
Thresholds
Loaded from operator_addresses.json's thresholds block. Resolved field-by-field with priority:
per-entry override → by_role[role] → by_chain[chain] → default
ETH thresholds for distributor-managed roles (provers, OGs) come from by_chain because the distributor's ETH_THRESHOLD varies by ~2 orders of magnitude across chains (0.008 ETH on Taiko vs 1 ETH on Base Sepolia staging). Audit thresholds are tied to those values: WARN ≈ 0.5 × ETH_THRESHOLD (distributor missed at least one cycle), CRIT ≈ 0.125 × ETH_THRESHOLD (auto-top-up clearly broken).
ETH thresholds for non-distributor-managed roles (distributor itself, signal signers) come from by_role and ignore the chain. Distributor's WARN matches the operational DISTRIBUTOR_ETH_ALERT_THRESHOLD of 0.5; signal signers use a smaller threshold tuned to their ~0.0001 ETH/day burn.
ZKC thresholds (prover collateral, distributor reserve) come from by_role.prover and by_role.distributor respectively, since they're uniform across chains.
eth_warn/eth_crit gate the native-ETH gas balance. To alert on an address's market deposit (balanceOf) instead of (or in addition to) gas, set deposit_warn/deposit_crit (ETH-equivalent units). These are opt-in: there is no default, so the script flags a deposit (DEPOSIT-LOW / DEPOSIT-CRIT) only for entries that resolve a deposit threshold. Used for requestors whose deposit is the operational signal (e.g. KOG, deposit WARN 1 / CRIT 0.1, while its gas stays on the base-mainnet default).
The resolved values the skill consumes live in operator_addresses.json's thresholds block. The runbook README no longer mirrors a values table — the operational source of truth is the distributor Pulumi config in the boundless repo (infra/distributor/Pulumi.l-prod-{chain_id}.yaml / Pulumi.l-staging-{chain_id}.yaml, keys like ETH_THRESHOLD / DISTRIBUTOR_ETH_ALERT_THRESHOLD). The deposit_warn/deposit_crit overrides are runbook-local and exist only in operator_addresses.json.
A specific entry can override any field by setting a thresholds: { eth_warn: ..., ... } block on it — only the fields you set are overridden; the rest fall through.
Public chain → contract addresses
Contract addresses (market, collateral token) are fetched at runtime from the public contracts/deployment.toml in the boundless repo on GitHub. The skill never hardcodes them — when a chain is added or addresses rotate, it's a single edit to deployment.toml and the skill picks it up on next run, no code change.
DEPLOYMENT_TOML_URL="https://raw.githubusercontent.com/boundless-xyz/boundless/main/contracts/deployment.toml"
The chain label used in operator_addresses.json must match a [deployment.<label>] section in deployment.toml. Today that's base-mainnet, taiko-mainnet, base-sepolia-staging, taiko-staging (the prod testnets base-sepolia and ethereum-sepolia were discontinued).
The only inline data the skill keeps is the chain label → public RPC URL mapping below — RPC URLs aren't in deployment.toml and don't rotate.
| Chain label | Public RPC |
|---|
base-mainnet | https://mainnet.base.org |
taiko-mainnet | https://rpc.taiko.xyz |
base-sepolia-staging | https://sepolia.base.org |
taiko-staging | https://rpc.taiko.xyz |
The check script
Save as /tmp/balance_check.sh and run. Defaults to prod env (mainnets + prod testnets); pass --all to also include staging.
#!/usr/bin/env bash
set -euo pipefail
RUNBOOKS_REF="${RUNBOOKS_REF:-main}"
ADDRS_FILE=$(mktemp)
trap 'rm -f "$ADDRS_FILE" "${DEPLOYMENT_TOML:-}"' EXIT
if ! gh api -H "Accept: application/vnd.github.raw" \
"repos/boundless-xyz/runbooks/contents/addresses/operator_addresses.json?ref=$RUNBOOKS_REF" \
> "$ADDRS_FILE" 2>/dev/null; then
echo "ERROR: failed to fetch operator_addresses.json from boundless-xyz/runbooks" >&2
echo " Run 'gh auth login' and ensure your account has access to that repo." >&2
exit 1
fi
INCLUDE_STAGING="${INCLUDE_STAGING:-false}"
[ "${1:-}" = "--all" ] && INCLUDE_STAGING=true
chain_rpc() {
case "$1" in
base-mainnet) echo "https://mainnet.base.org" ;;
taiko-mainnet) echo "https://rpc.taiko.xyz" ;;
base-sepolia-staging) echo "https://sepolia.base.org" ;;
taiko-staging) echo "https://rpc.taiko.xyz" ;;
*) echo "" ;;
esac
}
DEPLOYMENT_TOML_URL="${DEPLOYMENT_TOML_URL:-https://raw.githubusercontent.com/boundless-xyz/boundless/main/contracts/deployment.toml}"
DEPLOYMENT_TOML=$(mktemp)
if ! curl -fsSL "$DEPLOYMENT_TOML_URL" -o "$DEPLOYMENT_TOML"; then
echo "ERROR: failed to fetch $DEPLOYMENT_TOML_URL" >&2
exit 1
fi
CHAIN_CONTRACTS=$(python3 - "$DEPLOYMENT_TOML" <<'PY'
import re, sys
t = open(sys.argv[1]).read()
zero = "0x0000000000000000000000000000000000000000"
for s in re.split(r'\n(?=\[deployment\.)', t):
m = re.match(r'\[deployment\.([^\]]+)\]', s)
if not m: continue
name = m.group(1)
market = re.search(r'^boundless-market\s*=\s*"(0x[a-fA-F0-9]{40})"', s, re.MULTILINE)
coll = re.search(r'^collateral-token\s*=\s*"(0x[a-fA-F0-9]{40})"', s, re.MULTILINE)
if not market or market.group(1).lower() == zero: continue
coll_addr = coll.group(1) if coll and coll.group(1).lower() != zero else ""
print(f"{name}\t{market.group(1)}\t{coll_addr}")
PY
)
chain_contracts() {
echo "$CHAIN_CONTRACTS" | awk -v c="$1" -F'\t' '$1==c {print $2"|"$3; exit}'
}
decode_uint() { echo "$1" | head -1 | awk '{print $1}' | grep -oE '^[0-9]+'; }
fmt_eth() { python3 -c "v='${1:-}'; print(f'{int(v)/1e18:.6f}' if v else '?')" 2>/dev/null; }
fmt_zkc() { python3 -c "v='${1:-}'; print(f'{int(v)/1e18:.4f}' if v else '?')" 2>/dev/null; }
flt_lt() { python3 -c "import sys; sys.exit(0 if float('$1') < float('$2') else 1)" 2>/dev/null; }
TUPLES=$(jq -r --arg incl "$INCLUDE_STAGING" '
. as $root
| .addresses[]
| . as $e
| select($incl == "true" or .env == "prod" or .env == "all")
| .chains[] as $c
| (
(($e.thresholds // {}).eth_warn)
// (($root.thresholds.by_role[$e.role] // {}).eth_warn)
// (($root.thresholds.by_chain[$c] // {}).eth_warn)
// ($root.thresholds.default.eth_warn)
// 0.02
) as $eth_warn
| (
(($e.thresholds // {}).eth_crit)
// (($root.thresholds.by_role[$e.role] // {}).eth_crit)
// (($root.thresholds.by_chain[$c] // {}).eth_crit)
// ($root.thresholds.default.eth_crit)
// 0.005
) as $eth_crit
| (
(($e.thresholds // {}).deposit_warn)
// (($root.thresholds.by_role[$e.role] // {}).deposit_warn)
// (($root.thresholds.by_chain[$c] // {}).deposit_warn)
// ($root.thresholds.default.deposit_warn)
// ""
) as $dep_warn
| (
(($e.thresholds // {}).deposit_crit)
// (($root.thresholds.by_role[$e.role] // {}).deposit_crit)
// (($root.thresholds.by_chain[$c] // {}).deposit_crit)
// ($root.thresholds.default.deposit_crit)
// ""
) as $dep_crit
| [.label, .address, .role, $c,
(if has("needs_gas") then .needs_gas else true end),
(if has("needs_market_check") then .needs_market_check else false end),
$eth_warn, $eth_crit, $dep_warn, $dep_crit]
| @tsv
' "$ADDRS_FILE")
printf "%-36s %-44s %-12s %-22s %-12s %-13s %-12s %s\n" SERVICE ADDRESS ROLE CHAIN ETH DEPOSIT COLLATERAL FLAG
echo "--------------------------------------------------------------------------------------------------------------------------------------------------------------------"
while IFS=$'\t' read -r LABEL ADDR ROLE CHAIN NEEDS_GAS NEEDS_MARKET ETH_WARN ETH_CRIT DEP_WARN DEP_CRIT; do
RPC=$(chain_rpc "$CHAIN")
CHAIN_INFO=$(chain_contracts "$CHAIN")
if [ -z "$RPC" ] || [ -z "$CHAIN_INFO" ]; then
printf "%-36s %-44s %-12s %-22s %s\n" "$LABEL" "$ADDR" "$ROLE" "$CHAIN" "(chain not found — RPC or deployment.toml entry missing)"
continue
fi
MKT="${CHAIN_INFO%%|*}"
E="-"; D="-"; C="-"
FLAG=""
if [ "$NEEDS_GAS" = "true" ]; then
E_RAW=$(cast balance --rpc-url "$RPC" "$ADDR" 2>/dev/null || true)
E=$(fmt_eth "$E_RAW")
if [ "$E" != "?" ] && [ "$E" != "-" ]; then
if flt_lt "$E" "$ETH_CRIT"; then FLAG="$FLAG ETH-CRIT"
elif flt_lt "$E" "$ETH_WARN"; then FLAG="$FLAG ETH-LOW"
fi
fi
fi
if [ "$NEEDS_MARKET" = "true" ] && [ -n "$MKT" ]; then
D_RAW=$(decode_uint "$(cast call --rpc-url "$RPC" "$MKT" 'balanceOf(address)(uint256)' "$ADDR" 2>/dev/null || true)")
D=$(fmt_eth "$D_RAW")
if [ "$D" != "?" ] && [ "$D" != "-" ]; then
if [ -n "$DEP_CRIT" ] && flt_lt "$D" "$DEP_CRIT"; then FLAG="$FLAG DEPOSIT-CRIT"
elif [ -n "$DEP_WARN" ] && flt_lt "$D" "$DEP_WARN"; then FLAG="$FLAG DEPOSIT-LOW"
fi
fi
fi
if [ "$ROLE" = "prover" ] && [ -n "$MKT" ]; then
C_RAW=$(decode_uint "$(cast call --rpc-url "$RPC" "$MKT" 'balanceOfCollateral(address)(uint256)' "$ADDR" 2>/dev/null || true)")
C=$(fmt_zkc "$C_RAW")
C_WARN=$(jq -r '.thresholds.by_role.prover.collateral_warn // .thresholds.default.collateral_warn // 100' "$ADDRS_FILE")
C_CRIT=$(jq -r '.thresholds.by_role.prover.collateral_crit // .thresholds.default.collateral_crit // 50' "$ADDRS_FILE")
if [ "$C" != "?" ] && [ "$C" != "-" ]; then
if flt_lt "$C" "$C_CRIT"; then FLAG="$FLAG ZKC-CRIT"
elif flt_lt "$C" "$C_WARN"; then FLAG="$FLAG ZKC-LOW"
fi
fi
fi
printf "%-36s %-44s %-12s %-22s %-12s %-13s %-12s%s\n" "$LABEL" "$ADDR" "$ROLE" "$CHAIN" "$E" "$D" "$C" "$FLAG"
done <<<"$TUPLES"
echo
echo "=== Distributor bridged-ZKC ERC20 reserve ==="
printf "%-36s %-44s %-22s %-14s %s\n" DISTRIBUTOR ADDRESS CHAIN ZKC_RESERVE FLAG
echo "------------------------------------------------------------------------------------------------------------------------"
DISTRIB_TUPLES=$(jq -r --arg incl "$INCLUDE_STAGING" '
.addresses[]
| select(.role == "distributor")
| select($incl == "true" or .env == "prod" or .env == "all")
| .chains[] as $c
| [.label, .address, $c] | @tsv
' "$ADDRS_FILE")
while IFS=$'\t' read -r LABEL ADDR CHAIN; do
RPC=$(chain_rpc "$CHAIN")
CHAIN_INFO=$(chain_contracts "$CHAIN")
if [ -z "$RPC" ] || [ -z "$CHAIN_INFO" ]; then continue; fi
TOKEN="${CHAIN_INFO##*|}"
if [ -z "$TOKEN" ]; then continue; fi
RAW=$(decode_uint "$(cast call --rpc-url "$RPC" "$TOKEN" 'balanceOf(address)(uint256)' "$ADDR" 2>/dev/null || true)")
ZKC=$(fmt_zkc "$RAW")
R_WARN=$(jq -r '.thresholds.by_role.distributor.reserve_zkc_warn // .thresholds.default.reserve_zkc_warn // 100' "$ADDRS_FILE")
R_CRIT=$(jq -r '.thresholds.by_role.distributor.reserve_zkc_crit // .thresholds.default.reserve_zkc_crit // 50' "$ADDRS_FILE")
FLAG=""
if [ "$ZKC" != "?" ] && [ "$ZKC" != "-" ]; then
if flt_lt "$ZKC" "$R_CRIT"; then FLAG="ZKC-CRIT"
elif flt_lt "$ZKC" "$R_WARN"; then FLAG="ZKC-LOW"
fi
fi
printf "%-36s %-44s %-22s %-14s %s\n" "$LABEL" "$ADDR" "$CHAIN" "$ZKC" "$FLAG"
done <<<"$DISTRIB_TUPLES"
Output format
Render the script's output as two separate markdown tables grouped by environment tier, in this order so operators can scan the highest-stakes rows first:
- Prod mainnet — chains
base-mainnet, taiko-mainnet. Real funds at stake; flagged rows here matter most.
- Staging — chains
base-sepolia-staging, taiko-staging. Internal testing; flagged rows are usually not paging-worthy unless they block an active staging change.
(The prod testnets base-sepolia and ethereum-sepolia were discontinued, so there's no prod-testnet tier.)
Within each tier, flagged rows in bold (CRIT and LOW). The Distributor's bridged-ZKC reserve table can stay as a single third table at the end (or split by tier too if it's noisy).
Follow with a short "action list" ordered by urgency (CRIT before LOW), with prod-mainnet items first since they map directly to operational impact. Skip the section if nothing is flagged.
Common gotchas
- Don't conflate
balanceOfCollateral with balanceOf for distributors. Distributors don't deposit stake — their ZKC sits in the bridged-ERC20 directly. The script renders both: market deposit (needs_market_check: true) and the per-distributor ZKC reserve in the second table.
- Chain id reused across prod and staging. Chain 167000 (Taiko) runs both a prod market (
taiko-mainnet) and a staging market (taiko-staging); chain 84532 (Base Sepolia) now only carries the staging market (base-sepolia-staging) since the prod base-sepolia testnet was discontinued. The runbook disambiguates by chain label, and the skill's chain lookup table mirrors that.
- Decommissioned hosts may still have CloudWatch log groups (e.g.
/boundless/bento/prover-01, prover-02, bento-prover-{1,2}). The audit reflects the runbook's operator_addresses.json (which mirrors infra/cw-monitoring/Pulumi.production.yaml for active provers), not historical log-group existence.
- Public RPCs sometimes return 0 when rate-limited or transiently unhealthy. If a balance comes back as exactly 0, retry once with a private RPC (see Prerequisites:
network_secrets.toml's QuikNode keys) before declaring it actually empty.
awk truncates wei to scientific notation. Always pipe through Python or printf "%.6f" for any wei→ETH math.
cast call quoting. The function signature 'balanceOf(address)(uint256)' must be a single-quoted string; the parens are part of the type signature.
Adding a new operator address
Edit the runbook, not this skill:
- Find the address in
infra/<service>/Pulumi.l-prod-*.yaml (preferred) or ansible/inventory.yml.
- Add a row to
runbooks/addresses/operator_addresses.json (and the corresponding markdown table in runbooks/addresses/README.md) with the right role, chains, and needs_gas / needs_market_check flags.
- The skill picks it up on the next run; no skill changes needed.
Adding a new chain
- Add the new chain to
contracts/deployment.toml in the boundless repo (the canonical contract registry). The skill picks up market + collateral-token addresses automatically on the next run.
- If the chain has a public RPC the skill needs to query, add a row to the
chain_rpc function (the only inline thing left — RPC endpoints aren't in deployment.toml).
- Reference the new chain label in
runbooks/addresses/operator_addresses.json for any operator addresses operating on that chain.