Medium severity

Deprecated Pyth getPrice: a staleness window your contract did not choose

Updated 2026-10-27

Pyth’s Solidity SDK used to give IPyth three convenience reads: getPrice, getEmaPrice and getValidTimePeriod. Pyth’s API reference marks all three as deprecated and recommends getPriceNoOlderThan and getEmaPriceNoOlderThan, which let the caller set the maximum age. Version 4.0.0 of @pythnetwork/pyth-sdk-solidity (August 2024) dropped all three from the interface, and the SDK’s AbstractPyth base keeps them “only for backward compatibility”. They are not unchecked reads: getPrice(id) is implemented as getPriceNoOlderThan(id, getValidTimePeriod()) and reverts with StalePrice once the stored price is older than that window. The problem is whose window it is. getValidTimePeriod() returns one number per Pyth deployment, set by Pyth and changeable through its governance, not by your contract. When we read it on 2026-10-27 it was 60 seconds on Base, Arbitrum One and OP Mainnet, and 120 seconds on Ethereum. A contract that prices collateral through getPrice has handed one of its risk parameters to someone else. This is the third Pyth integration check, next to the publish time and the confidence interval.

Vulnerable pattern

collateralValue() reads the deprecated getPrice, so Pyth’s per-chain window decides how stale is too stale
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

library PythStructs {
    struct Price {
        int64 price;
        uint64 conf;
        int32 expo;
        uint256 publishTime;
    }
}

// Excerpt of IPyth as it shipped before SDK 4.0.0
interface IPyth {
    function getValidTimePeriod() external view returns (uint256);
    function getPrice(bytes32 id)
        external view returns (PythStructs.Price memory);
    function getEmaPrice(bytes32 id)
        external view returns (PythStructs.Price memory);
}

contract Lending {
    IPyth public immutable pyth;
    bytes32 public immutable ethUsd;

    constructor(IPyth _pyth, bytes32 _ethUsd) {
        pyth = _pyth;
        ethUsd = _ethUsd;
    }

    // BUG: getPrice is deprecated. It returns any price younger than
    // getValidTimePeriod(), a window Pyth sets per chain, so how stale
    // a price this market accepts is decided outside the contract and
    // changes with the chain it is deployed on.
    function collateralValue(uint256 ethAmount) public view returns (uint256) {
        PythStructs.Price memory p = pyth.getPrice(ethUsd);
        return ethAmount * uint256(uint64(p.price));   // feed units: scale by 10^expo
    }
}

Deploy this Lending contract unchanged on Base and on Ethereum. On Base, a price that has not been refreshed for more than a minute makes collateralValue revert; on Ethereum the same code keeps valuing collateral at a quote up to two minutes old. Neither bound came out of the protocol’s risk analysis: a liquidation engine on a volatile asset may want ten or twenty seconds, while a vault holding a slow-moving asset may accept more. Because the window lives in Pyth’s contract, it can also move under you: a governance change on that chain changes how stale a price your market accepts, with no upgrade, review or event of your own. In a fast market that gap is the exploit window, since on Ethereum a borrower can draw against collateral marked at a price up to two minutes old.

There is a maintenance trap as well. Against the 4.x SDK, pyth.getPrice(id) no longer compiles (solc 0.8.19 reports Member "getPrice" not found or not visible after argument-dependent lookup in contract IPyth.), and the one-word change that turns the build green again, getPriceUnsafe, removes the age check altogether, which is the bug on the unchecked publish time page. Code that borrows the window explicitly has the same problem in another shape:

markPrice() checks freshness, but against Pyth’s deprecated getValidTimePeriod()
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

library PythStructs {
    struct Price {
        int64 price;
        uint64 conf;
        int32 expo;
        uint256 publishTime;
    }
}

interface IPyth {
    function getValidTimePeriod() external view returns (uint256);
    function getPriceUnsafe(bytes32 id)
        external view returns (PythStructs.Price memory);
}

contract Perp {
    IPyth public immutable pyth;
    constructor(IPyth _pyth) { pyth = _pyth; }

    // BUG: the freshness check is real, but its bound is still Pyth's
    // deprecated chain-wide window, not one this market chose.
    function markPrice(bytes32 id) public view returns (int64) {
        PythStructs.Price memory p = pyth.getPriceUnsafe(id);
        require(p.publishTime + pyth.getValidTimePeriod() >= block.timestamp, "stale");
        require(p.price > 0, "bad price");
        require(p.conf * 100 <= uint64(p.price), "price too uncertain");
        return p.price;
    }
}

The fix

Name the maximum age in the call. getPriceNoOlderThan(id, age) returns the price only if it was published within age seconds and reverts with StalePrice otherwise, so the bound is visible in your code, reviewed with it, and the same on every chain. The fixed contract below picks 30 seconds for its market and adds the positive-price and confidence-band checks from the companion pages; it clears all three Pyth detectors.

the market chooses its own maximum age
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

library PythStructs {
    struct Price {
        int64 price;
        uint64 conf;
        int32 expo;
        uint256 publishTime;
    }
}

interface IPyth {
    function getPriceNoOlderThan(bytes32 id, uint256 age)
        external view returns (PythStructs.Price memory);
}

contract Lending {
    IPyth public immutable pyth;
    bytes32 public immutable ethUsd;
    uint256 public constant MAX_AGE = 30;   // seconds; this market's choice

    constructor(IPyth _pyth, bytes32 _ethUsd) {
        pyth = _pyth;
        ethUsd = _ethUsd;
    }

    // FIX: name the maximum age in the call. getPriceNoOlderThan reverts
    // with StalePrice once the stored price is older than MAX_AGE, on
    // every chain, whatever Pyth's own default window is.
    function collateralValue(uint256 ethAmount) public view returns (uint256) {
        PythStructs.Price memory p = pyth.getPriceNoOlderThan(ethUsd, MAX_AGE);
        require(p.price > 0, "bad price");
        require(p.conf * 100 <= uint64(p.price), "price too uncertain");
        return ethAmount * uint256(uint64(p.price));   // feed units: scale by 10^expo
    }
}

If the right age really differs by chain or asset, make it your own parameter, set per market in the constructor or by your own governance with sane limits, rather than inheriting Pyth’s default. A tight bound has an operational cost on a pull oracle: reads revert until someone pushes a fresh update. Pyth’s own SDK example pays getUpdateFee, calls updatePriceFeeds with fresh update data in the same transaction, and then reads with a 10-second bound. The moving-average reads follow the same rule: replace getEmaPrice with getEmaPriceNoOlderThan.

A note on scope

Slither’s pyth-deprecated-functions detector reports every high-level call named getPrice, getEmaPrice or getValidTimePeriod on a type named IPyth, including calls inside inherited functions. It is a name match, so it is exact about what it sees and blind to the rest. In our tests it did not flag the same getPrice call made through a differently named interface (IPythOracle), through a contract type instead of the interface (such as the SDK’s AbstractPyth), or as a low-level staticcall built from the selector. It also ignores what you do with the result: a getPrice call followed by your own publishTime check is still reported. With a tighter check of your own the behaviour is sound for as long as Pyth’s window stays above your bound, and the finding is a migration item (the code stops compiling on a 4.x SDK); with a looser one, Pyth’s window still wins and reads revert sooner than you intended. A clean result means no deprecated call through IPyth, not that the oracle handling is sound.

Scan your contract for this

pactlint flags pyth-deprecated-functions, the two other Pyth checks 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 →

A call to a deprecated Pyth read is reported by Slither’s stock pyth-deprecated-functions detector (“Pyth deprecated functions”), rated medium impact at high confidence, which pactlint surfaces at medium severity with no refinement of its own. We confirmed this on solc 0.8.19 with a full pactlint scan: the Lending contract above gets three medium findings on the pyth.getPrice(ethUsd) line, this one plus pyth-unchecked-publishtime and pyth-unchecked-confidence; the Perp contract gets this finding on its getValidTimePeriod() call; the fixed version has no findings. In a Foundry test against the MockPyth contract from SDK 4.3.1, the deprecated version accepted a 45-second-old price under a 60-second window that the fixed one rejected with StalePrice, and the same deprecated code rejected a 90-second-old price under a 60-second window but accepted it under a 120-second one. The per-chain windows are getValidTimePeriod() reads of the Pyth Core contracts listed in Pyth’s documentation, made on 2026-10-27. 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.