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.
- fetch, aggregate, sign
GET /prices
Kaskad Price Oracle. update Price- or
KaskadRouter. *With Prices
updatePriceis 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.
KaskadRouterdoes 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
| Network | Price API | Signer | Assets |
|---|---|---|---|
Robinhood Chain (4663) | https://nuntius.kaskad.live | 0xE4D88bE9756EB3fc5bD28B81642605FBF238d2c9 | 12 |
Igra (38833) | https://kaskad.live/enclave | 0x544705F5d72E1C6f24bFD7C2E176F2Cd2aA8Dc2A | 7 |
Igra (38833) | https://kaskad.live/enclave-eu | 0xD35e55c7C2d10472e6d9E7132c88BEeD347D91A7 | 7 |
- 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.
GETonly.HEADandOPTIONSreturn 501. - Browsers. Responses carry
Access-Control-Allow-Origin: *. CORS preflight is not supported, so send plainGETrequests without custom headers. - Caching. Responses are not cached.
- Rate limit. About 60 requests per minute per client IP.
X-RateLimit-Remainingshows what is left. Over the limit, the API returns 429 withretry_afterin seconds. - Symbols. URL-encode the slash:
/prices/ETH%2FUSD. The unencoded/prices/ETH/USDalso 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
| Field | Type | Meaning |
|---|---|---|
asset_id | string | keccak256 of asset_symbol, 32 bytes. The oracle's key for the asset |
asset_symbol | string | For example ETH/USD. All prices are in USD |
price | string | Price × 10^8 as a decimal integer. Parse it as a big integer |
price_human | string | The same price with 8 decimals. For display only |
timestamp | number | Unix 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_sources | number | Sources in the final aggregate, after outliers were removed |
sources_hash | string | Commitment to the sources used and their prices. See Signature Format |
signature | string | 65 bytes, r ‖ s ‖ v, with v 27 or 28 |
signer | string | Address 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 }| Field | Meaning |
|---|---|
status | ok when the enclave answers. It says nothing about how old the prices are; check timestamp for that |
signer | Address of the enclave key |
num_assets | Number of assets the enclave currently has a price for |
attestation_healthy | false 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_docis the AWS Nitro attestation document (COSE_Sign1) in hex, without a0xprefix. Itspublic_keyis 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
| Status | Body | Meaning |
|---|---|---|
| 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 |
| 501 | HEAD 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);| Bytes | Field | Type |
|---|---|---|
| 0–31 | assetId | bytes32 |
| 32–63 | price | uint256 |
| 64–95 | timestamp | uint256 |
| 96 | numSources | uint8 |
| 97–128 | sourcesHash | bytes32 |
- 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.
sourcesHashiskeccak256over 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
| Contract | Network | Address |
|---|---|---|
KaskadPriceOracleV2 | Robinhood Chain | 0xB4922b744cc395E05Cc962d55FA63FBab0599ec2 |
KaskadRouter | Robinhood Chain | 0x75cbB7B191307F869f54479897D87B3a7C20bb0F |
NitroAttestationVerifierV2 | Robinhood Chain | 0x52A22A678Af63d74BBfCB4c8fA90299db2760ACD |
KaskadPriceOracle | Igra | 0xB01993906D14792DC21B7EA75A971D8191AAE402 |
KaskadRouter | Igra | 0xD22606268c0929AB299918eCf9c6E0eEA0977A0C |
| Attestation verifier | Igra | 0x76216b3bde5bC81b04FA5A5d4a67F6AfACc666B9 |
The lending markets read the oracle through these feeds, which are the Aave price sources of the reserves:
| Network | Reserve | Feed | Asset |
|---|---|---|---|
| Igra | WiKAS | 0x538607bDdc28b215aB7db97360816194AF1085Ad | KAS/USD |
| Igra | USDC | 0x7ba76bFf6a9c7926eB118e5D879A6C3B620Ef8a7 | USDC/USD |
| Igra | USDT | 0x748891Af1C0AA0515AD73b9E3FC8C8cFcd0FFa24 | USDT/USD |
| Igra | WETH | 0xd37Ff9c574c520aD988522709B43A4E800CAb34B | ETH/USD |
| Igra | cbBTC | 0xfd3a1ad2C2C73Fb54286E1932b3f05a6bCF18E9F | BTC/USD |
| Robinhood Chain | PONS | 0x724A8014c37De5f646E517a521A7C35D96AFB649 | PONS/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) externalSelector 0x1828041c. Anyone can call it. The checks run in this order:
| # | Requirement | Error |
|---|---|---|
| 1 | At least one signer is registered | NoEnclaveRegistered() |
| 2 | price is not zero | ZeroPrice() |
| 3 | The asset is registered | AssetNotRegistered(bytes32 assetId) |
| 4 | numSources is at least the asset's minSources | InsufficientSources() |
| 5 | timestamp is at most block.timestamp + 7,200 | FutureTimestamp(uint256 provided, uint256 maxAllowed) |
| 6 | timestamp is greater than the stored signedTimestamp | StalePrice(uint256 provided, uint256 current) |
| 7 | The 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 price | PriceChangeExceedsLimit(uint256 changeBps, uint256 maxBps) |
| 8 | The signature recovers to a registered signer | InvalidSignature() |
- The change is
abs(new − old) × 10000 / oldin basis points, rounded down. - A price further than
maxResumeChangeBpsfrom the stored price cannot be submitted. The stored price can move only within these limits. - On success the contract stores the price with
timestampset toblock.timestampandsignedTimestampset to the signedtimestamp. It then increments the asset's round and emitsPriceUpdated.
Reads
| Function | Selector | Returns |
|---|---|---|
latestPrices(bytes32 assetId) | 0xdb7c1ad1 | price, timestamp (block time of the update), signedTimestamp, numSources, sourcesHash, roundId. All zero if there is no price |
getLatestPrice(bytes32 assetId) | 0xb2ee2f01 | price, timestamp (block time of the update), numSources, roundId. Reverts NoPriceData(bytes32) if there is no price |
getRoundData(bytes32 assetId, uint80 roundId) | 0xacaec0f2 | price, timestamp, numSources of a past round. Reverts NoRoundData(bytes32,uint80) |
priceHistory(bytes32 assetId, uint80 roundId) | 0x4d49c8f7 | The latestPrices fields of a past round |
currentRound(bytes32 assetId) | 0xb49a0461 | Latest round id |
assetParams(bytes32 assetId) | 0x5b034f9b | minSources, maxChangeBps, maxResumeChangeBps. All zero if the asset is not registered |
registeredAssetIds() | 0xfdc317a0 | bytes32[] |
isValidSigner(address) | 0xd5f50582 | bool |
validSigner(address) | 0xba6f8b0e | bool, the same value |
signerCount() | 0x7ca548c6 | 1 today |
expectedPcr0() | 0x62776303 | First 32 bytes of the PCR0 of the pinned enclave image |
verifier() | 0x2b7ac3f3 | NitroAttestationVerifierV2 |
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
| Event | topic0 |
|---|---|
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
| Error | Selector | Raised by |
|---|---|---|
NoEnclaveRegistered() | 0x34bdf075 | updatePrice |
ZeroPrice() | 0x4dfba023 | updatePrice |
AssetNotRegistered(bytes32 assetId) | 0x26ef68cf | updatePrice |
InsufficientSources() | 0x4639fbe8 | updatePrice |
FutureTimestamp(uint256 provided, uint256 maxAllowed) | 0x8fdce34f | updatePrice |
StalePrice(uint256 provided, uint256 current) | 0x2730eb48 | updatePrice. The same or a newer price is already stored |
PriceChangeExceedsLimit(uint256 changeBps, uint256 maxBps) | 0xea09eea7 | updatePrice |
InvalidSignature() | 0x8baa579f | updatePrice. The key is not registered, or a field was changed after signing |
ECDSAInvalidSignature() | 0xf645eedf | updatePrice. Malformed signature |
ECDSAInvalidSignatureLength(uint256 length) | 0xfce698f7 | updatePrice. The signature is not 65 bytes |
ECDSAInvalidSignatureS(bytes32 s) | 0xd78bce0c | updatePrice. High-s signature |
NoPriceData(bytes32 assetId) | 0x794969d4 | getLatestPrice |
NoRoundData(bytes32 assetId, uint80 roundId) | 0xfbd484c3 | getRoundData |
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_BPSandMAX_RESUME_CHANGE_BPS. - There is no
ZeroPricecheck. assetParams(bytes32)returns onlyminSources.- The pin is read with
expectedPCR0()(selector0xf2053881), which returns0x2e66fc4f74006323675a397ab3c6b6eab68f2ccc36f4cf10bb4d0e5c82b2106c. 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:
| Field | Meaning | Used by |
|---|---|---|
signedTimestamp | When the sources quoted the price. Set by the enclave and covered by the signature | The oracle's ordering check, and the PONS feed on Robinhood Chain |
timestamp | Block time of the transaction that submitted the price | getLatestPrice, 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
updatedAtset to the block time of the update and no age limit. The market's staleness check refuses borrows with Aave error59while 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, andFutureObservation()(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;
}| Function | Selector | Before the call |
|---|---|---|
borrowWithPrices(PriceUpdate[] prices, address asset, uint256 amount, uint256 interestRateMode) | 0xbf978f17 | approveDelegation(router, amount) on the reserve's variable debt token. Use interestRateMode 2 (variable) |
withdrawWithPrices(PriceUpdate[] prices, address asset, uint256 amount) | 0x102f89e5 | approve(router, amount) on the reserve's aToken |
liquidateWithPrices(PriceUpdate[] prices, address collateralAsset, address debtAsset, address user, uint256 debtToCover, bool receiveAToken) | 0xbc86d9c0 | approve(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
pricesgoes toupdatePrice. 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 withPriceUpdateFailed(bytes32 assetId). pricescan be empty.borrowWithPricesthen requires the market's staleness sentinel to reportassetas fresh, and revertsStaleAsset(address asset)otherwise. The debt is opened for the caller, and the borrowed tokens are sent to the caller.withdrawWithPriceshas 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:
| Function | Selector | Notes |
|---|---|---|
pushPrices(PriceUpdate[] prices) | 0xe438f128 | Submits prices only, with the same skip rule |
borrowEthWithPrices(PriceUpdate[] prices, uint256 amount, uint256 interestRateMode) | 0x56039f7c | Borrows WETH and sends ETH. Needs delegation on the WETH variable debt token |
withdrawEthWithPrices(PriceUpdate[] prices, uint256 amount) | 0x02825540 | Withdraws 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.
| Error | Selector | Cause |
|---|---|---|
PriceUpdateFailed(bytes32 assetId) | 0x52d90bc0 | The oracle rejected a price for a reason other than StalePrice. Simulate updatePrice with that price to see the reason |
StaleAsset(address asset) | 0xce4fc8cb | borrowWithPrices: the borrowed asset's price is too old for the market. Include its price |
Aave error 59 | The market's staleness check failed. On Igra, include all five reserve prices | |
StaleObservation() | 0x158767e7 | Robinhood 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'.
| Symbol | assetId | Robinhood Chain | Igra |
|---|---|---|---|
BTC/USD | 0xee62665949c883f9e0f6f002eac32e00bd59dfe6c34e92a91c37d6a8322d6489 | 3 / 10 % / 25 % | 3 |
ETH/USD | 0x0b43555ace6b39aae1b894097d0a9fc17f504c62fea598fa206cc6f5088e6e45 | 3 / 10 % / 25 % | 3 |
KAS/USD | 0xb44572c4e8e9637838099a1efc6b60a116ae075275c5d952d1448a78eea45cd2 | 3 / 15 % / 35 % | 3 |
TAO/USD | 0x92672906cbedad5b67ba80d7e4361725ef6b8fa45eb9dd04b335529420e01a7f | 3 / 15 % / 35 % | — |
USDC/USD | 0xff064b881a0c0fff844177f881a313ff894bfc6093d33b5514e34d7faa41b7ef | 2 / 2 % / 10 % | 2 |
USDT/USD | 0x91879a0c0be4e43cacda1599ac414205651f4a62b614b6be9e5318a182c33eb0 | 2 / 2 % / 10 % | 2 |
USDG/USD | 0x5ccd101e2b6abfc96509bf25b88853399130693255d2ba8bca753e61d2f39eb8 | 2 / 2 % / 10 % | — |
IGRA/USD | 0x4db259340621d1dc62dcf876ca25a99b67a8e72a4f5522c952b0f6ee296c8273 | 1 / 25 % / 50 % | 1 |
TIBBIR/USD | 0x6bbc5ceb0c297d76c0e5fc77a458a1b87d6c45c700bef7bb6bacbf75029d2e96 | 2 / 30 % / 50 % | 2 |
PONS/USD | 0xa8398d30625806644f9d06ad2d483e9e25bc76c891b444acffc61e06fa929db4 | 3 / 25 % / 50 % | — |
NVDA/USD | 0xd7f402a699378a97cf4b1f46fb772a465535d2fead1457bcb27b58312638e264 | 3 / 15 % / 50 % | — |
TSLA/USD | 0x13ec3ae6d9a65df1ae5953598775a86c251d530770729433b0e40d1f2141afd6 | 3 / 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.15Save the chain definitions as chains.ts. The other scripts import it.
// 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.
// 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 acceptedaccepted 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 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.tsThe 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) pushedThe 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 $RPC28813937 [2.881e7]
1792581045 [1.792e9]
1792581020 [1.792e9]
5
0x6821d62bd588ada46b1d08131466d57543090d3296da4de45e450cd560951a15
44The 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 $RPCThe Igra RPC caps eth_getLogs at 100,000 blocks per query.
Integration Notes
- Parse
priceas a big integer. Useprice_humanfor display only. - Recover the signer from the signature and check it with
isValidSigneron the target network. Do not rely on thesignerfield. - Judge age by
timestampfrom the API orsignedTimestampon-chain, not by the time thatgetLatestPricereturns. - A
StalePricerevert means someone else submitted the same or a newer price first.KaskadRouterskips 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.

