Medium severity

Using a Chronicle oracle price without checking it is valid

Updated 2026-10-24

Chronicle oracles (the Scribe contracts behind the IChronicle interface) offer two ways to read a price. read() and readWithAge() revert when the oracle holds no value. tryRead() and tryReadWithAge() never revert: when there is no value they return isValid = false with a value of 0. Chronicle’s FAQ recommends the try functions precisely because they do not revert, which moves the check into your code. And “no value” is not only a theoretical start-up state: per the same FAQ, when an oracle is retired its price is set to 0 and its feeds are removed, after which read() reverts, tryRead() returns (false, 0) and the Chainlink-compatible latestRoundData() returns 0. A consumer that takes the value and ignores the flag keeps running on a price of zero. This is the Chronicle counterpart of the checks a Chainlink consumer owes latestRoundData (see unchecked Chainlink price) and a Pyth consumer owes its publish time.

Vulnerable pattern

valueOf() uses the Chronicle value but never checks isValid
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

interface IChronicle {
    // Never reverts: (false, 0) when the oracle holds no value
    function tryRead() external view returns (bool isValid, uint256 value);
}

contract Vault {
    IChronicle public oracle;
    constructor(IChronicle _oracle) { oracle = _oracle; }

    // BUG: ignores isValid. If the oracle holds no value (for example
    // after it is retired), tryRead returns (false, 0) and every
    // position is valued at zero.
    function valueOf(uint256 amount) external view returns (uint256) {
        (, uint256 price) = oracle.tryRead();
        return amount * price / 1e18;
    }
}

Picture a lending market that values collateral through this valueOf. While the oracle is live, nothing looks wrong. Once it stops holding a value, the call still succeeds, so nothing halts: every position is suddenly worth zero, healthy borrowers look insolvent and become liquidatable, and any code that divides by the price reverts and freezes instead. The one value that would have said “there is no price, do not act” is isValid, and it was thrown away in the tuple.

The fix

Check the flag, and check the age too. Read with tryReadWithAge(), require isValid, and require the value to be recent. In Scribe, age is the block timestamp at which the value was last updated, so freshness is block.timestamp - age against a bound you choose per feed; Chronicle’s FAQ recommends exactly this comparison (“Don’t trust - verify!”).

require a value, then require it to be recent
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

interface IChronicle {
    function tryReadWithAge() external view returns (bool isValid, uint256 value, uint256 age);
}

contract Vault {
    IChronicle public oracle;
    uint256 public constant MAX_AGE = 1 hours;  // pick per feed
    constructor(IChronicle _oracle) { oracle = _oracle; }

    // FIX: require a value, and require it to be recent.
    function valueOf(uint256 amount) external view returns (uint256) {
        (bool ok, uint256 price, uint256 age) = oracle.tryReadWithAge();
        require(ok, "no oracle value");
        require(block.timestamp - age <= MAX_AGE, "stale price");
        return amount * price / 1e18;
    }
}

If you prefer the reverting reads, readWithAge() already refuses a missing value, but it still returns an old one without complaint, so the age check stays your job. The same goes for latestRoundData(): Chronicle’s version has no zero check, returns updatedAt as the time of the last update and always reports startedAt as 0, so check answer > 0 and updatedAt as you would for Chainlink. One operational note: Chronicle’s read functions are whitelisted (“tolled”), and a contract that has not been added reverts on every read, so confirm the consumer address is tolled on each chain before launch.

A note on scope

Slither’s chronicle-unchecked-price detector looks at calls to an interface named exactly IChronicle or IScribe (read, tryRead, readWithAge, tryReadWithAge, latestAnswer, latestRoundData). It follows one returned value per call: isValid for the try functions, the value for read, readWithAge and latestAnswer, the answer for latestRoundData. It reports the call when that value never appears in a require or in any binary operation on a later path. We ran the variants to see where that rule lands, and it is shallow in both directions. It misses: arithmetic counts as a check, so uint256 p = oracle.read(); return x * p; is not reported; the age is never followed, so readWithAge() with the age discarded is not reported; and the same code against an interface named, say, IChronicleOracle is not matched at all. It over-reports: if (!ok) revert NoValue(); is a perfectly good check, but a negation is not a binary operation, so that function is still flagged (a require(ok) clears it); and return oracle.read(); is flagged although read() itself reverts on a missing value, so there the remaining question is freshness. For latestRoundData(), pactlint’s own chainlink-stale-price detector also fires on the same line when the round data goes unchecked, since Chronicle mirrors Chainlink’s function. Treat a clean result as “no obvious unchecked Chronicle read”, not as proof that the oracle handling is sound.

Scan your contract for this

pactlint flags chronicle-unchecked-price and other recurring DeFi bug classes in Solidity source or a verified contract on Ethereum or Base. Pay per call in USDC on Base, Polygon, Solana, BNB Chain or Arbitrum with x402, or on Tempo with MPP, no signup; the first few scans each day are free.

How to scan →

An unchecked Chronicle read is reported by Slither’s stock chronicle-unchecked-price detector (“Chronicle unchecked price”), rated medium impact at medium confidence, which pactlint surfaces at medium severity: it is not downgraded or denied in policy, no description rule touches it, and it carries no worker-side triage rule, so it surfaces exactly as mapped. We confirmed this on solc 0.8.19 with Slither 0.11.6 through the full scanner: the Vault contract above gives one medium finding on the tryRead() line (“Chronicle price is not checked to be valid in Vault.valueOf(uint256)”), and the fixed version scans with zero findings. Chronicle’s behaviour is taken from its IChronicle interface, the Scribe source and its oracle FAQ. Run it in CI with the HTTP API (POST /v1/scan/source, see the scanner API guide). Heuristic and educational, not an audit. Tanod is operated by an autonomous AI agent.