Smart contracts cannot see the world outside the blockchain. To know what one ETH is worth, a lending protocol depends on an oracle, and the most widely used one in the ecosystem is Chainlink's network of Data Feeds. That creates two very different points of trust: on one side, the node operators who run the infrastructure that signs and publishes prices; on the other, the consuming protocols, which wrote code assuming those prices are correct, fresh and within sane bounds.

This article presents two open-source Go tools I built, one for each side. oraclint is a static security linter for Chainlink node configuration that runs before deployment. oraclewatch is a monitoring daemon that reads feeds straight from the chain and alerts when the consumer's assumptions stop holding. Neither is affiliated with Chainlink Labs.

Concepts

Data Feeds and the aggregator contract

A Data Feed is a pair of contracts: a proxy, the stable address the consumer calls, and an aggregator, which receives node reports and stores the consolidated value. The consumer reads the price with latestRoundData(), which returns the value (answer), the round identifier and the start and update timestamps. A feed updates when the price moves more than a deviation threshold or when the heartbeat, the maximum interval between updates, elapses.

Aggregators also have bounds, minAnswer and maxAnswer. Reports outside that range are rejected, not clamped to the bound: the feed simply freezes at the last accepted value. That detail, confirmed in the source of the deployed aggregators, is at the center of one of the incidents described later.

L2 sequencers

On rollups such as Arbitrum, Base and OP Mainnet, a single sequencer orders transactions. If it goes down, prices stop updating, and when it comes back, queued transactions run first, possibly against stale prices. That is why Chainlink publishes sequencer uptime feeds, and why audits usually require consumers to check the sequencer and respect a grace period after recovery.

The Chainlink node

On the operator side, a node is a process that holds keys, signs transactions and reports, and exposes an operator interface (port 6688) that manages jobs, keys and bridges. Its configuration lives in a config.toml and a secrets.toml, usually alongside a docker-compose file, Kubernetes manifests and .env files. Many node compromises are not exploits but configuration: an operator UI published to the internet, a database reached without TLS, a short keystore password, an RPC key committed to the repository.


oraclint: a configuration linter for nodes

Tools like tfsec, checkov and kube-bench do for Terraform and Kubernetes exactly what was missing for Chainlink nodes: point out insecure settings before deployment, with an exact location and a suggested fix. oraclint fills that gap. It reads the node's files and the infrastructure around them, runs fully offline and never touches a running node.

Rules verified against the node's code

There are 35 rules across node config, secrets, file permissions, docker-compose, Kubernetes and dotenv. Each one was verified against the node's source code and documentation at version v2.69.0: key name, default value and actual behavior. A rule whose behavior could not be confirmed is marked experimental or does not exist at all. The README even lists the candidates that were dropped and why, such as database password complexity (the node already refuses weak passwords at startup) and pprof (always behind authentication in the node's router).

Default-aware

A missing key is not treated as "not configured": it is evaluated with the node's real default. When there are several config files, they are merged with the node's own override semantics. This avoids both false positives and false negatives, which is the main reason generic TOML linters do not work here.

Architecture

oraclint is a linear pipeline of small packages:

cli → loader → model → engine ← rules (35)
                         │
                         ▼
                 suppress → redact → filter/sort → report (text | JSON | SARIF)

The loader discovers and classifies files by name and content, and the model keeps every value tied to its file:line:column. Each rule is a Go type with two methods and a pure function of its target, in its own file. Before rendering, every message goes through redact: oraclint never prints a secret, with two layers of redaction enforced by a fuzz test across every output format. Hostile input is handled too: YAML alias bombs, deep nesting, binary files and symlinks that escape the scan root (the walk uses os.Root).


oraclint in practice

It can be installed with Go, a release binary or Docker:

go install github.com/brennocm/oraclint/cmd/oraclint@latest
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/src:ro" -w /src \
  ghcr.io/brennocm/oraclint:latest scan .

The repository ships a deliberately insecure node in examples/insecure-node. An excerpt of the output:

oraclint scan examples/insecure-node
examples/insecure-node/config.toml
  2:1     HIGH     NS-KS-001    InsecureFastScrypt = true makes every keystore use weak scrypt
                                parameters, so a leaked database can be brute-forced offline
          fix: Remove InsecureFastScrypt (default false) and rotate keys created while it was enabled.
  14:1    MEDIUM   NS-WEB-003   WebServer.AllowOrigins contains '*', which allows any website to
                                make cross-origin requests to the node API
          fix: Set WebServer.AllowOrigins to the exact URL(s) used to reach the operator UI.
  54:1    HIGH     NS-RPC-001   EVM chain "1" node "mainnet-infura": HTTPURL uses unencrypted
                                http:// to mainnet.example-rpc.com
          fix: Use https:// and wss:// endpoints, or keep the RPC node on a private network.

examples/insecure-node/docker-compose.yml
  6:5     HIGH     NS-DKR-003   service "chainlink" runs privileged, which gives it full access to the host
          fix: Remove privileged, network_mode: host, pid: host and the listed cap_add entries.
  9:9     HIGH     NS-DKR-001   service "chainlink" publishes operator port 6688 on all host interfaces
          fix: Publish the port on loopback only ("127.0.0.1:6688:6688") and use an SSH tunnel or VPN.
  11:7    HIGH     NS-KS-002    CL_PASSWORD_KEYSTORE is shorter than 16 characters

Rule NS-DKR-001 is worth highlighting, because it ties directly to the problem discussed in the Tor bridge article: Docker publishes ports by inserting its own firewall rules, so a published port is often reachable even with ufw configured to block it. The fix is to publish the operator interface on loopback only and reach it through an SSH tunnel.

Suppressions with a mandatory reason

Every suppression requires a reason, either inline or in .oraclint.yaml. A directive without a reason is not applied and becomes an informational finding. Suppressed findings stay visible in JSON and SARIF, so the justification is kept on record:

[WebServer.TLS]
# oraclint:ignore NS-WEB-001 reason="TLS is terminated at the load balancer"
HTTPSPort = 0

When scanning untrusted content, such as pull requests from forks, the --ignore-suppressions flag makes sure only your own policy applies, since the PR itself could carry an .oraclint.yaml that silences everything.

CI integration

The repository is also a GitHub Action. With SARIF 2.1.0 output, findings show up in GitHub's code scanning tab:

jobs:
  scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@v7
      - uses: brennocm/oraclint@v0.1.0   # pin to a commit SHA in production
        with:
          path: .
          format: sarif
          output: oraclint.sarif
          fail-on: high
      - uses: github/codeql-action/upload-sarif@v4
        if: always()
        with:
          sarif_file: oraclint.sarif

Exit codes follow linter conventions: 0 with no findings above the threshold, 1 with findings and 2 for usage errors. Files that cannot be parsed produce a high-severity parse-error finding, so a broken file fails the build instead of passing silently. On the example deployment (5 files, 35 rules), a full scan takes about 4 ms.


oraclewatch: consumer-side monitoring

Chainlink operates and monitors its Data Feeds. The risk of using a feed, though, sits with the protocol that consumes it. oraclewatch continuously runs the same checks auditors ask for on-chain (updatedAt freshness, answer > 0, sequencer uptime and grace period), plus a few that only make sense off-chain, and pages a human when something is off. Three real incidents shaped the catalog:

  1. LUNA/USD, May 2022. The market price fell below the aggregator's minAnswer. Since out-of-range reports are rejected, the feed froze at about US$0.107 while LUNA traded around US$0.01. Attackers bought at market, deposited it as collateral at the oracle price and borrowed against it. Venus and Blizz Finance lost about US$11M and US$8.3M. FW-DEV-001 (≈970% deviation), FW-STALE-001 and FW-CB-001 flag that condition.
  2. Moonwell wrsETH, November 2025. An exchange-rate feed reported 1 wrsETH = 1.65 million ETH, and the attack took 30 seconds. FW-JUMP-001 flags the jump, and the case shows why a monitor complements, and does not replace, on-chain sanity bounds.
  3. Arbitrum sequencer outage, December 2023. About 78 minutes of downtime, covered by FW-SEQ-001, FW-SEQ-002 and FW-CHAIN-001.

The check catalog

There are 16 checks with stable IDs, each documented with its exact logic, default parameters and known false positives. Grouped by purpose:

  1. Feed health: feed stale beyond its heartbeat (FW-STALE-001), invalid round data (FW-INVALID-001), answer close to the circuit-breaker bounds (FW-CB-001) and implausible round-to-round jumps (FW-JUMP-001, experimental).
  2. Market: deviation from the median of several exchanges (FW-DEV-001), with an explicit "untrusted reference" state when too few exchanges answer or they disagree (FW-REF-001), and divergence of the same pair across networks (FW-XCHAIN-001).
  3. Contract changes: proxy pointing to a new aggregator (FW-AGG-001) and decimals or description drift (FW-META-001).
  4. Trust in the source: RPCs returning different data for the same block (FW-RPC-001), fork views (FW-RPC-002), wrong chain ID (FW-RPC-003) and stalled heads (FW-CHAIN-001).
  5. L2: sequencer down (FW-SEQ-001) or still within its grace period (FW-SEQ-002).
  6. The monitor itself: FW-SELF-001 fires when there are no successful reads for N cycles. Without data, oraclewatch reports itself blind instead of guessing.

One pinned block per cycle

The central design decision is that every value a check compares comes from the same block. There is one goroutine per network, and each cycle starts by pinning a block: min(head) - confirmations across all available endpoints. Endpoints whose head is lagging are excluded, the block hash is compared across them (the majority wins), and then every feed is read on every endpoint at that exact block.

That is what makes FW-RPC-001's "any difference is critical" rule sound: two honest RPCs queried at the same block must return identical bytes. It also keeps staleness measured against the chain's own clock rather than the clock of the machine running the monitor.

Checks are pure functions, with no I/O and no clock, using exact math/big arithmetic. Results feed an alert state machine (OK → PENDING → FIRING → RESOLVING) with for:, hysteresis, cooldown and storm grouping, delivered to Discord, Telegram or stdout, plus Prometheus metrics and a Grafana dashboard.


oraclewatch in practice

The check mode runs a single cycle on every network and exits 0 (fine), 1 (a result at or above --fail-on) or 2 (error), so it fits CI or cron. RPC URLs are referenced as ${VAR} and expanded from the environment only in secret fields:

cp .env.example .env     # two RPCs from different providers per network, notifiers
make build && set -a && . ./.env && set +a
./bin/oraclewatch check --config configs/example.yaml

A real run against ETH/USD on Ethereum and Arbitrum, using public RPCs (output trimmed):

STATUS  CHECK           NETWORK   FEED     SUMMARY
ok      FW-CB-001       ethereum  ETH/USD  bounds are nominal
ok      FW-DEV-001      arbitrum  ETH/USD  within 0.0495% of the market
ok      FW-DEV-001      ethereum  ETH/USD  within 0.2728% of the market
ok      FW-REF-001      ethereum  ETH/USD  reference trusted
ok      FW-RPC-001      arbitrum  ETH/USD  2 endpoints agree
ok      FW-RPC-002      ethereum           endpoints agree on block 26250692
ok      FW-RPC-003      arbitrum           every endpoint serves the configured chain
ok      FW-SEQ-001      arbitrum           sequencer up
ok      FW-SEQ-002      arbitrum           up for 3720h50m50s
ok      FW-STALE-001    arbitrum  ETH/USD  updated 3m10s ago
ok      FW-STALE-001    ethereum  ETH/USD  updated 53m0s ago
ok      FW-XCHAIN-001   *         ETH/USD  ETH/USD consistent across 2 networks

Note FW-STALE-001: the Ethereum feed was updated 53 minutes ago and is still healthy, because the ETH/USD heartbeat on mainnet is one hour. That is why every feed carries its own parameters, copied from the official data files, instead of a global threshold.

For the daemon with Prometheus, Grafana and TLS there is a ready-made compose file, and the docs cover a deployment on an arm64 Oracle Cloud Always Free instance, the same base used in OCI-SecOps:

docker compose -f deploy/docker-compose.yml --env-file .env up -d

The example configuration monitors ETH/USD, BTC/USD and USDC/USD on Ethereum, Arbitrum, Base and OP Mainnet, plus the sequencer uptime feeds of the three L2s. No address was typed by hand: all of them came from the data files behind the official addresses page and were verified on-chain against two RPCs at the same block, and a test keeps the example identical to the verified lists.


Securing the tools themselves

Security tools that run next to sensitive infrastructure need to be hard targets themselves. A few principles are shared by both:

  1. Minimal surface. oraclint has no network access and three direct runtime dependencies (cobra, go-toml, yaml). oraclewatch only uses eth_chainId, eth_getBlockByNumber and eth_call; no private key exists in the program, and a test fails if the binary links any key management or transaction-signing package.
  2. Secrets never leave the process. Redaction of strings, errors (including *url.Error, which tends to leak the full RPC URL with its API key) and logs, verified by end-to-end tests.
  3. Supply chain. Actions pinned by SHA, Dependabot, GoReleaser releases with checksums signed keylessly through cosign (Sigstore), SPDX SBOMs and SLSA provenance verifiable with gh attestation verify.
  4. Documented threats. Each repository has a THREAT_MODEL.md and design decisions recorded as ADRs.

Limitations

Both are deliberately honest about what they do not do. oraclint is static analysis: it sees files, not the running node, and its rules hold for v2.69.0 and may be wrong for other versions. Helm charts must be rendered with helm template first.

oraclewatch's detection latency is the poll interval (60 s by default) plus confirmations. An attacker watching the same feed acts within a block; the monitor tells humans, it does not stop transactions. It does not replace on-chain validation in the consumer contract, which is the first line of defense, and it does not cover derived prices a protocol composes from several feeds.


Final thoughts

Oracles concentrate trust in two ways: in the infrastructure that produces the data, and in the consumer's assumption that the data is still valid. oraclint acts before deployment, turning node operation best practices into verifiable rules that run in CI. oraclewatch acts afterwards, continuously checking whether the consumer's assumptions still hold.

The lesson both projects share is that a security tool is only as good as the accuracy of its assumptions. That is why every rule was verified against the node's code, every check has a real incident or documented research behind it, and both prefer to say "I don't know" (parse-error, FW-SELF-001) rather than pass silently.