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:
- 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.
- 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.
- 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:
- 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).
- 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).
- Contract changes: proxy pointing to a new aggregator (FW-AGG-001) and decimals or description drift (FW-META-001).
- 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).
- L2: sequencer down (FW-SEQ-001) or still within its grace period (FW-SEQ-002).
- 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:
- 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.
- 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.
- 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.
- 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.