Medium severity
Using a Pyth price without checking its publish time
Updated 2026-10-22
Pyth is a pull oracle: the price does not stream on-chain by itself. Someone has to submit a signed update, and between updates the last value just sits in the contract. Every price Pyth returns carries a publishTime — the moment that value was actually produced — precisely so a consumer can tell a fresh quote from a stale one. The “unsafe” and plain read functions (getPriceUnsafe, getPrice, getEmaPrice, getEmaPriceUnsafe) hand back whatever is stored, however old, and never revert on age. Code that reads price and never looks at publishTime is trusting a number that might have been produced seconds ago — or hours ago, if the publisher set stalled, the chain was congested, or nobody paid to push an update. This is the Pyth counterpart of the staleness check a Chainlink consumer owes latestRoundData (see unchecked Chainlink price), and the sibling of reading a Pyth price without its confidence interval.
Vulnerable pattern
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
library PythStructs {
struct Price {
int64 price;
uint64 conf;
int32 expo;
uint256 publishTime; // when this price was produced
}
}
interface IPyth {
// "Unsafe": returns the stored price regardless of age, never reverts
function getPriceUnsafe(bytes32 id)
external view returns (PythStructs.Price memory);
}
contract Vault {
IPyth public pyth;
constructor(IPyth _pyth) { pyth = _pyth; }
// BUG: reads `price` and ignores `publishTime`. If the feed stops
// updating, this keeps valuing collateral at a quote that may be
// minutes or hours old while the market has already moved.
function valueOf(bytes32 id, uint256 amount) external view returns (uint256) {
PythStructs.Price memory p = pyth.getPriceUnsafe(id);
return amount * uint256(uint64(p.price));
}
}Picture a lending market pricing collateral through this valueOf. Updates flow normally and everything looks fine. Then the feed goes quiet — a publisher outage, a congested block, or simply no one submitting an update during a fast move — and the last stored price freezes while the real market drops. valueOf keeps returning the frozen number, so a borrower can draw against collateral marked well above what it is now worth, or a liquidation fires (or fails to fire) against a price nobody should be acting on, and the protocol absorbs the gap as bad debt. The single datum that would have said “this quote is old, do not trust it” is publishTime, and it was never read.
The fix
Read publishTime and make the price prove it is recent before using it: revert when it is older than a maximum age you choose. The example below subtracts publishTime from the current block time and requires the gap to stay within a cap, which is enough for the detector to report zero findings because the timestamp is now part of a real check.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
// ... PythStructs and IPyth as above ...
contract Vault {
IPyth public pyth;
uint256 public constant MAX_AGE = 60; // seconds a price may be stale
constructor(IPyth _pyth) { pyth = _pyth; }
// FIX: reject a price older than MAX_AGE instead of acting on a
// quote the feed has not refreshed.
function valueOf(bytes32 id, uint256 amount) external view returns (uint256) {
PythStructs.Price memory p = pyth.getPriceUnsafe(id);
require(block.timestamp - p.publishTime <= MAX_AGE, "stale price");
return amount * uint256(uint64(p.price));
}
}Better still, let Pyth do the staleness check for you: getPriceNoOlderThan(id, age) returns the price only if it is within age seconds and reverts otherwise, so the freshness guarantee is atomic with the read. The detector deliberately does not flag the NoOlderThan functions, because the age check is built into them — switching to one is both the simplest fix and the one Pyth recommends. Combine freshness with a positive-price check and the confidence-band check from the companion page, and the rule that avoids the class is plain: a Pyth price is only usable together with the time it was produced; if your contract never reads that time, it cannot know the price is still real.
A note on scope
Slither’s pyth-unchecked-publishtime detector fires when a value returned by an IPyth price call — getPrice, getPriceUnsafe, getEmaPrice or getEmaPriceUnsafe — is used without the publishTime field ever being read into a comparison on a path after the call. The getPriceNoOlderThan and getEmaPriceNoOlderThan variants are excluded by design, since they revert on staleness themselves. It runs at high confidence because the shape is clear-cut, but it is deliberately shallow in one direction: it counts the time as “checked” only when the publishTime member feeds directly into a comparison. We verified that a freshness check which first casts it (for example uint256(p.publishTime) + MAX_AGE >= block.timestamp) still trips the detector even though it is a perfectly good check — so a finding on a contract that validates age through a cast or a helper is a false positive; read it in context. The reverse limit holds too: the detector confirms only that you read publishTime in some comparison, not that your age bound is sensible, and it keys on the IPyth interface name, so a feed consumed through a differently named wrapper will not be matched. Treat a clean result as “no obvious unchecked Pyth staleness”, not as proof the oracle handling is sound.
Scan your contract for this
pactlint flags pyth-unchecked-publishtime 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.
An unchecked Pyth publish time is reported by Slither’s stock
pyth-unchecked-publishtime detector (“Pyth unchecked publishTime”),
rated medium impact at high 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: the Vault contract above fires pyth-unchecked-publishtime at medium impact and
high confidence (“Pyth price publishTime field is not checked in Vault.valueOf(bytes32,uint256)”);
adding the require(block.timestamp - p.publishTime <= MAX_AGE) check clears it to zero findings, and a contract that reads
the feed through getPriceNoOlderThan is not flagged. 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.