Robinwood StocksDocs
Feeds

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 — roundId is a constant 1.
  • Assuming a heartbeat. There is none. updatedAt moves 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) at 0x4E03522f038F4d0dEA65e72a8A1eD22c06d05AAE records 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 per minWindow / 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.

On this page