Kaskad

Oracle API Reference

This page is for developers who use Kaskad prices directly: integrators, liquidators, bots, and reviewers. Oracle explains how prices are produced. Enclave Verification shows how to check the enclave that signs them.

Model

Nuntius is a pull oracle. An AWS Nitro Enclave fetches prices from public sources, aggregates them, and signs each result with a key that exists only inside enclaves. Anyone can fetch the signed prices over HTTPS. A price reaches the chain only when a transaction submits it to the oracle contract, which checks the signature.

Public APIs
Price sources
TLS
AWS Nitro Enclave
Nuntius
  • fetch, aggregate, sign
HTTPS
Your code
App or bot
  • GET /prices
tx
On-chain
KaskadPriceOracle.updatePrice
  • or KaskadRouter.*WithPrices
  • updatePrice is permissionless. The contract stores a price only if it is signed by a registered enclave key, newer than the stored price, and within the asset's change limit.
  • Prices are not pushed on a schedule. An on-chain price is as old as the last transaction that submitted one. A transaction that depends on a price should submit a fresh one first. KaskadRouter does this for borrows, withdrawals and liquidations.
  • The HTTP service runs on the host instances, outside the enclave. A host can refuse or delay requests, or serve an older signed price. It cannot produce a valid signature.

Deployments

NetworkPrice APISignerAssets
Robinhood Chain (4663)https://nuntius.kaskad.live0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c912
Igra (38833)https://kaskad.live/enclave0x544705F5d72E1C6f24bFD7C2E176F2Cd2aA8Dc2A7
Igra (38833)https://kaskad.live/enclave-eu0xD35e55c7C2d10472e6d9E7132c88BEeD347D91A77
  • Robinhood Chain has one signer. Requests are served from the US and fail over to Europe, and both regions sign with the same key.
  • Igra has two independent enclaves, one in the US and one in Europe, each with its own key. Both signers are registered on Igra, so a price from either is accepted.
  • A signer is valid only on the network where it is registered. A price from the Robinhood Chain API is rejected on Igra, and the other way round. isValidSigner(address) on the oracle tells you whether a signer is current.

Quick Start

curl -s 'https://nuntius.kaskad.live/prices/ETH%2FUSD'
{
  "price": {
    "asset_id": "0x0b43555ace6b39aae1b894097d0a9fc17f504c62fea598fa206cc6f5088e6e45",
    "asset_symbol": "ETH/USD",
    "price": "245070790020",
    "price_human": "2450.70790020",
    "timestamp": 1792577431,
    "num_sources": 10,
    "sources_hash": "0x54f96140454ba272dbcd7191a321fc969490b7c59716232a40cc040bbd47f04f",
    "signature": "0x8bebaa400ffbf50964e80a8f8d29f9a4cbfdb17a137db7fe2f06baa25c359cf127538bced6992106e021f2bdaef8a9ef13be4add7d412b5584bc57a77b5f1aaa1c",
    "signer": "0xe4d88be9756eb3fc5bd28b81642605fbf238d2c9"
  },
  "signer": "0xe4d88be9756eb3fc5bd28b81642605fbf238d2c9"
}

price is USD × 10^8 as a decimal string, so this response says 2,450.70790020 USD. To put it on-chain, pass the fields of price to updatePrice, or to KaskadRouter as one PriceUpdate.

HTTP API

The same API runs at every base URL in Deployments.

  • Methods. GET only. HEAD and OPTIONS return 501.
  • Browsers. Responses carry Access-Control-Allow-Origin: *. CORS preflight is not supported, so send plain GET requests without custom headers.
  • Caching. Responses are not cached.
  • Rate limit. About 60 requests per minute per client IP. X-RateLimit-Remaining shows what is left. Over the limit, the API returns 429 with retry_after in seconds.
  • Symbols. URL-encode the slash: /prices/ETH%2FUSD. The unencoded /prices/ETH/USD also works, and symbols are case-insensitive. Query strings and a trailing slash are ignored.

GET /prices

All signed prices of the deployment:

{
  "num_assets": 12,
  "prices": [
    { "asset_id": "0xa839…9db4", "asset_symbol": "PONS/USD", "price": "28176289", "price_human": "0.28176289", "timestamp": 1792577423, "num_sources": 3, … },
    …
  ],
  "signer": "0xe4d88be9756eb3fc5bd28b81642605fbf238d2c9"
}

The status is 200 when at least one price is available, otherwise 503 with an error field. Look prices up by asset_symbol or asset_id; their order is not defined.

GET /prices/SYMBOL

One asset, for example /prices/ETH%2FUSD. The response is { "price": …, "signer": … }, as in Quick Start. An unknown symbol returns 404 {"error":"asset 'FOO/USD' not found"}.

Price Object

FieldTypeMeaning
asset_idstringkeccak256 of asset_symbol, 32 bytes. The oracle's key for the asset
asset_symbolstringFor example ETH/USD. All prices are in USD
pricestringPrice × 10^8 as a decimal integer. Parse it as a big integer
price_humanstringThe same price with 8 decimals. For display only
timestampnumberUnix seconds: the median of the Date headers that the price sources returned with the quotes used. It is not taken from the enclave or host clock
num_sourcesnumberSources in the final aggregate, after outliers were removed
sources_hashstringCommitment to the sources used and their prices. See Signature Format
signaturestring65 bytes, r ‖ s ‖ v, with v 27 or 28
signerstringAddress of the signing key, lowercase. Recover the signer from the signature instead of trusting this field

GET /health

{ "status": "ok", "signer": "0xe4d88be9756eb3fc5bd28b81642605fbf238d2c9", "num_assets": 12, "attestation_healthy": true }
FieldMeaning
statusok when the enclave answers. It says nothing about how old the prices are; check timestamp for that
signerAddress of the enclave key
num_assetsNumber of assets the enclave currently has a price for
attestation_healthyfalse when the enclave cannot obtain an attestation document. The API then refuses to serve prices

The response carries further diagnostic fields. They are not part of the API and can change.

GET /attestation

{ "signer": "0xe4d88be9756eb3fc5bd28b81642605fbf238d2c9", "attestation_doc": "8444a101…" }
  • attestation_doc is the AWS Nitro attestation document (COSE_Sign1) in hex, without a 0x prefix. Its public_key is the signer key, and PCR0 identifies the enclave image.
  • The same document is served for up to 5 minutes. This endpoint takes no nonce.
  • Enclave Verification shows how to check it.

GET /

Describes the service:

{"service":"Kaskad TEE Oracle","version":"0.1.0","endpoints":["/prices","/prices/{SYMBOL}","/health","/attestation"],"docs":"Query signed price data from Nitro Enclave"}

Errors

StatusBodyMeaning
404{"error":"not found"}Unknown path
404{"error":"asset 'FOO/USD' not found"}Unknown symbol
429{"error":"rate limit exceeded","retry_after":60}Too many requests. Wait retry_after seconds
501HEAD or OPTIONS request
503{"error":"enclave not running"}, {"error":"enclave timeout"} or {"error":"enclave error: …"}The enclave is unavailable or restarting. Retry
503{"error":"server overloaded, retry"}Too many concurrent requests. Retry
503{"error":"Attestation document unavailable — …","attestation_healthy":false}The enclave cannot prove its identity, so it refuses to sign

Signature Format

Each price is signed with EIP-191 (personal_sign) over a 32-byte digest of five fields. The oracle contract checks it like this:

bytes32 digest = keccak256(abi.encodePacked(assetId, price, timestamp, numSources, sourcesHash));
address signer = ECDSA.recover(MessageHashUtils.toEthSignedMessageHash(digest), signature);
BytesFieldType
0–31assetIdbytes32
32–63priceuint256
64–95timestampuint256
96numSourcesuint8
97–128sourcesHashbytes32
  • The packed message is 129 bytes long.
  • Signatures are deterministic (RFC 6979): the same five fields always give the same signature.
  • The message names no chain and no contract. Prices are kept apart by keys: each network's oracle accepts only the enclave keys registered on that network.
  • sourcesHash is keccak256 over each source used, in order: its name, the byte |, and its USD price as an 8-byte big-endian IEEE 754 double. The individual quotes are not published, so you cannot recompute it. Treat it as an identifier.

If you check prices in your own contract, test the recovered address with isValidSigner on the Kaskad oracle of the same network. Do not hard-code signer addresses; the set of registered signers can change.

Check a Signature With cast

This reproduces the check for the Quick Start response with Foundry cast:

# The asset id is keccak256 of the symbol.
cast keccak 'ETH/USD'
# 0x0b43555ace6b39aae1b894097d0a9fc17f504c62fea598fa206cc6f5088e6e45

# Pack the five fields (129 bytes) and hash them.
PACKED=$(cast abi-encode --packed 'f(bytes32,uint256,uint256,uint8,bytes32)' \
  0x0b43555ace6b39aae1b894097d0a9fc17f504c62fea598fa206cc6f5088e6e45 \
  245070790020 1792577431 10 \
  0x54f96140454ba272dbcd7191a321fc969490b7c59716232a40cc040bbd47f04f)
DIGEST=$(cast keccak $PACKED)
echo $DIGEST
# 0x8f77938db9d960326358a572b90c460fee37397c8cd5a1535f2594953996d792

# cast adds the EIP-191 prefix, recovers the signer and compares it.
cast wallet verify --address 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 $DIGEST \
  0x8bebaa400ffbf50964e80a8f8d29f9a4cbfdb17a137db7fe2f06baa25c359cf127538bced6992106e021f2bdaef8a9ef13be4add7d412b5584bc57a77b5f1aaa1c
# Validation succeeded. Address 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 signed this message.

Change any field and the last command prints Validation failed.

Contracts

ContractNetworkAddress
KaskadPriceOracleV2Robinhood Chain0xB4922b744cc395E05Cc962d55FA63FBab0599ec2
KaskadRouterRobinhood Chain0x75cbB7B191307F869f54479897D87B3a7C20bb0F
NitroAttestationVerifierV2Robinhood Chain0x52A22A678Af63d74BBfCB4c8fA90299db2760ACD
KaskadPriceOracleIgra0xB01993906D14792DC21B7EA75A971D8191AAE402
KaskadRouterIgra0xD22606268c0929AB299918eCf9c6E0eEA0977A0C
Attestation verifierIgra0x76216b3bde5bC81b04FA5A5d4a67F6AfACc666B9

The lending markets read the oracle through these feeds, which are the Aave price sources of the reserves:

NetworkReserveFeedAsset
IgraWiKAS0x538607bDdc28b215aB7db97360816194AF1085AdKAS/USD
IgraUSDC0x7ba76bFf6a9c7926eB118e5D879A6C3B620Ef8a7USDC/USD
IgraUSDT0x748891Af1C0AA0515AD73b9E3FC8C8cFcd0FFa24USDT/USD
IgraWETH0xd37Ff9c574c520aD988522709B43A4E800CAb34BETH/USD
IgracbBTC0xfd3a1ad2C2C73Fb54286E1932b3f05a6bCF18E9FBTC/USD
Robinhood ChainPONS0x724A8014c37De5f646E517a521A7C35D96AFB649PONS/USD

On Robinhood Chain, WETH, USDG, NVDA and TSLA use third-party aggregator feeds. Nuntius signs prices for those assets too, but the lending market does not read them. Each feed above returns its asset id from assetId(), so you can find the Nuntius-priced reserves on-chain: call getSourceOfAsset(reserve) on the market's AaveOracle, then assetId() on the source.

Source code: Kaskad-Lending/kaskad-nuntius-contracts. The deployed bytecode of KaskadPriceOracleV2 and NitroAttestationVerifierV2 on Robinhood Chain matches commit a840d6b. The deployed bytecode of KaskadPriceOracle and its attestation verifier on Igra matches commit 5d865b3.

KaskadPriceOracleV2 (Robinhood Chain)

updatePrice

function updatePrice(bytes32 assetId, uint256 price, uint256 timestamp, uint8 numSources, bytes32 sourcesHash, bytes calldata signature) external

Selector 0x1828041c. Anyone can call it. The checks run in this order:

#RequirementError
1At least one signer is registeredNoEnclaveRegistered()
2price is not zeroZeroPrice()
3The asset is registeredAssetNotRegistered(bytes32 assetId)
4numSources is at least the asset's minSourcesInsufficientSources()
5timestamp is at most block.timestamp + 7,200FutureTimestamp(uint256 provided, uint256 maxAllowed)
6timestamp is greater than the stored signedTimestampStalePrice(uint256 provided, uint256 current)
7The change from the stored price is at most maxChangeBps, or at most maxResumeChangeBps if the last update was 3,600 seconds or more ago. Not checked for an asset's first pricePriceChangeExceedsLimit(uint256 changeBps, uint256 maxBps)
8The signature recovers to a registered signerInvalidSignature()
  • The change is abs(new − old) × 10000 / old in basis points, rounded down.
  • A price further than maxResumeChangeBps from the stored price cannot be submitted. The stored price can move only within these limits.
  • On success the contract stores the price with timestamp set to block.timestamp and signedTimestamp set to the signed timestamp. It then increments the asset's round and emits PriceUpdated.

Reads

FunctionSelectorReturns
latestPrices(bytes32 assetId)0xdb7c1ad1price, timestamp (block time of the update), signedTimestamp, numSources, sourcesHash, roundId. All zero if there is no price
getLatestPrice(bytes32 assetId)0xb2ee2f01price, timestamp (block time of the update), numSources, roundId. Reverts NoPriceData(bytes32) if there is no price
getRoundData(bytes32 assetId, uint80 roundId)0xacaec0f2price, timestamp, numSources of a past round. Reverts NoRoundData(bytes32,uint80)
priceHistory(bytes32 assetId, uint80 roundId)0x4d49c8f7The latestPrices fields of a past round
currentRound(bytes32 assetId)0xb49a0461Latest round id
assetParams(bytes32 assetId)0x5b034f9bminSources, maxChangeBps, maxResumeChangeBps. All zero if the asset is not registered
registeredAssetIds()0xfdc317a0bytes32[]
isValidSigner(address)0xd5f50582bool
validSigner(address)0xba6f8b0ebool, the same value
signerCount()0x7ca548c61 today
expectedPcr0()0x62776303First 32 bytes of the PCR0 of the pinned enclave image
verifier()0x2b7ac3f3NitroAttestationVerifierV2

Constants: DECIMALS = 8, CIRCUIT_BREAKER_STALENESS = 3600, MAX_FUTURE_SKEW = 7200, MAX_CHANGE_BPS_CEILING = 5000, MAX_ASSETS = 32.

getLatestPrice and getRoundData return the block time of the update, not the signing time. To know how old a price is, read signedTimestamp from latestPrices.

Events

Eventtopic0
PriceUpdated(bytes32 indexed assetId, uint256 price, uint256 timestamp, uint8 numSources, uint80 roundId)0x0293bd20b29245932bb510252a29097c5c0578a1d0e040a864a123031ff433f1

The timestamp in PriceUpdated is the signed time, unlike the timestamp that getLatestPrice returns.

Errors

ErrorSelectorRaised by
NoEnclaveRegistered()0x34bdf075updatePrice
ZeroPrice()0x4dfba023updatePrice
AssetNotRegistered(bytes32 assetId)0x26ef68cfupdatePrice
InsufficientSources()0x4639fbe8updatePrice
FutureTimestamp(uint256 provided, uint256 maxAllowed)0x8fdce34fupdatePrice
StalePrice(uint256 provided, uint256 current)0x2730eb48updatePrice. The same or a newer price is already stored
PriceChangeExceedsLimit(uint256 changeBps, uint256 maxBps)0xea09eea7updatePrice
InvalidSignature()0x8baa579fupdatePrice. The key is not registered, or a field was changed after signing
ECDSAInvalidSignature()0xf645eedfupdatePrice. Malformed signature
ECDSAInvalidSignatureLength(uint256 length)0xfce698f7updatePrice. The signature is not 65 bytes
ECDSAInvalidSignatureS(bytes32 s)0xd78bce0cupdatePrice. High-s signature
NoPriceData(bytes32 assetId)0x794969d4getLatestPrice
NoRoundData(bytes32 assetId, uint80 roundId)0xfbd484c3getRoundData

KaskadPriceOracle (Igra)

Igra runs the first version of the oracle. Its updatePrice, reads, events and signature format are the same as above, with these differences:

  • Every asset has the same change limits: 1,500 bps (15 %), or 3,000 bps (30 %) after 3,600 seconds without an update. They are the constants MAX_PRICE_CHANGE_BPS and MAX_RESUME_CHANGE_BPS.
  • There is no ZeroPrice check.
  • assetParams(bytes32) returns only minSources.
  • The pin is read with expectedPCR0() (selector 0xf2053881), which returns 0x2e66fc4f74006323675a397ab3c6b6eab68f2ccc36f4cf10bb4d0e5c82b2106c. It is fixed at deployment.
  • signerCount() is 2: the US and Europe enclaves.

The constants and signers above were read from the deployed contract on 21 October 2026.

Freshness

A stored price has two times:

FieldMeaningUsed by
signedTimestampWhen the sources quoted the price. Set by the enclave and covered by the signatureThe oracle's ordering check, and the PONS feed on Robinhood Chain
timestampBlock time of the transaction that submitted the pricegetLatestPrice, and the Igra lending feeds and staleness checks

The oracle sets no maximum age for a signed price. It accepts any correctly signed price that is newer than the stored one and within the change limit, even one signed long ago. If your contract consumes these prices, check the age of signedTimestamp yourself.

Igra block time

Igra block timestamps currently run about 10 minutes behind real time, so signedTimestamp is usually later than block.timestamp there. In Solidity, block.timestamp - signedTimestamp then reverts with an arithmetic underflow; treat a signed time ahead of the block as age zero.

The lending markets enforce freshness as follows:

  • Igra. The five reserve feeds return the last stored price, with updatedAt set to the block time of the update and no age limit. The market's staleness check refuses borrows with Aave error 59 while reserve prices are more than 3,600 seconds old. It also blocks liquidation of positions with a health factor between 0.95 and 1. Positions below 0.95 can always be liquidated, and withdrawals and repayments are not checked.
  • Robinhood Chain. The PONS feed reverts StaleObservation() (0x158767e7) when the signed price is more than 3,600 seconds old, and FutureObservation() (0xcbf5216b) when it is more than 300 seconds ahead of the block. Until a fresh PONS price is submitted, every action that computes the health factor of an account holding PONS reverts: borrowing, withdrawing while in debt, liquidation, and disabling collateral.

Submit signed prices for every Nuntius-priced reserve in the same transaction: all five reserves on Igra, and PONS on Robinhood Chain. KaskadRouter and the borrow example do this. The Kaskad app also sends only reserve prices, which keeps calldata small.

KaskadRouter

KaskadRouter submits prices and then performs a lending action, in one transaction.

struct PriceUpdate {
    bytes32 assetId;
    uint256 price;
    uint256 timestamp;
    uint8   numSources;
    bytes32 sourcesHash;
    bytes   signature;
}
FunctionSelectorBefore the call
borrowWithPrices(PriceUpdate[] prices, address asset, uint256 amount, uint256 interestRateMode)0xbf978f17approveDelegation(router, amount) on the reserve's variable debt token. Use interestRateMode 2 (variable)
withdrawWithPrices(PriceUpdate[] prices, address asset, uint256 amount)0x102f89e5approve(router, amount) on the reserve's aToken
liquidateWithPrices(PriceUpdate[] prices, address collateralAsset, address debtAsset, address user, uint256 debtToCover, bool receiveAToken)0xbc86d9c0approve(router, debtToCover) on debtAsset

The following behaviour was verified on 21 October 2026 by simulating calls against both deployments and by borrows on local forks of both networks:

  • Each entry of prices goes to updatePrice. An entry whose price is already stored, or older, is skipped, so a bundle does not fail when someone else submitted first. Any other rejection reverts the whole call with PriceUpdateFailed(bytes32 assetId).
  • prices can be empty.
  • borrowWithPrices then requires the market's staleness sentinel to report asset as fresh, and reverts StaleAsset(address asset) otherwise. The debt is opened for the caller, and the borrowed tokens are sent to the caller.
  • withdrawWithPrices has no freshness check of its own. The pool still computes the health factor if you have debt.

According to the reference source, withdrawWithPrices withdraws the whole aToken balance when amount is type(uint256).max, and liquidateWithPrices pulls debtToCover from the caller and sends the caller the seized collateral, or aTokens if receiveAToken is set, plus any unspent debt tokens.

The Robinhood Chain router also has these functions:

FunctionSelectorNotes
pushPrices(PriceUpdate[] prices)0xe438f128Submits prices only, with the same skip rule
borrowEthWithPrices(PriceUpdate[] prices, uint256 amount, uint256 interestRateMode)0x56039f7cBorrows WETH and sends ETH. Needs delegation on the WETH variable debt token
withdrawEthWithPrices(PriceUpdate[] prices, uint256 amount)0x02825540Withdraws WETH and sends ETH. Needs approval of the WETH aToken

Reference source: src/KaskadRouter.sol at 5d865b3. The deployed routers have not been matched to it byte for byte, so rely on the verified behaviour above.

ErrorSelectorCause
PriceUpdateFailed(bytes32 assetId)0x52d90bc0The oracle rejected a price for a reason other than StalePrice. Simulate updatePrice with that price to see the reason
StaleAsset(address asset)0xce4fc8cbborrowWithPrices: the borrowed asset's price is too old for the market. Include its price
Aave error 59The market's staleness check failed. On Igra, include all five reserve prices
StaleObservation()0x158767e7Robinhood Chain: the PONS price is more than an hour old. Include PONS

Assets

assetId is keccak256 of the symbol, for example cast keccak 'ETH/USD'.

SymbolassetIdRobinhood ChainIgra
BTC/USD0xee62665949c883f9e0f6f002eac32e00bd59dfe6c34e92a91c37d6a8322d64893 / 10 % / 25 %3
ETH/USD0x0b43555ace6b39aae1b894097d0a9fc17f504c62fea598fa206cc6f5088e6e453 / 10 % / 25 %3
KAS/USD0xb44572c4e8e9637838099a1efc6b60a116ae075275c5d952d1448a78eea45cd23 / 15 % / 35 %3
TAO/USD0x92672906cbedad5b67ba80d7e4361725ef6b8fa45eb9dd04b335529420e01a7f3 / 15 % / 35 %—
USDC/USD0xff064b881a0c0fff844177f881a313ff894bfc6093d33b5514e34d7faa41b7ef2 / 2 % / 10 %2
USDT/USD0x91879a0c0be4e43cacda1599ac414205651f4a62b614b6be9e5318a182c33eb02 / 2 % / 10 %2
USDG/USD0x5ccd101e2b6abfc96509bf25b88853399130693255d2ba8bca753e61d2f39eb82 / 2 % / 10 %—
IGRA/USD0x4db259340621d1dc62dcf876ca25a99b67a8e72a4f5522c952b0f6ee296c82731 / 25 % / 50 %1
TIBBIR/USD0x6bbc5ceb0c297d76c0e5fc77a458a1b87d6c45c700bef7bb6bacbf75029d2e962 / 30 % / 50 %2
PONS/USD0xa8398d30625806644f9d06ad2d483e9e25bc76c891b444acffc61e06fa929db43 / 25 % / 50 %—
NVDA/USD0xd7f402a699378a97cf4b1f46fb772a465535d2fead1457bcb27b58312638e2643 / 15 % / 50 %—
TSLA/USD0x13ec3ae6d9a65df1ae5953598775a86c251d530770729433b0e40d1f2141afd63 / 15 % / 50 %—
  • Robinhood Chain: minimum sources / maximum change / maximum change after 3,600 seconds without an update.
  • Igra: minimum sources. Every Igra asset uses 15 % and 30 %. A dash means the asset is not served or registered on Igra.
  • A minimum of 1 means that a price from a single source is accepted.

Values were read from the oracle contracts on 21 October 2026. Read assetParams for current values.

Examples

The scripts use viem and run with Node.js 22. The read-only scripts were run against mainnet on 21 October 2026. The borrow script was tested on local forks of both mainnets.

mkdir nuntius-scripts && cd nuntius-scripts
npm init -y && npm pkg set type=module
npm install viem@2.57.7 tsx@4.23.15

Save the chain definitions as chains.ts. The other scripts import it.

chains.ts
// Chain definitions shared by the examples.
import { defineChain } from "viem";

export const igra = defineChain({
  id: 38833,
  name: "Igra",
  nativeCurrency: { name: "iKAS", symbol: "iKAS", decimals: 18 },
  rpcUrls: { default: { http: [process.env.IGRA_RPC ?? "https://rpc.igralabs.com:8545"] } },
});

export const robinhood = defineChain({
  id: 4663,
  name: "Robinhood Chain",
  nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
  rpcUrls: { default: { http: [process.env.RH_RPC ?? "https://rpc.mainnet.chain.robinhood.com"] } },
});

Verify Signed Prices

The script fetches every price from the Robinhood Chain API and checks each asset id. It then recovers the signer and simulates updatePrice without sending a transaction.

verify-prices.ts
// Fetch signed prices from Nuntius, recover each signer, and dry-run updatePrice on KaskadPriceOracleV2.
import { BaseError, ContractFunctionRevertedError, createPublicClient, encodePacked, http, keccak256, parseAbi, recoverMessageAddress, type Hex } from "viem";
import { robinhood } from "./chains.ts";

const NUNTIUS_URL = "https://nuntius.kaskad.live";
const ORACLE = "0xB4922b744cc395E05Cc962d55FA63FBab0599ec2"; // KaskadPriceOracleV2, Robinhood Chain

interface SignedPrice {
  asset_id: Hex; // keccak256 of asset_symbol
  asset_symbol: string;
  price: string; // integer, 8 decimals
  price_human: string;
  timestamp: number; // unix seconds, set by the enclave
  num_sources: number;
  sources_hash: Hex;
  signature: Hex;
  signer: Hex;
}

const oracleAbi = parseAbi([
  "function isValidSigner(address who) view returns (bool)",
  "function updatePrice(bytes32 assetId, uint256 price, uint256 timestamp, uint8 numSources, bytes32 sourcesHash, bytes signature)",
  // Errors updatePrice can raise, verbatim from KaskadPriceOracleV2.sol.
  "error NoEnclaveRegistered()",
  "error ZeroPrice()",
  "error AssetNotRegistered(bytes32 assetId)",
  "error InsufficientSources()",
  "error FutureTimestamp(uint256 provided, uint256 maxAllowed)",
  "error StalePrice(uint256 provided, uint256 current)",
  "error PriceChangeExceedsLimit(uint256 changeBps, uint256 maxBps)",
  "error InvalidSignature()",
]);

const client = createPublicClient({ chain: robinhood, transport: http() });
const { prices } = (await (await fetch(`${NUNTIUS_URL}/prices`)).json()) as { prices: SignedPrice[] };

for (const p of prices) {
  if (p.asset_id !== keccak256(new TextEncoder().encode(p.asset_symbol))) throw new Error(`asset id mismatch: ${p.asset_symbol}`);

  // The contract verifies EIP-191 over keccak256(abi.encodePacked(assetId, price, timestamp, numSources, sourcesHash)).
  const digest = keccak256(encodePacked(
    ["bytes32", "uint256", "uint256", "uint8", "bytes32"],
    [p.asset_id, BigInt(p.price), BigInt(p.timestamp), p.num_sources, p.sources_hash],
  ));
  const signer = await recoverMessageAddress({ message: { raw: digest }, signature: p.signature });
  const registered = await client.readContract({ address: ORACLE, abi: oracleAbi, functionName: "isValidSigner", args: [signer] });

  // Dry run: would the oracle accept this update right now? Nothing is sent.
  let verdict = "accepted";
  try {
    await client.simulateContract({
      address: ORACLE, abi: oracleAbi, functionName: "updatePrice",
      args: [p.asset_id, BigInt(p.price), BigInt(p.timestamp), p.num_sources, p.sources_hash, p.signature],
    });
  } catch (err) {
    const revert = err instanceof BaseError ? err.walk((e) => e instanceof ContractFunctionRevertedError) : null;
    verdict = revert instanceof ContractFunctionRevertedError ? `rejected: ${revert.data?.errorName ?? revert.shortMessage}` : `rejected: ${String(err)}`;
  }

  console.log(p.asset_symbol.padEnd(11), p.price_human.padStart(16), `sources=${p.num_sources}`, signer, registered ? "registered" : "NOT REGISTERED", verdict);
}
$ npx tsx verify-prices.ts
BTC/USD       83316.69706925 sources=9 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 registered accepted
NVDA/USD        221.16096255 sources=4 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 registered accepted
KAS/USD           0.03874287 sources=5 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 registered accepted
ETH/USD        2460.79649515 sources=10 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 registered accepted
USDG/USD          0.99998000 sources=4 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 registered accepted
USDC/USD          0.99997355 sources=5 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 registered accepted
IGRA/USD          0.00306553 sources=2 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 registered accepted
TIBBIR/USD        0.25938200 sources=2 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 registered accepted
USDT/USD          0.99878500 sources=3 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 registered accepted
TSLA/USD        378.76923555 sources=3 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 registered accepted
PONS/USD          0.28994729 sources=5 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 registered accepted
TAO/USD         273.17768535 sources=3 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 registered accepted

accepted means the oracle would store the price if it were submitted now. rejected: StalePrice would mean that the same or a newer price is already stored.

Borrow With Fresh Prices

The script borrows a reserve through KaskadRouter and submits the Nuntius prices of the lending reserves in the same transaction. It finds those reserves on-chain, so the same code works on both networks.

borrow-with-prices.ts
// Borrow through KaskadRouter, pushing fresh Nuntius prices for the lending reserves in the same transaction.
import { createPublicClient, createWalletClient, erc20Abi, http, parseAbi, parseUnits, type Address, type Chain, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { igra, robinhood } from "./chains.ts";

enum Network {
  Robinhood = "robinhood",
  Igra = "igra",
}

enum InterestRateMode {
  Variable = 2,
}

interface Deployment {
  chain: Chain;
  nuntiusUrl: string;
  router: Address;
  poolAddressesProvider: Address;
}

const deployments: Record<Network, Deployment> = {
  [Network.Robinhood]: {
    chain: robinhood,
    nuntiusUrl: "https://nuntius.kaskad.live",
    router: "0x75cbB7B191307F869f54479897D87B3a7C20bb0F",
    poolAddressesProvider: "0x3e9a28d988eE889B47bF257F0e103960721475d0",
  },
  [Network.Igra]: {
    chain: igra,
    nuntiusUrl: "https://kaskad.live/enclave",
    router: "0xD22606268c0929AB299918eCf9c6E0eEA0977A0C",
    poolAddressesProvider: "0x4e718714BF19c7BBcf402ecA92f971B8a65c716D",
  },
};

interface SignedPrice {
  asset_id: Hex;
  price: string;
  timestamp: number;
  num_sources: number;
  sources_hash: Hex;
  signature: Hex;
}

// Mirrors KaskadRouter.PriceUpdate.
interface PriceUpdate {
  assetId: Hex;
  price: bigint;
  timestamp: bigint;
  numSources: number;
  sourcesHash: Hex;
  signature: Hex;
}

const routerAbi = parseAbi([
  "struct PriceUpdate { bytes32 assetId; uint256 price; uint256 timestamp; uint8 numSources; bytes32 sourcesHash; bytes signature; }",
  "function borrowWithPrices(PriceUpdate[] prices, address asset, uint256 amount, uint256 interestRateMode)",
  "error PriceUpdateFailed(bytes32 assetId)",
  "error StaleAsset(address asset)",
]);
const providerAbi = parseAbi(["function getPool() view returns (address)", "function getPriceOracle() view returns (address)"]);
const poolAbi = parseAbi([
  "struct ReserveData { uint256 configuration; uint128 liquidityIndex; uint128 currentLiquidityRate; uint128 variableBorrowIndex; uint128 currentVariableBorrowRate; uint128 currentStableBorrowRate; uint40 lastUpdateTimestamp; uint16 id; address aTokenAddress; address stableDebtTokenAddress; address variableDebtTokenAddress; address interestRateStrategyAddress; uint128 accruedToTreasury; uint128 unbacked; uint128 isolationModeTotalDebt; }",
  "function getReservesList() view returns (address[])",
  "function getReserveData(address asset) view returns (ReserveData)",
]);
const aaveOracleAbi = parseAbi(["function getSourceOfAsset(address asset) view returns (address)"]);
const nuntiusFeedAbi = parseAbi(["function assetId() view returns (bytes32)"]);
const debtTokenAbi = parseAbi([
  "function borrowAllowance(address fromUser, address toUser) view returns (uint256)",
  "function approveDelegation(address delegatee, uint256 amount)",
]);

function parseNetwork(value: string | undefined): Network {
  if (value === undefined) return Network.Robinhood;
  const network = Object.values(Network).find((n) => n === value);
  if (!network) throw new Error(`NETWORK must be one of: ${Object.values(Network).join(", ")}`);
  return network;
}

const { chain, nuntiusUrl, router, poolAddressesProvider } = deployments[parseNetwork(process.env.NETWORK)];
const asset = process.env.ASSET as Address | undefined; // reserve to borrow
const amount = process.env.AMOUNT; // in token units, e.g. "10"
if (!process.env.PRIVATE_KEY || !asset || !amount) {
  throw new Error("usage: [NETWORK=igra] PRIVATE_KEY=0x... ASSET=0x... AMOUNT=10 npx tsx borrow-with-prices.ts");
}

const account = privateKeyToAccount(process.env.PRIVATE_KEY as Hex);
const client = createPublicClient({ chain, transport: http() });
const wallet = createWalletClient({ account, chain, transport: http() });

// 1. Asset ids of the reserves that Nuntius prices. Other reserves use feeds without assetId().
const [pool, aaveOracle] = await Promise.all([
  client.readContract({ address: poolAddressesProvider, abi: providerAbi, functionName: "getPool" }),
  client.readContract({ address: poolAddressesProvider, abi: providerAbi, functionName: "getPriceOracle" }),
]);
const reserves = await client.readContract({ address: pool, abi: poolAbi, functionName: "getReservesList" });
const reserveIds = new Set<string>();
for (const reserve of reserves) {
  const source = await client.readContract({ address: aaveOracle, abi: aaveOracleAbi, functionName: "getSourceOfAsset", args: [reserve] });
  const id = await client.readContract({ address: source, abi: nuntiusFeedAbi, functionName: "assetId" }).catch(() => null);
  if (id) reserveIds.add(id.toLowerCase());
}

// 2. Signed prices for those reserves.
const response = await fetch(`${nuntiusUrl}/prices`);
if (!response.ok) throw new Error(`GET /prices failed: HTTP ${response.status}`);
const { prices } = (await response.json()) as { prices: SignedPrice[] };
const updates: PriceUpdate[] = prices
  .filter((p) => reserveIds.has(p.asset_id.toLowerCase()))
  .map((p) => ({
    assetId: p.asset_id,
    price: BigInt(p.price),
    timestamp: BigInt(p.timestamp),
    numSources: p.num_sources,
    sourcesHash: p.sources_hash,
    signature: p.signature,
  }));

// 3. The router borrows on your behalf, so it needs credit delegation on the variable debt token.
const decimals = await client.readContract({ address: asset, abi: erc20Abi, functionName: "decimals" });
const value = parseUnits(amount, decimals);
const { variableDebtTokenAddress } = await client.readContract({ address: pool, abi: poolAbi, functionName: "getReserveData", args: [asset] });
const allowance = await client.readContract({
  address: variableDebtTokenAddress, abi: debtTokenAbi, functionName: "borrowAllowance", args: [account.address, router],
});
if (allowance < value) {
  const hash = await wallet.writeContract({ address: variableDebtTokenAddress, abi: debtTokenAbi, functionName: "approveDelegation", args: [router, value] });
  await client.waitForTransactionReceipt({ hash });
}

// 4. Push the prices and borrow in one transaction. The simulation surfaces PriceUpdateFailed or StaleAsset before anything is sent.
const { request } = await client.simulateContract({
  account, address: router, abi: routerAbi, functionName: "borrowWithPrices",
  args: [updates, asset, value, BigInt(InterestRateMode.Variable)],
});
const receipt = await client.waitForTransactionReceipt({ hash: await wallet.writeContract(request) });
console.log(`borrow ${receipt.status} in block ${receipt.blockNumber}: ${amount} sent to ${account.address}, ${updates.length} price(s) pushed`);
# Robinhood Chain: borrow 10 USDG
PRIVATE_KEY=0x… ASSET=0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 AMOUNT=10 npx tsx borrow-with-prices.ts
# Igra: borrow 1 USDT
NETWORK=igra PRIVATE_KEY=0x… ASSET=0x46346F49b4fe8c640c5FCdbed2d6741056FEB959 AMOUNT=1 npx tsx borrow-with-prices.ts

The account needs collateral in the market and gas on the network. Results on local forks of both mainnets:

borrow success in block 94249184: 10 sent to 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 1 price(s) pushed
borrow success in block 19791508: 1 sent to 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 5 price(s) pushed

The Robinhood Chain borrow used PONS as collateral and the Igra borrow used USDC. On the Igra fork, the same borrow sent straight to the pool without prices reverted with Aave error 59.

Read Prices On-chain

ORACLE=0xB4922b744cc395E05Cc962d55FA63FBab0599ec2
RPC=https://rpc.mainnet.chain.robinhood.com
cast call $ORACLE "latestPrices(bytes32)(uint256,uint256,uint256,uint8,bytes32,uint80)" $(cast keccak 'PONS/USD') --rpc-url $RPC
28813937 [2.881e7]
1792581045 [1.792e9]
1792581020 [1.792e9]
5
0x6821d62bd588ada46b1d08131466d57543090d3296da4de45e450cd560951a15
44

The fields are price (0.28813937 USD), timestamp (block time of the update), signedTimestamp, numSources, sourcesHash and roundId.

For history, filter PriceUpdated events by asset id:

cast logs --address $ORACLE \
  "PriceUpdated(bytes32 indexed assetId, uint256 price, uint256 timestamp, uint8 numSources, uint80 roundId)" \
  $(cast keccak 'PONS/USD') --from-block 94000000 --rpc-url $RPC

The Igra RPC caps eth_getLogs at 100,000 blocks per query.

Integration Notes

  • Parse price as a big integer. Use price_human for display only.
  • Recover the signer from the signature and check it with isValidSigner on the target network. Do not rely on the signer field.
  • Judge age by timestamp from the API or signedTimestamp on-chain, not by the time that getLatestPrice returns.
  • A StalePrice revert means someone else submitted the same or a newer price first. KaskadRouter skips such entries.
  • When the API is unavailable, on-chain prices stay as they are. Actions that need a fresh price wait until the API is back, and the Kaskad app shows its maintenance screen.