Для разработчиков / Реальные активы

Чтение цены

Четыре поверхности отвечают на один и тот же вопрос. Обычно вам нужен адаптер фида, потому что это AggregatorV3Interface из Chainlink и ваш существующий код уже умеет с ним работать.

Из контракта

Адаптер - это AggregatorV3Interface из Chainlink

The adapter implements AggregatorV3Interface with the same five functions and the same signatures, so existing code that reads a Chainlink feed compiles and runs against it unchanged. Point your consumer at the feed's adapter and it works.

Существуют две формы, и они держат одни и те же раунды. Реестр индексирует каждое чтение по feedId; адаптер на каждый фид закрепляет один feedId за адресом, так что потребителю, написанному под Chainlink, не нужно знать об идентификаторах фидов ничего.

solidity
// The adapter: what an existing Chainlink consumer already calls.
AggregatorV3Interface feed = AggregatorV3Interface(adapter);
(, int256 answer,, uint256 updatedAt,) = feed.latestRoundData();
uint8 decimals = feed.decimals();          // 8 on every feed today - read it anyway

// The registry: one address for all 33 feeds, keyed by the hash of the symbol.
bytes32 feedId = keccak256(bytes("XAU/USD"));
(, int256 gold,, uint256 goldUpdatedAt,) = registry.latestRoundData(feedId);
uint8 state = registry.status(feedId);     // 1 open, 2 closed
uint64 observedAt = registry.latestObservedAt(feedId);

Шесть вещей, которые потребитель Chainlink должен знать, прежде чем на это полагаться:

  • One adapter per feed, holding no data: it stores the registry address and its own feedId and forwards every call. Rounds live in the registry.
  • startedAt equals updatedAt. Every round is a single transmission, as an OCR feed reports it, and answeredInRound equals roundId because no round is ever carried over.
  • updatedAt is the block timestamp of the publishing transaction, not the time the price was observed. The observation time is observedAt(feedId, roundId) on the registry, which usually LEADS updatedAt - that is why it is kept off the Chainlink tuple, where startedAt <= updatedAt is assumed.
  • The adapter emits no events. A consumer watching AnswerUpdated on it sees nothing; the registry emits FeedUpdated(feedId, ...) instead, and feedId is indexed.
  • version() returns 2, which is this layer's version and not a Chainlink aggregator version.
  • latestRoundData() on a feed that has never been written reverts with Error("No data present"), bubbled from the registry unchanged. The legacy latestAnswer() does not revert - it reads the ring at index 0 and returns 0, which is exactly the zero a consumer must not treat as a price.

Потребителю, который проверяет всё сам, нужен реестр, а не адаптер, потому что статуса и времени наблюдения в кортеже Chainlink нет:

solidity
interface IPickleFeeds {
    function latestRoundData(bytes32 feedId)
        external view returns (uint80, int256, uint256, uint256, uint80);
    function status(bytes32 feedId) external view returns (uint8);
    function decimals(bytes32 feedId) external view returns (uint8);
}

uint8 constant STATUS_OPEN = 1;
uint8 constant STATUS_CLOSED = 2;

/// maxAge is YOUR tolerance, in seconds, and it only applies while the market is open.
function priceOf(IPickleFeeds registry, bytes32 feedId, uint256 maxAge)
    internal view returns (int256 answer, uint8 decimals)
{
    uint256 updatedAt;
    // Reverts with Error("No data present") when the feed has never been written.
    (, answer,, updatedAt,) = registry.latestRoundData(feedId);
    require(answer > 0, "feed: non-positive answer");

    uint8 state = registry.status(feedId);
    require(state == STATUS_OPEN || state == STATUS_CLOSED, "feed: status unknown");
    // A closed market's last print is the right price for as long as it is closed.
    if (state == STATUS_OPEN) {
        require(block.timestamp - updatedAt <= maxAge, "feed: stale");
    }
    decimals = registry.decimals(feedId);
}

Чтения реестра

  • latestRoundData(bytes32 feedId) returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
  • getRoundData(bytes32 feedId, uint80 roundId) returns (uint80, int256, uint256, uint256, uint80)
  • latestAnswer(bytes32 feedId) returns (int256)
  • latestTimestamp(bytes32 feedId) returns (uint256)
  • latestRound(bytes32 feedId) returns (uint256)
  • observedAt(bytes32 feedId, uint80 roundId) returns (uint64)
  • latestObservedAt(bytes32 feedId) returns (uint64)
  • status(bytes32 feedId) returns (uint8)
  • source(bytes32 feedId) returns (uint8)
  • decimals(bytes32 feedId) returns (uint8)
  • description(bytes32 feedId) returns (string)
  • symbol(bytes32 feedId) returns (string)
  • feed(bytes32 feedId) returns (FeedView)
  • feeds(uint256 offset, uint256 limit) returns (FeedView[])
  • feedCount() returns (uint256)
  • feedIdAt(uint256 index) returns (bytes32)
  • adapterOf(bytes32 feedId) returns (address)
  • adapterAddress(bytes32 feedId) returns (address)
  • version() returns (uint256)

Чтения адаптера

  • decimals() returns (uint8)
  • description() returns (string)
  • version() returns (uint256)
  • latestRoundData() returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
  • getRoundData(uint80 roundId) returns (uint80, int256, uint256, uint256, uint80)
  • latestAnswer() returns (int256)
  • latestTimestamp() returns (uint256)
  • latestRound() returns (uint256)
  • symbol() returns (string)

Ни один из двух адресов на этом сайте не напечатан. Определение адресов объясняет, как получить их из сети.

Ответ и его десятичные знаки

Ответ - это целое число с фиксированной точкой, не число с плавающей точкой и не wei. Каждый фид генезисного набора несёт decimals = 8, поэтому ответ 415012345678 - это 4150.12345678. Поле допускает от 0 до 18 и задаётся отдельно для каждого фида: читайте его из фида, а не зашивайте 8 в свой потребитель.

javascript
// answer arrives as a decimal string, because it does not fit a JS number safely.
const { answer, decimals, price } = result;         // "415012345678", 8, "4150.12345678"
const scaled = BigInt(answer);                       // keep it integral
const human = Number(scaled) / 10 ** decimals;       // only for display

// price is the same number pre-formatted with exactly `decimals` digits.
// Never parseFloat(answer) and never compare two feeds without rescaling both.

Ноль - не цена

Контракт отказывает в неположительном ответе, поэтому ноль никогда не доходит до раунда. Значит, прочитанный вами ноль пришёл откуда-то ещё: из унаследованного latestAnswer() на фиде, в который никогда не писали, или из вызова к адресу без кода, который завершается успешно и не возвращает ничего. Проверяйте roundId != 0 или используйте latestRoundData(), который вместо этого делает revert.

По JSON-RPC

Три метода, все - простые чтения без ключа и без аккаунта. Они перечислены вместе со всеми остальными в справочнике JSON-RPC; дальше идёт то, что они отвечают для этого слоя.

Params
none
Возвращает
{ core, version, blockNumber, timestamp, publisher, owner, genesisStateHash, feeds: [FeedObject] }

The whole registry in one read, and the answer to "what assets are there". The node reads feeds(offset, 256) against the published state snapshot once per EVM block and memoises it, so a client polling the list costs about what one eth_call costs.

Params
[feed] - "ETH/USD", "ETH-USD", "eth-usd" or "0x<64 hex feedId>"
Возвращает
one FeedObject, the shape pickle_prices lists

One feed. A name that is not a well-formed symbol or a 32-byte id, or one the chain does not carry, answers -32602 "unknown feed" - never an empty answer that reads as a price of zero.

Params
none
Возвращает
{ configured, pair, contract, roundId, answer, price, decimals, updatedAt, observedAt, ageSecs, staleAfterSecs, staleOnchain, feedId, adapter, core }

ETH/USD alone, in the shape the first version of this layer served, with contract set to the feed's adapter. When the feed cannot be read it answers an object carrying an error field rather than failing, so a caller must check for it.

bash
curl -s $RPC_URL -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"pickle_price","params":["AAPL-USD"]}'

# -> {"jsonrpc":"2.0","id":1,"result":{
#      "symbol":"AAPL/USD","feedId":"0x...","adapter":"0x...","decimals":8,
#      "roundId":184,"answer":"23144000000","price":"231.44000000",
#      "updatedAt":1758470400,"observedAt":1758470396,
#      "status":2,"statusName":"closed","source":2,"sourceName":"pyth",
#      "ageSecs":51840,"staleAfterSecs":330,"staleOnchain":false, ... }}
#
# staleOnchain is FALSE at fourteen hours old, because the market is closed.

Объект фида

ПолеValue
symbol"ETH/USD"the canonical spelling
feedId0x + 64 hexkeccak256 of the symbol
adapterchecksummed addressthis feed's Chainlink-shaped face; the one to point a consumer at
decimals8read it rather than assuming it
roundIduint0 means never written. answer, price, updatedAt and observedAt are then null
answerinteger as a stringthe fixed-point integer. 415012345678 at 8 decimals is 4150.12345678
pricedecimal stringthe same number formatted with exactly `decimals` fractional digits. A convenience, never a float
updatedAtunix secondsthe timestamp of the block that wrote the round
observedAtunix secondswhen the source saw the price. Usually LEADS updatedAt
status / statusName0 unknown, 1 open, 2 closed, 3 halted3 is reserved and never published. Check this before you check the age
source / sourceName0 unknown, 1 exchanges-median, 2 pythwhich method produced this round
ageSecsnow - updatedAtwall clock at the request, not at the seal. null on an unwritten feed
staleAfterSecs30, or heartbeat + 30flat on an everyBlock feed; a deviation feed gets its heartbeat plus the default, so a feed that has not moved is not stale until it has missed a heartbeat
staleOnchainbooleanageSecs > staleAfterSecs AND the status is not closed. An unwritten feed is always true
policy / policyName0 paused, 1 everyBlock, 2 deviationwith deviationBps, heartbeatSecs and maxStepBps beside them
capacityring lengthhow many rounds back getRoundData can still reach
Эти чтения расходуют бюджет дорогих чтений

All three methods run on the blocking pool and draw on the same token bucket as eth_call, eth_estimateGas and eth_getLogs: they simulate against the state snapshot. Exhausting it answers -32005 "rate limit exceeded for expensive read methods".

The registry read is memoised on the block tip, so the first caller in a block pays for the catalog decode and the rest do not. Leader and replica answer identically because both read the published snapshot rather than live execution state.

Из сервиса оракула

Этот API не опубликован на публичном хосте

The oracle service answers on port 3005 for reads and 3006 for the batch the node polls. The deployment binds 3005 to loopback and leaves 3006 unpublished, and no public vhost proxies either one.

Treat the JSON-RPC price methods as the public read surface. The routes below matter when you run the stack yourself, and when you need the per-venue detail the chain does not carry.

Сверх сети он добавляет ход вычисления: какая площадка была свежей, какая вошла в медиану, насколько далеко от неё стояла каждая и почему фид не публикует. Когда цены нет, причина находится здесь.

/v1/prices

GET

Every feed's FeedView, with generatedAtMs. The service-side equivalent of pickle_prices, plus the per-source detail: which venue was fresh, which was included in the median, each one's age, spread and delivery lag.

/v1/price/{symbol}

GET

One feed. The symbol may be ETH/USD, ETH-USD, eth-usd, the percent-encoded slash, or 0x + 64 hex. Anything else, or a feed that does not exist, answers 404 {"error":"unknown feed"}.

/v1/stream

GET

Server-sent events. An immediate prices event with every feed, then one per change carrying only the feeds that changed, plus an onchain event per new block listing the feeds whose round advanced. Bursts are coalesced and a keepalive comment is sent when nothing has moved.

/v1/eth-usd and /v1/eth-usd/stream

GET

The ETH/USD view and its stream in the shape the first version of this service served, kept for its consumers. The same data as /v1/price/ETH-USD under the original key names.

/v1/sources

GET

Operational rather than price data: every venue with its connection state, per-product message counts, reconnects and last message time, the Pyth client's state and per-feed accept and reject counters, and the chain poller's own status. This is where you look when a feed is stale and you want to know which leg failed.

/v1/contract

GET

The registry's address, owner, publisher and feed count as read from the chain, the two ABIs when the build artefacts are present, every feed's adapter, and the event topic hashes - recomputed locally so they are served even without the ABI files.

/v1/pyth/audit/{symbol}

GET

The raw accepted updates for one feed, up to 256 deep, with their publish times and slots, plus the reject counters by reason. The audit trail behind a price.

/health and /ready

GET

/health is 200 unless a worker thread has died. /ready is 503 while starting, when no feed is fresh, when the chain is unreachable or the registry has gone stale on chain - and 200 with a pyth_unauthorized warning when the credential is missing, because that is a degraded service rather than an unready one.

Два порта, две аудитории. API чтения отвечает на 3005; пакет, который опрашивает узел, - это отдельный слушатель на 3006, несущий HMAC точных байтов тела в X-Pickle-Batch-Hmac, а запрос /v1/publish к публичному слушателю получает 404, вместо того чтобы признать существование маршрута. Каждое тело ошибки - фиксированная строка: ни пути, ни источника выше по цепочке, ни текста исключения.

Что вид фида несёт сверх сети

ПолеValue
stale + reasonboolean and a codeno_sources, too_few_sources, starting, market_closed, cross_check_diverged, or a Pyth reason - state, age, lag, no_data
market{ isOpen, schedule, hermesIsOpen, nextOpen, nextClose, nextTransition }present on all 33 feeds, since every one carries a Pyth id; null only on a feed configured without one. The schedule is the raw schedule string
sourcesone row per venue, plus the Pyth rowprice, kind (mid or last), observedAtMs, ageMs, fresh, included, spreadBps, lagMs, state
degradedbooleanthe median was taken on exactly minSources venues; one more drop-out and the feed stops publishing
dispersed + dispersionBpsboolean and an integerthe spread between the included venues, and whether it exceeded the limit even after the outlier was dropped
outlierDroppedvenue name or nullwhich venue was excluded from this median
crossCheckBps + crossCheckDivergedinteger and booleanhow far the exchange median sits from the Pyth price for the same feed
lastKnown{ price, observedAtMs, ageMs } or nullthe last good answer while the feed is stale. Shown for diagnosis - it is not republished on chain
onchainthe chain's own row for this feedpolled once a second, so the service can show what actually landed beside what it computed

Устаревание

Читайте статус раньше возраста

Read status first, then staleness. A closed market's last print is the correct price for as long as the market is closed, and a naive "reject anything older than N minutes" check rejects every equity every weekend.

СлучайПравило
everyBlock feedstale after 30 sORACLE_STALE_AFTER_SECS on the node, matching the service's setting of the same name
deviation feedstale after heartbeat + 30 s90 s on the crypto feeds, 330 s on every real-world feed. A feed that has not moved is not late
status closednever stale by agestaleOnchain stays false while the market is closed, however old the round is
roundId 0always stalenever written. answer and price are null, and the adapter's latestRoundData reverts

staleOnchain - это узел, применяющий эти правила за вас, по настенным часам вашего запроса, а не момента запечатывания. Это удобство: у потребителя внутри контракта такого поля нет, и он должен сравнивать updatedAt со своим собственным допуском - именно эту проверку делает пример выше.

updatedAt - честный сигнал в любом случае, потому что он просто перестаёт двигаться, когда публикация прекращается. Никакой ончейн-проверки возраста, на которую можно опереться, нет: There is deliberately no on-chain age check on observedAt. The node replays its write-ahead log with the restart-time timestamp, so a rule of the form block.timestamp - observedAt <= maxAge would fail every replayed update. Freshness is enforced by the publisher, which does not submit a stale aggregate, and by you, through updatedAt - which simply stops advancing when publication stops.

Сервис сообщает reason рядом со stale, и эти причины стоит обрабатывать по отдельности: too_few_sources означает, что площадки выпали, cross_check_diverged означает, что площадки и второй источник расходятся и фид молчит намеренно, market_closed означает, что ничего плохого не происходит вовсе. Как возникает каждая из них - на странице Как формируется цена.

Часы работы рынков

Each feed with a Pyth leg carries a schedule: a time zone, seven day patterns starting Monday, and a list of dated overrides for holidays and half-days. It is resolved against the real time-zone database, so a market opens at 09:30 local whatever the daylight-saving offset that day.

РазделГрамматика
Time zoneAmerica/New_Yorkthe first section; every schedule in the catalog uses it
A dayO, C, or HHMM-HHMMO is the whole day, C is closed. Ranges are half-open and joined with & for a day with a break
OverridesMMDD/speca dated exception using the same day grammar - 1225/C for a holiday, 1127/0930-1300 for a half-day
ФидыРасписание
Crypto and the energy indicesO,O,O,O,O,O,Oopen every day, all day, with no overrides
The nine equities0930-1600 Monday to Friday, C,C at the weekendwith the exchange holiday and half-day list as overrides
EUR/USD, GBP/USD, USD/JPY, USD/CHFcontinuous to 17:00 Friday, closed Saturday, reopening 17:00 Sundaytwo dated overrides shorten the sessions around the end of December
XAU/USD, XAG/USD, USD/CNHa daily 17:00-18:00 break, closed Saturday, reopening Sunday eveningthe metals carry the longer holiday list

The upstream also reports whether the market is open right now. That flag wins while its poll is fresh and no scheduled transition has passed since - it knows about a halt or an unlisted holiday that the schedule does not. Otherwise the schedule decides.

The status machinery applies to a feed whose price comes from the second source, which is every real-world feed. A feed publishing an exchange median reports status open, and that is consistent rather than a gap: all thirteen of them are crypto pairs whose schedule is open every day, all day.

Закрытый рынок - не устаревший фид

The price that stands while a market is closed is the one seen at the close, never anything republished afterwards. It is published with status closed and reason market_closed, and it is republished on a one-hour heartbeat rather than on the feed's normal cadence.

Барьер повторного открытия. Going from closed back to open needs one accepted update whose publish time is later than the newest one seen up to the reopen. Until that arrives the feed keeps reporting closed and the standing price, so a stale pre-close print is never labelled as an open-market quote. A restart while the market is closed adopts the first accepted update as the standing price, because there was no close to observe.

Разрывы ожидаемы. The first print after a reopen legitimately gaps. The contract's step check is bypassed whenever the status changed, so that print lands instead of being skipped as an implausible move.

Для потребителя это значит три вещи. Фид акции может законно оставаться неизменным двое с половиной суток, не будучи устаревшим. Наивная проверка свежести отвергает каждый фид реального актива каждые выходные и каждый из них каждую ночь. А позиция, которую нельзя ликвидировать по цене, которую сейчас никто не выставляет, должна читать status и отказываться действовать, пока он равен 2, вместо того чтобы действовать по стоящей цене - верной, но неподвижной.

javascript
const feed = result;                       // one feed object

if (feed.roundId === 0) throw new Error("never written");
if (feed.statusName === "unknown") throw new Error("status not established");

const closed = feed.statusName === "closed";
if (!closed && feed.staleOnchain) throw new Error("stale while open");

// closed === true is a valid, current price for a market that is not trading.
// Decide deliberately whether your product acts on it.

Раунды, история и пропуски

Rounds live in a fixed-size ring per feed rather than an append-only history. Round r sits at index r % capacity, is readable while latestRound - r < capacity, and is overwritten afterwards.

getRoundData on an evicted round reverts with "No data present", not with a zero answer. A feed with capacity 1,024 keeps its last 1,024 rounds and no more, so an indexer that wants full history reads the FeedUpdated logs rather than paging the ring.

updateMany never reverts on a bad item. One stale or out-of-range feed in a batch of sixty must not cost the other fifty-nine their round, so a bad item is skipped with a FeedSkipped(feedId, reason) log and the rest are written. Only a malformed batch - mismatched array lengths, over maxBatch, or a sender that is not the publisher - reverts.

ПричинаЗначение
1unknown feedno feed registered under that id
2pausedthe feed's policy is paused
3answer out of rangenot strictly positive, or wider than int128
4observedAt in the futurebeyond block.timestamp + futureTolerance
5observedAt regressedolder than the feed's last observation; the round would go backwards in time
6step too largea move wider than the feed's maxStepBps - 2,000 bps on the equities and energy indices, 1,000 on the metals, 500 on FX, disabled on everything crypto. Bypassed whenever the status changed, so a reopening gap still lands

Пропущенный элемент виден только как лог FeedSkipped: раунд не продвигается, а предыдущий ответ остаётся в силе. Поэтому потребителю, следящему за одним фидом, следует следить за FeedUpdated с идентификатором фида в индексированном топике - это событие, которого адаптер не испускает, - и трактовать долгую тишину как то, чем она является, а не ждать ошибки, которая никогда не придёт.