Integrating a feed
The exact read pattern for Robinwood Stocks Feeds — resolve from the registry, handle the two no-price cases, bound freshness yourself, and the anti-patterns that will hurt you.
Everything on this page follows from three verified facts about the
adapters: they implement only latestRoundData(), they answer
USD at 8 decimals, and they refuse to answer rather than guess
(updatedAt = 0 for an uncovered window, a revert for a stale upstream).
If you remember nothing else: treat both refusals as "no price right
now", never as an invitation to substitute another source.
Resolve the feed from the registry
Do not hardcode feed addresses. The AssetRegistry is the canonical
directory, and the feed it returns for an asset is frozen forever:
interface IAssetRegistry {
function assetOf(address token)
external view returns (address feed, uint32 heartbeatSeconds, bool enabled);
function isRegistered(address token) external view returns (bool);
}
IAssetRegistry constant REGISTRY =
IAssetRegistry(0x53B255bff87450979c459cC91aFA47A3B93f81fb);enabled == false means the asset was delisted inside Robinwood Stocks (new buys
blocked). The feed keeps answering; whether a delisted asset is still
acceptable to your protocol is your decision, and you should make it
explicitly.
The read pattern
interface IRobinwoodFeed {
function latestRoundData() external view
returns (uint80, int256 answer, uint256, uint256 updatedAt, uint80);
}
/// Reverts when no trustworthy price exists. That is the point:
/// bubble the failure up, do not paper over it.
function priceUsd8(address token, uint256 maxAgeSeconds) view returns (uint256) {
(address feed,,) = REGISTRY.assetOf(token);
require(feed != address(0), "unregistered asset");
// Case 1: the read itself reverts (stale/dead upstream). Let it revert.
(, int256 answer,, uint256 updatedAt,) = IRobinwoodFeed(feed).latestRoundData();
// Case 2: uncovered window. The adapter says "no price"; so do you.
require(updatedAt != 0, "no covered window");
// Sanity plus YOUR OWN freshness bound. The adapters have no heartbeat:
// updatedAt is the newest observation, and its cadence is best-effort.
require(answer > 0, "bad answer");
require(block.timestamp - updatedAt <= maxAgeSeconds, "too old for us");
return uint256(answer); // USD, 8 decimals
}Choosing maxAgeSeconds: observations land roughly every five minutes
while the keeper runs, and the v4 window is one hour deep. Bounds tighter
than ~10 minutes will flap on normal operation; bounds looser than a few
hours defeat the purpose. Start at 3,600 and reason from your own risk.
The same pattern off-chain (viem):
const feedAbi = parseAbi([
"function latestRoundData() view returns (uint80,int256,uint256,uint256,uint80)",
]);
const [, answer, , updatedAt] = await client.readContract({
address: feed, abi: feedAbi, functionName: "latestRoundData",
}); // throws on stale upstream — catch it as "no price", not as "use plan B"
if (updatedAt === 0n || answer <= 0n) throw new Error("no price");
const usd = Number(answer) / 1e8;Anti-patterns
Each of these has a body count elsewhere in DeFi. In order of how much they will hurt:
- Falling back to pool spot when the feed refuses. The refusal exists because the honest answer is unknowable right now; spot is the manipulable number the TWAP protects you from. A fallback converts your safest moment into your most attackable one.
- Catching the revert and reusing the last price you saw. Same failure in a different coat: you are now the stale feed.
- Using a thin-pool feed for liquidations. Check the feed's manipulation cost first — today every native feed is thin by our own $100k threshold, most of them by two orders of magnitude. Robinwood Stocks's own consumers are capped by contract-level slippage budgets; your liquidation engine is not.
- Calling
decimals()/getRoundData(). Not implemented; the call reverts. Answers are 8-decimal USD by construction, and there is no round history —roundIdis a constant1. - Assuming a heartbeat. There is none.
updatedAtmoves when someone pokes an observation; bound the age yourself. - Hardcoding a feed address across assets. Resolve through the registry per asset. The one thing you MAY cache forever is the mapping itself: it is frozen at registration.
Running your own keeper
Coverage is permissionless. If your integration depends on these feeds, you can remove the single-keeper dependency yourself:
- v4 observer:
PoolTwapObserver.poke(poolKey)at0x4E03522f038F4d0dEA65e72a8A1eD22c06d05AAErecords one observation per pool per second at most; ~6 pokes spread over an hour keep a window alive (minObs = 6). - v2 adapters: each feed's
poke()checkpoints the pair's cumulative price, rate-limited to one perminWindow / 4(450 seconds).
Both are plain unauthenticated transactions costing dust-level gas. A reference keeper implementation ships with the protocol's public repo.
Requesting a feed
A feed and an asset listing are the same act: registration in the
AssetRegistry, after a one-time vetting that the token is canonical and
verified, the pool has enough depth and trading continuity to keep a TWAP
window covered, and the token's admin surface (transfer tax, blacklist,
mint authority) is disclosed. Because the feed is frozen at registration,
the vetting happens once and cannot be revisited — which is why it is
deliberate. Open an issue on the public repo with the token, the pool,
and those disclosures; decisions come with reasons.
Robinwood Stocks Feeds
The chain-native price layer on Robinhood Chain, published as public infrastructure — TWAP adapter feeds for every pool-priced asset the registry knows.
Manipulation cost
What moving each Robinwood Stocks feed actually costs, computed from live pool state and published openly — model, formulas, assumptions, and the honest verdict that today every native feed is thin.