Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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-staleness

Non-interactive (CI/agents):

rflow new --template chainlink-oracle-staleness --yes --output ./oracle-watch \
  --answer feed_address=0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419 --answer max_staleness=1h

The 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_letter

latestRoundData() returns five values, so the decoded read output is a map โ€” the condition reads output.updatedAt, the message reads trigger.args.output.answer.

Inputs

keytypedefault
project_namestringchainlink-oracle-staleness
network / chain_id / rpc_env / rpc_urlnetwork / chain_id / env_var / stringethereum / 1 / ETH_RPC / a public RPC
feed_name / feed_addresscontract / addressmainnet ETH/USD (0x5f4eโ€ฆ8419)
feed_decimalsint8 (USD pairs; ETH pairs use 18)
max_stalenessduration1h โ€” match the feed's documented heartbeat
poll_everyduration2m
channelstringops

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 start

Rehearse with a canned stale round (dry-run, nothing sent):

rflow test oracle-staleness --fixture fixtures/stale-round.json

Production checklist

  • feed_address is the aggregator proxy for your pair/network (see Chainlink's feed registry) and feed_decimals matches
  • max_staleness is aligned with that feed's documented heartbeat
  • poll_every is meaningfully shorter than max_staleness
  • your RPC allows the poll rate you configured
  • telegram credentials in .env, test with rflow test
  • rflow validate --preflight passes against your RPC

Common modifications

  • also alert on price deviation: add a second read-triggered workflow with mode: changed, or compare against previous_run
  • watch several feeds: one contracts: entry + one workflow per feed
  • feed a circuit breaker: let the alert state_set a flag your money-moving workflows check in a recheck: expression
  • page instead of chat: swap the channel for pagerduty with rflow add notification