Chainlink oracle staleness
Template: chainlink-oracle-staleness ยท category: monitoring ยท risk: monitor_only
Polls a Chainlink aggregator's latestRoundData() on an interval and alerts
when updatedAt is older than the feed's heartbeat โ the classic "oracle went
quiet" alert every protocol that consumes a price feed should run. Firing is
edge-triggered: a stale feed alerts once per episode, then re-arms when
the feed updates again.
When to use it
- your protocol prices collateral, liquidations, or redemptions off a feed
- you consume a low-activity feed on an L2 and want to know when it stalls
- as an early-warning input to a circuit breaker or pause policy
Generate it
rflow new --template chainlink-oracle-staleness
# or add the workflow to an existing project:
rflow add workflow chainlink-oracle-stalenessNon-interactive (CI/agents):
rflow new --template chainlink-oracle-staleness --yes --output ./oracle-watch \
--answer feed_address=0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419 --answer max_staleness=1hThe generated workflow
# recipe: partial
workflows:
oracle-staleness:
trigger:
# poll latestRoundData() and fire on the false->true CROSSING of the
# staleness condition (edge-triggered - a stale feed alerts once, not
# every poll; it re-arms when the feed updates again)
read:
contract: EthUsdFeed
network: ethereum
function: "latestRoundData()"
every: 2m
condition: "${{ now() - output.updatedAt > duration('1h') }}"
mode: threshold
steps:
- id: alert
notify:
channel: ops
message: "ORACLE STALE: EthUsdFeed has not updated for over 1h (updatedAt ${{ trigger.args.output.updatedAt }}, answer ${{ format_units(trigger.args.output.answer, 8) }})"
on_failure: dead_letterlatestRoundData() returns five values, so the decoded
read output is a map โ the condition reads
output.updatedAt, the message reads trigger.args.output.answer.
Inputs
| key | type | default |
|---|---|---|
project_name | string | chainlink-oracle-staleness |
network / chain_id / rpc_env / rpc_url | network / chain_id / env_var / string | ethereum / 1 / ETH_RPC / a public RPC |
feed_name / feed_address | contract / address | mainnet ETH/USD (0x5f4eโฆ8419) |
feed_decimals | int | 8 (USD pairs; ETH pairs use 18) |
max_staleness | duration | 1h โ match the feed's documented heartbeat |
poll_every | duration | 2m |
channel | string | ops |
Required env vars
DATABASE_URL, the RPC env var (default ETH_RPC), TG_BOT_TOKEN,
TG_CHAT_ID โ all listed in the generated .env.example.
Safety notes
Monitor-only: no signer:, no relayers:, no transactions. Set
max_staleness a little above the feed's heartbeat (heartbeat 1h โ alert
at 1h5m if you want zero false positives; the default 1h alerts the moment a
heartbeat is missed). The edge state is persisted in Postgres, so a restart
mid-staleness does not re-alert. on_failure: dead_letter journals failed
notifications.
Run it locally
docker compose up -d # postgres on localhost:5448
# fill .env (RPC + telegram credentials)
rflow validate
rflow startRehearse with a canned stale round (dry-run, nothing sent):
rflow test oracle-staleness --fixture fixtures/stale-round.jsonProduction checklist
-
feed_addressis the aggregator proxy for your pair/network (see Chainlink's feed registry) andfeed_decimalsmatches -
max_stalenessis aligned with that feed's documented heartbeat -
poll_everyis meaningfully shorter thanmax_staleness - your RPC allows the poll rate you configured
- telegram credentials in
.env, test withrflow test -
rflow validate --preflightpasses against your RPC
Common modifications
- also alert on price deviation: add a second read-triggered workflow with
mode: changed, or compare againstprevious_run - watch several feeds: one
contracts:entry + one workflow per feed - feed a circuit breaker: let the alert
state_seta flag your money-moving workflows check in arecheck:expression - page instead of chat: swap the channel for pagerduty with
rflow add notification