RAAC Real Estate Oracle¶
Overview¶
The RAAC Real Estate oracle keeps on-chain house-NFT prices in sync with the off-chain valuation database. It has three parts:
- RAACHousePrices — the on-chain source of truth. Stores each RAAC house's latest price in USD, exposes read helpers that return prices in
pmUSD, tracks timestamps, and notifies optional syncers. - RAACHousePriceReceiver — the updater. Accepts price-update requests, marks the affected houses pending, and writes DON-signed prices into
RAACHousePrices. - CRE workflow + AWS driver — the off-chain half: a daily Lambda that decides which houses need repricing, and a Chainlink Runtime Environment (CRE) workflow that fetches the authoritative prices and writes them back.
This replaces the Chainlink Functions oracle
The previous RAACHousePriceOracle (Chainlink Functions, one house per request, JS source on the DON) is no longer the update path. The storage layer — RAACHousePrices, including setHousePrices — is unchanged; only the oracle layer calling into it is new. RAACHousePrices.oracle now points at RAACHousePriceReceiver.
AWS never supplies data — it only monitors and triggers
No price that lands on-chain ever originates in AWS. The Lambda reads the valuation database only to decide which houses are out of sync; the prices it saw are discarded and never sent on-chain. The CRE workflow re-fetches and re-verifies every price itself on the DON, under OCR consensus. This holds for every RAAC CRE oracle.
Price Units
- Stored unit: USD with oracle-decimals (18 decimals)
- Read helper: returns price converted to
pmUSDusing thePmUSDToUSDOracle
Architecture¶
The trigger is on-chain first: houses have to be flagged pendingRequest = true before the new price arrives, so the update starts with a transaction rather than an HTTP call.
Key points:
- The receiver only orchestrates updates; it does not store canonical prices.
RAACHousePricesconverts USD →pmUSDat read time viaPmUSDToUSDOracle.- Syncers (max 5) can subscribe to updates for indexing, caching, or off-chain services.
- The requester key cannot influence prices — the workflow fetches and verifies them itself — it can only decide which houses get repriced.
RAACHousePrices¶
Purpose¶
- Store canonical house prices per RAAC tokenId in USD.
- Provide read helpers that convert USD →
pmUSDusingPmUSDToUSDOracle. - Track last update timestamps, maintain a lightweight token registry, and notify syncers on changes.
Core Concepts¶
- USD storage, pmUSD readout:
getLatestPrice(tokenId)converts stored USD topmUSDusingcrvUSDToUSDOracle.getPrice()and itsdecimals(). - Timestamps:
tokenToLastUpdateTimestamp[tokenId]andlastUpdateTimestamp(global) track freshness. - Syncers: Optional contracts implementing
IRAACHousePriceSyncercan be added (up to 5). They receivenotify(tokenId, previousPrice, newPrice)on every write.
Vault NAV Syncer
One configured syncer is RAACNFTVaultAdapterV2.sol. When a house price is updated and the corresponding asset is stored in the vault, the adapter receives the notify callback and updates the vault NAV immediately.
- Oracle-only writes:
setHousePriceandsetHousePricesare restricted to the configuredoracleaddress, which isRAACHousePriceReceiver.
Conversion
For a stored USD price P_usd and oracle price pmUSD/USD = Q with d = decimals(), the read helper returns:
\( P_{pmUSD} = P_{usd} * 10^{d} / Q \)
Key Functions¶
| Function | Description | Access |
|---|---|---|
getLatestPrice(tokenId) |
Returns (priceInCrvUSD, lastUpdateTimestamp) |
view |
crvUSDToUSDPrice() |
Returns pmUSD/USD price normalized to 18 decimals |
view |
decimals() |
Mirror decimals of PmUSDToUSDOracle |
view |
getRawPrice(tokenId) |
Returns (priceInUSD, lastUpdateTimestamp) |
view |
setHousePrice(tokenId, amount) |
Set single USD price and timestamp; emit create/update; notify syncers | onlyOracle |
setHousePrices(tokenIds[], amounts[]) |
Batch set USD prices; emit per-item events; notify syncers | onlyOracle |
setOracle(addr) |
Point at the updater (the CRE receiver) | onlyOwner |
setCrvUSDToUSDOracle(addr) |
Set the FX oracle used for conversion | onlyOwner |
isPendingRequest(tokenId) |
Staticcalls the updater: is a request pending for tokenId |
view |
addSyncer(addr) / removeSyncer(addr) |
Manage syncer list; interface-checked; max 5 | onlyOwner |
Events & Errors¶
- Events:
PriceCreated,PriceUpdated,OracleUpdated,CrvUSDToUSDOracleUpdated,SyncerAdded,SyncerRemoved - Errors:
NotOracle,ArrayLengthMismatch,SyncerDoesNotSupportInterface,MaximumSyncers
The off-chain driver (AWS Lambda)¶
A daily Lambda decides which houses need repricing. Each run:
- Reads
totalSupply()from the RAAC ERC-721 to learn the house universe (IDs1..N). - Reads the current on-chain price for every house from
RAACHousePricesin one batched JSON-RPC round-trip. - Fetches each house's authoritative price from the valuation database in a single query — the exact value the CRE will write.
- Diffs the two. Every ID that does not match exactly is queued for update.
- Splits the queue into batches of at most the receiver's on-chain
maxBatch(), read from the contract each run rather than configured. - For each batch, signs a transaction with its KMS key calling
RAACHousePriceReceiver.requestPriceUpdate(ids). - Posts lifecycle messages to Slack — started → requesting → outcome, with tx hashes, never per-ID prices.
The KMS key holds the receiver's priceUpdateRequester role and nothing else, and must be funded with gas. The Lambda performs no verification of the prices themselves: the workflow re-fetches and re-verifies them before any write.
RAACHousePriceReceiver¶
Requesting an update¶
requestPriceUpdate(uint256[] ids) is onlyPriceUpdateRequester. It atomically:
- reverts on an empty batch (
EmptyBatch) or one larger thanmaxBatch(BatchTooLarge); - sets
pendingRequest[id] = truefor each id not already pending, emittingPendingRequestSet; - emits
PriceUpdateRequested(ids)— the log the CRE workflow's EVM trigger watches.
This is the only entry point that sets pending flags, so a pending house always has a request behind it. The trigger event fires on every call, including for ids already pending, so re-requesting after a missed or failed CRE run re-fires the workflow.
clearPendingRequests(uint256[] ids) is the onlyOwner recovery path for a run that never delivered a report — flags are otherwise cleared automatically when the price is written.
Writing the report¶
onReport is callable only by the Chainlink Forwarder, with the ReceiverTemplate base additionally checking the report's author and workflow id. _processReport decodes (uint256 chainId, uint64 reportedAt, uint256[] ids, uint256[] prices) and then:
- rejects the whole report if
chainId != block.chainid(ChainIdMismatch),reportedAt == 0(InvalidReportTimestamp), the arrays disagree in length (LengthMismatch), the batch is empty (EmptyBatch), or it exceedsmaxBatch(BatchTooLarge); - skips individual ids with a zero price (
PriceRejected), with areportedAtnot strictly newer than that id'slastReportedAt(PriceStale— out-of-order delivery), or whose move exceedsmaxDeviationBpsagainst the current on-chain price (PriceRejected); - writes the survivors in one
housePrices.setHousePrices(ids, prices)call; - advances
lastReportedAt[id]and clearspendingRequest[id]for each written house, so consumers unblock atomically with the price update.
Skipped ids stay pending and keep their previous watermark. isPendingRequest(houseId) matches the old IRAACHousePriceOracle shape, so RAACHousePrices.isPendingRequest — which staticcalls the configured oracle — keeps working unchanged.
Configuration¶
| Function | Description | Access |
|---|---|---|
requestPriceUpdate(ids) |
Mark pending and fire the CRE trigger | onlyPriceUpdateRequester |
clearPendingRequests(ids) |
Force-clear pending flags (recovery) | onlyOwner |
setPriceUpdateRequester(addr) |
Rotate the trigger key | onlyOwner |
setMaxBatch(n) |
Cap ids per request and per report | onlyOwner |
setMaxDeviationBps(bps) |
Per-house move cap; 0 disables the check |
onlyOwner |
setForwarderAddress / setExpectedAuthor / setExpectedWorkflowId |
Workflow-identity configuration | onlyOwner |
isPendingRequest(houseId) |
Whether a house is awaiting a price | view |
Events: PriceUpdateRequested, PendingRequestSet, PendingRequestCleared, PriceRejected, PriceStale, PriceUpdateRequesterUpdated, MaxDeviationBpsUpdated.
The workflow (CRE)¶
The workflow is deployed to the DON and triggered by the PriceUpdateRequested log. It:
- Fetches the authoritative prices over Confidential HTTP — the database credentials are injected by the Vault DON enclave and never enter workflow code or memory.
- Verifies the auditor's EIP-712 signature and ties out the global fractional totals before trusting any row.
- Encodes
(chainId, reportedAt, ids, prices)into a report and signs it under OCR consensus.reportedAtisruntime.now(), the DON-consensus clock;chainIdbinds the report to one chain so it cannot be replayed against a receiver elsewhere. - Calls
writeReport, which lands through the Forwarder.
Pending state, frontrun protection, and the trust model¶
Pending State & Frontrun Protection
A house flagged pendingRequest has mint, deposit and withdraw blocked on it — in RAACNFT.sol and on NFT deposit into the RWA Vault. This closes the window in which a soon-to-change price could be traded against. The flag is set by the request and cleared only when that house's price is actually written; a report that skips the house leaves it pending.
Pending flags never expire
Nothing caps how many ids may be outstanding and nothing clears them on a timeout. A compromised trigger key cannot lie about prices — the workflow re-fetches them — but it can freeze the protocol by marking every tokenId pending. Recovery is an owner (multisig) action: setPriceUpdateRequester(newKey), then clearPendingRequests.
Price information leaks before it is on-chain
This is a push oracle. Read access to the valuation database reveals the next price hours to weeks early, and the Forwarder transaction is visible in the mempool before it confirms. No key is exposed — this is an information-flow risk. Mitigations: least-privilege, rotated DB read tokens; a settlement guard in consuming protocols; workflow logs kept free of per-ID prices.
Three keys — never conflate them
| Key | Powers | If compromised |
|---|---|---|
| Workflow owner | Deploy/update the workflow, manage DON secrets | Can push a malicious workflow and rotate the DB key |
| Receiver owner | setMaxBatch, setMaxDeviationBps, setForwarderAddress, setExpectedAuthor/setExpectedWorkflowId, setPriceUpdateRequester, transferOwnership |
Can disable the receiver's workflow-identity check |
Trigger key (priceUpdateRequester, held by the Lambda) |
requestPriceUpdate only |
Cannot influence prices, but can freeze mint/deposit/withdraw protocol-wide until rotated |
The workflow owner and the receiver owner must be different keys — otherwise one compromise both deploys a malicious workflow and disables the check that guards against it.
Integration Notes¶
- To read a house price for lending and health factor calculations, always query
RAACHousePrices.getLatestPrice(tokenId)which returns the price denominated inpmUSDand its update timestamp. - To show the raw USD price and timestamp, use
getRawPrice(tokenId). - The
pmUSDconversion relies onPmUSDToUSDOracle; ensure it is configured and healthy. Switching it is owner-controlled viasetCrvUSDToUSDOracle.
Circuit-Breakers & Staleness
The contracts are designed so that staleness and fallback handling live in the FX oracle used for conversion. Operational procedures should include monitoring and replacing the FX oracle if needed.