Skip to content

After OpenZeppelin Defender: Run Contract State Checks on a GitHub Actions Cron

Many Defender Monitor setups were state checks. Move pause flags, owner changes, Safe thresholds and oracle age into one TypeScript script on GitHub Actions.

evmquery team10 min read
Try it: Contract InspectorProxy chain and full method schema on Ethereum, Base, BNB Smart Chain, Polygon. Free, no signup.

The run fails, GitHub emails you, and the job summary says ALERT usdc-owner: USDC owner is now 0xfcb19e6a322b27c06842a71e8c725399f049ae3a. That is the whole alerting system this post builds: one script that reads a handful of contract states, compares them with the last run, and goes red only when something changed. It is meant for the Defender Monitors that were never really watching events.

TL;DR

Pause flags, owner and admin changes, Safe thresholds, oracle freshness and balance floors are state reads, so one TypeScript script can check all of them in a single evmquery request. Run it every ten minutes from GitHub Actions, keep the last result in the Actions cache, and fail the run on a new alert.

Prerequisites

  • Node 24 locally (Node 22.18+ also runs .ts files directly).
  • A GitHub repository where you can add Actions secrets.
  • An evmquery API key, exposed as EVMQUERY_API_KEY. The free tier is enough for this.
  • Optional: a Slack incoming webhook URL as SLACK_WEBHOOK_URL.

Which Defender monitors were really state checks

OpenZeppelin announced on 2025-06-30 that hosted Defender would retire on July 1, 2026, and its sunset FAQ names the open source Relayer and Monitor as the tools to self-host afterwards. Both announcements are plain about the date, and the hosted service is gone now. If you still have a list of what your old setup watched, sort it into two piles.

The first pile reacts to something that happened: an Upgraded event, a call to transferOwnership, a failed transaction. Those need log and transaction data, and the open source OpenZeppelin Monitor is built for exactly that. The second pile asks a question about the present: is the token paused, who owns this contract, how many signatures does the treasury Safe require, when did this price feed last update, is the keeper wallet above its floor. That pile needs no events at all. You call a view function on a schedule and compare the answer with the previous one.

Defender Monitor jobs split in two: state checks such as the paused flag, owner, Safe threshold, oracle age and balance floor go to a scheduled script on GitHub Actions, while events, function calls and mempool go to the open source OpenZeppelin Monitor
Where each kind of Defender Monitor job goes next.

This post moves the second pile to code you own.

Reading six checks in one request

The checks below are real mainnet contracts: USDC’s paused() and owner(), the Chainlink ETH/USD feed’s updatedAt, and the signer list, threshold and ETH balance of the 3-of-5 Safe from the Safe owners post. The top of the script declares them, sends one request, and unpacks the result.

Set up the project once and keep the key in a .env file as a plain EVMQUERY_API_KEY=... line:

npm init -y
npm pkg set type=module
npm install @evmquery/sdk

Run the script locally with mkdir -p state && node --env-file=.env check.ts.

import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { createEvmqueryClient } from "@evmquery/sdk";
const evmquery = createEvmqueryClient({ apiKey: process.env.EVMQUERY_API_KEY });
const SAFE = "0x0da0c3e52c977ed3cbc641ff02dd271c3ed55afe";
const MIN_SAFE_BALANCE_WEI = 250_000_000_000_000_000n; // 0.25 ETH
const MAX_FEED_AGE_S = 2 * 60 * 60;
const { data } = await evmquery.query({
throwOnError: true,
body: {
chain: "evm_ethereum",
schema: {
contracts: {
usdc: { address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" },
ethUsd: { address: "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419" },
safe: { address: SAFE },
},
},
expression: `{
"paused": dyn(usdc.paused()),
"owner": dyn(usdc.owner()),
"updatedAt": dyn(ethUsd.latestRoundData().updatedAt),
"threshold": dyn(formatUnits(safe.getThreshold(), 0)),
"signers": dyn(safe.getOwners()),
"balance": dyn(solAddress("${SAFE}").balance())
}`,
},
});
const v = data.result.value as Record<string, any>;

Everything between the backticks in expression is evaluated server side, and every call lands in the same block, so a threshold and a signer list can never come from two different moments. The dyn() wrappers are there so that one map can carry a boolean, an address, numbers and a list together. formatUnits(..., 0) turns the Safe threshold into a plain number. The raw alternative is a viem multicall with six ABI fragments you maintain yourself. The request cost 7 units when I ran it.

Run it and the raw data.result.value looks like this for the parts I use:

{
"paused": false,
"owner": { "value": "0xfcb19e6a322b27c06842a71e8c725399f049ae3a" },
"updatedAt": { "value": "1794238583" },
"threshold": 3,
"signers": [{ "value": "0xe4df0cdc9ef7e388ea906226010bbd1b9a6ffed9" }, "..."],
"balance": { "value": "682960961506566725" }
}

The boolean and the threshold come back bare, while addresses and the remaining integers arrive wrapped as { "value": ... }, with integers as strings. That is why the script reads v.owner.value and converts with Number and BigInt. The balance is wei, so it stays a BigInt for the comparison.

Alerting only when something changes

A cron that messages you every ten minutes while USDC is paused gets muted within a day. So each check stores a state string. Threshold checks store "ok" or "alert", and change checks (owner, signer set) store the value itself. The next part of the same file builds that list.

// state: "ok" | "alert" for threshold checks, the observed value for change checks
const checks: { key: string; state: string; text: string }[] = [
{ key: "usdc-paused", state: v.paused ? "alert" : "ok", text: "USDC is paused" },
{ key: "usdc-owner", state: v.owner.value, text: `USDC owner is now ${v.owner.value}` },
{
key: "eth-usd-stale",
state: Date.now() / 1000 - Number(v.updatedAt.value) > MAX_FEED_AGE_S ? "alert" : "ok",
text: "ETH/USD feed has not updated in over 2 hours",
},
{
key: "safe-threshold",
state: v.threshold < 3 ? "alert" : "ok",
text: `Safe threshold is ${v.threshold}, expected 3 or more`,
},
{
key: "safe-signers",
state: v.signers.map((s: { value: string }) => s.value).join(","),
text: "Safe signer set changed",
},
{
key: "safe-balance",
state: BigInt(v.balance.value) < MIN_SAFE_BALANCE_WEI ? "alert" : "ok",
text: `Safe balance is down to ${Number(v.balance.value) / 1e18} ETH`,
},
];

The signer list is joined in the order the Safe returns it. A Safe keeps owners in a linked list, so the order only changes when the set does. If you point this at a contract that returns an unordered set, sort it first or you will page yourself for nothing.

Then the part that compares, persists and tells you:

const stateFile = "state/state.json";
const prev: Record<string, string> = existsSync(stateFile)
? JSON.parse(readFileSync(stateFile, "utf8"))
: {};
// Fire on change, or on the first run if a threshold check is already failing
const fired = checks.filter(
(c) => prev[c.key] !== c.state && (prev[c.key] !== undefined || c.state === "alert"),
);
const lines = fired.map((c) => (c.state === "ok" ? `Recovered: ${c.key}` : `ALERT ${c.key}: ${c.text}`));
console.log(lines.length ? lines.join("\n") : "No changes");
if (lines.length) {
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## State checks\n\n${lines.map((l) => `- ${l}`).join("\n")}\n`);
}
if (process.env.SLACK_WEBHOOK_URL) {
const res = await fetch(process.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: lines.join("\n") }),
});
if (!res.ok) throw new Error(`Slack webhook failed with status ${res.status}`);
}
if (fired.some((c) => c.state !== "ok")) process.exitCode = 1;
}
// Written last, so a failed Slack post is retried on the next run
writeFileSync(stateFile, JSON.stringify(Object.fromEntries(checks.map((c) => [c.key, c.state]))));

A check fires when its state differs from last run, with one exception for the very first run: a change check has nothing to compare against, so it records a baseline silently, while a threshold check that is already failing fires right away. Going from "alert" back to "ok" fires too, as a Recovered: line, but only a non-ok state sets process.exitCode = 1. A recovery lands in the summary and Slack without turning the run red. The state file is written last, after Slack has accepted the message, so a failed send is retried on the next run instead of being lost.

I tested the transitions by editing state/state.json between runs: a changed owner and a shrunken signer list both fired and exited 1, a stored "alert" for the balance fired Recovered: safe-balance, and an unchanged second run printed No changes and exited 0. Raising the balance floor to 1 ETH on an empty state printed ALERT safe-balance: Safe balance is down to 0.6829609615065668 ETH.

Running it on a GitHub Actions cron

Save the script as check.ts in the repository root and add this as .github/workflows/state-checks.yml.

name: state-checks
on:
schedule:
- cron: "*/10 * * * *"
workflow_dispatch:
concurrency:
group: state-checks
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm install @evmquery/sdk
- uses: actions/cache/restore@v4
with:
path: state
key: alert-state-restore
restore-keys: alert-state-
- run: mkdir -p state && node check.ts
env:
EVMQUERY_API_KEY: ${{ secrets.EVMQUERY_API_KEY }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- uses: actions/cache/save@v4
if: always()
with:
path: state
key: alert-state-${{ github.run_id }}
One scheduled run: restore the cached state file, run the checks, send alerts and fail the run only when something new fired, then always save the cache for the next run ten minutes later
One scheduled run. The save step runs even when the alert step fails the job.

Add EVMQUERY_API_KEY (and optionally SLACK_WEBHOOK_URL) under the repository’s Actions secrets. Both actions/cache halves are sub-actions of actions/cache@v4, used separately on purpose; the next section explains why. A few details that the file does not show:

  • Scheduled workflows run from the default branch only, and GitHub may start them a few minutes late under load. Treat ten minutes as a target.
  • A */10 schedule is about 4,300 runs a month. Public repositories run Actions for free; private repositories draw on a monthly allowance of Actions minutes, so check the plan before picking the interval.
  • concurrency with cancel-in-progress: false makes a slow run finish before the next one starts, so two runs never write state at the same time.
  • Cache entries are immutable, which is why the save key ends in github.run_id. The restore step falls back to the newest entry that starts with alert-state-.
  • A cache entry unused for 7 days is evicted. Every run saves a fresh one, so this only matters after an outage.
  • In a public repository GitHub disables scheduled workflows after 60 days without repository activity. When that happens, enable the workflow again in the Actions tab, and expect the first run afterwards to start from a blank baseline.

Failed runs email whoever GitHub has configured for workflow notifications, and the job summary shows the same lines the script printed. For a team channel, use the Slack webhook. If you already run n8n, the n8n Safe alert post covers the Safe part, and the low gas balance post extends the balance check to several chains.

Cache saves and the first baseline

The cache is not saved after a failed step. A workflow with a single actions/cache@v4 step saves in a post-run phase that only runs when the job succeeds, and this job fails on purpose when it alerts. The state never gets written, so the same alert fires again on every run. Splitting into restore and save with if: always() fixes it.

The first run bakes in a bad baseline. Change checks record whatever they see first. If the owner is already wrong on day one, you will not be told. Check the printed state once by hand before trusting it.

Or run the checks as monitors

If you would rather not host the GitHub Actions job, an evmquery monitor runs a state check on a schedule and alerts by email or an HTTPS webhook. Each monitor watches one expression, so the six checks become a few monitors. A condition covers the pause flag and a change trigger covers the owner:

usdc.paused()
usdc.owner()

Use a condition trigger for paused() and a change trigger for owner(). The first fires when the token becomes paused. The second records the first value as its baseline and fires when the owner differs, which also removes the bad first-run baseline above. Each monitor tracks one value, so give each check its own monitor. evmquery retries deliveries and pauses a monitor with an alert after repeated errors. The Free plan covers 3 monitors with 15-minute checks.

What this does not cover

This is a cron job that reads current state. It will not see anything that happens and then reverses between two runs: a token paused and unpaused within ten minutes, an owner transferred and transferred back, a price feed that spiked and recovered. It has no event or log access, so it cannot watch an Upgraded or Transfer event, filter a function call by arguments, or react to a failed transaction. It does not look at the mempool, so it cannot warn before a transaction lands. And there is no relayer here; it alerts, it does not send transactions.

For those, use the open source OpenZeppelin Monitor that the sunset notice points to. It runs as a Docker image you host yourself and covers event and function-call conditions with notification channels. The two approaches fit together: state checks for the slow, quiet questions, a self-hosted Monitor for the event-driven ones. If you are deciding what to keep, the developer page shows where evmquery fits.

Next steps

Share

Run your state checks on one key

Free tier, no credit card. One API key reads every contract in the script above, across Ethereum, Base, BNB Chain and Polygon.