// SPDX-License-Identifier: MIT pragma solidity >=0.8.0 <0.9.0; import { ORA } from "./OracleToken.sol"; import { StatisticsUtils } from "../utils/StatisticsUtils.sol"; contract StakingOracle { using StatisticsUtils for uint256[]; ///////////////// /// Errors ////// ///////////////// error NodeNotRegistered(); error InsufficientStake(); error NodeAlreadyRegistered(); error NoRewardsAvailable(); error OnlyPastBucketsAllowed(); error NodeAlreadySlashed(); error AlreadyReportedInCurrentBucket(); error NotDeviated(); error WaitingPeriodNotOver(); error InvalidPrice(); error IndexOutOfBounds(); error NodeNotAtGivenIndex(); error TransferFailed(); error MedianNotRecorded(); error BucketMedianAlreadyRecorded(); error NodeDidNotReport(); ////////////////////// /// State Variables // ////////////////////// ORA public oracleToken; struct OracleNode { uint256 stakedAmount; uint256 lastReportedBucket; uint256 reportCount; uint256 claimedReportCount; uint256 firstBucket; // block when node registered bool active; } struct BlockBucket { mapping(address => bool) slashedOffenses; address[] reporters; uint256[] prices; uint256 medianPrice; } mapping(address => OracleNode) public nodes; mapping(uint256 => BlockBucket) public blockBuckets; // one bucket per 24 blocks address[] public nodeAddresses; uint256 public constant MINIMUM_STAKE = 100 ether; uint256 public constant BUCKET_WINDOW = 24; // 24 blocks uint256 public constant SLASHER_REWARD_PERCENTAGE = 10; uint256 public constant REWARD_PER_REPORT = 1 ether; // ORA Token reward per report uint256 public constant INACTIVITY_PENALTY = 1 ether; uint256 public constant MISREPORT_PENALTY = 100 ether; uint256 public constant MAX_DEVIATION_BPS = 1000; // 10% default threshold uint256 public constant WAITING_PERIOD = 2; // 2 buckets after last report before exit allowed //////////////// /// Events ///// //////////////// event NodeRegistered(address indexed node, uint256 stakedAmount); event PriceReported(address indexed node, uint256 price, uint256 bucketNumber); event BucketMedianRecorded(uint256 indexed bucketNumber, uint256 medianPrice); event NodeSlashed(address indexed node, uint256 amount); event NodeRewarded(address indexed node, uint256 amount); event StakeAdded(address indexed node, uint256 amount); event NodeExited(address indexed node, uint256 amount); /////////////////// /// Modifiers ///// /////////////////// /** * @notice Modifier to restrict function access to registered oracle nodes * @dev Checks if the sender has a registered node in the mapping */ modifier onlyNode() { if (nodes[msg.sender].active == false) revert NodeNotRegistered(); _; } /////////////////// /// Constructor /// /////////////////// constructor(address oraTokenAddress) { oracleToken = ORA(payable(oraTokenAddress)); } /////////////////// /// Functions ///// /////////////////// /** * @notice Registers a new oracle node with initial ORA token stake * @dev Creates a new OracleNode struct and adds the sender to the nodeAddresses array. * Requires minimum stake amount and prevents duplicate registrations. */ function registerNode(uint256 amount) public { if (amount < MINIMUM_STAKE) revert InsufficientStake(); if (nodes[msg.sender].active) revert NodeAlreadyRegistered(); bool success = oracleToken.transferFrom(msg.sender, address(this), amount); if (!success) revert TransferFailed(); nodes[msg.sender] = OracleNode({ stakedAmount: amount, lastReportedBucket: 0, reportCount: 0, claimedReportCount: 0, firstBucket: getCurrentBucketNumber(), active: true }); nodeAddresses.push(msg.sender); emit NodeRegistered(msg.sender, amount); } /** * @notice Updates the price reported by an oracle node (only registered nodes) * @dev Updates the node's lastReportedBucket and price in that bucket. Requires sufficient stake. * Enforces that previous report's bucket must have its median recorded before allowing new report. * This creates a chain of finalized buckets, ensuring all past reports are accountable. * @param price The new price value to report */ function reportPrice(uint256 price) public onlyNode { if (price == 0) revert InvalidPrice(); if (getEffectiveStake(msg.sender) < MINIMUM_STAKE) revert InsufficientStake(); uint256 currentBucket = getCurrentBucketNumber(); OracleNode storage n = nodes[msg.sender]; if (n.lastReportedBucket == currentBucket) revert AlreadyReportedInCurrentBucket(); BlockBucket storage bucket = blockBuckets[currentBucket]; bucket.reporters.push(msg.sender); bucket.prices.push(price); n.lastReportedBucket = currentBucket; n.reportCount++; emit PriceReported(msg.sender, price, currentBucket); } /** * @notice Allows active and inactive nodes to claim accumulated ORA token rewards * @dev Calculates rewards based on time elapsed since last claim. */ function claimReward() public { OracleNode storage node = nodes[msg.sender]; uint256 delta = node.reportCount - node.claimedReportCount; if (delta == 0) revert NoRewardsAvailable(); node.claimedReportCount = node.reportCount; oracleToken.mint(msg.sender, delta * REWARD_PER_REPORT); emit NodeRewarded(msg.sender, delta * REWARD_PER_REPORT); } /** * @notice Allows a registered node to increase its ORA token stake */ function addStake(uint256 amount) public onlyNode { if (amount == 0) revert InsufficientStake(); bool success = oracleToken.transferFrom(msg.sender, address(this), amount); if (!success) revert TransferFailed(); nodes[msg.sender].stakedAmount += amount; emit StakeAdded(msg.sender, amount); } /** * @notice Records the median price for a bucket once sufficient reports are available * @dev Anyone who uses the oracle's price feed can call this function to record the median price for a bucket. * @param bucketNumber The bucket number to finalize */ function recordBucketMedian(uint256 bucketNumber) public { uint256 currentBucket = getCurrentBucketNumber(); if (bucketNumber == currentBucket) revert OnlyPastBucketsAllowed(); BlockBucket storage bucket = blockBuckets[bucketNumber]; if (bucket.medianPrice != 0) revert BucketMedianAlreadyRecorded(); uint256[] memory prices = bucket.prices; prices.sort(); uint256 medianPrice = prices.getMedian(); bucket.medianPrice = medianPrice; emit BucketMedianRecorded(bucketNumber, medianPrice); } /** * @notice Slashes a node for giving a price that is deviated too far from the average * @param nodeToSlash The address of the node to slash * @param bucketNumber The bucket number to slash the node from * @param reportIndex The index of node in the prices and reporters arrays * @param nodeAddressesIndex The index of the node to slash in the nodeAddresses array */ function slashNode( address nodeToSlash, uint256 bucketNumber, uint256 reportIndex, uint256 nodeAddressesIndex ) public { if (bucketNumber == getCurrentBucketNumber()) revert OnlyPastBucketsAllowed(); if (nodeAddressesIndex >= nodeAddresses.length) revert IndexOutOfBounds(); BlockBucket storage bucket = blockBuckets[bucketNumber]; address reporter = bucket.reporters[reportIndex]; if (bucket.slashedOffenses[reporter]) revert NodeAlreadySlashed(); if (bucket.medianPrice == 0) revert MedianNotRecorded(); if (reportIndex >= bucket.prices.length) revert IndexOutOfBounds(); if (reporter != nodeToSlash) revert NodeNotAtGivenIndex(); uint256 reportedPrice = bucket.prices[reportIndex]; if (reportedPrice == 0) revert NodeDidNotReport(); bool isOutlier = _checkPriceDeviated(reportedPrice, bucket.medianPrice); if (!isOutlier) revert NotDeviated(); bucket.slashedOffenses[reporter] = true; OracleNode storage node = nodes[nodeToSlash]; uint256 actualPenalty = MISREPORT_PENALTY > node.stakedAmount ? node.stakedAmount : MISREPORT_PENALTY; node.stakedAmount -= actualPenalty; if (node.stakedAmount == 0) { _removeNode(nodeToSlash, nodeAddressesIndex); emit NodeExited(nodeToSlash, 0); } uint256 reward = (actualPenalty * SLASHER_REWARD_PERCENTAGE) / 100; bool rewardSent = oracleToken.transfer(msg.sender, reward); if (!rewardSent) revert TransferFailed(); emit NodeSlashed(nodeToSlash, actualPenalty); } /** * @notice Allows a registered node to exit the system and withdraw their stake * @dev Removes the node from the system and sends the stake to the node. * Requires that the the initial waiting period has passed to ensure the * node has been slashed if it reported a bad price before allowing it to exit. * @param index The index of the node to remove in nodeAddresses */ function exitNode(uint256 index) public onlyNode { if (index >= nodeAddresses.length) revert IndexOutOfBounds(); if (nodeAddresses[index] != msg.sender) revert NodeNotAtGivenIndex(); OracleNode storage node = nodes[msg.sender]; if (!node.active) revert NodeNotRegistered(); if (node.lastReportedBucket + WAITING_PERIOD > getCurrentBucketNumber()) revert WaitingPeriodNotOver(); uint256 effectiveStake = getEffectiveStake(msg.sender); _removeNode(msg.sender, index); node.stakedAmount = 0; node.active = false; bool success = oracleToken.transfer(msg.sender, effectiveStake); if (!success) revert TransferFailed(); emit NodeExited(msg.sender, effectiveStake); } //////////////////////// /// View Functions ///// //////////////////////// /** * @notice Returns the current bucket number * @dev Returns the current bucket number based on the block number * @return The current bucket number */ function getCurrentBucketNumber() public view returns (uint256) { return (block.number / BUCKET_WINDOW) + 1; } /** * @notice Returns the list of registered oracle node addresses * @return Array of registered oracle node addresses */ function getNodeAddresses() public view returns (address[] memory) { return nodeAddresses; } /** * @notice Returns the stored median price from the most recently completed bucket * @dev Requires that the median for the bucket be recorded via recordBucketMedian * @return The median price for the last finalized bucket */ function getLatestPrice() public view returns (uint256) { uint256 bucketNumber = getCurrentBucketNumber() - 1; BlockBucket storage bucket = blockBuckets[bucketNumber]; if (bucket.medianPrice == 0) revert MedianNotRecorded(); return bucket.medianPrice; } /** * @notice Returns the stored median price from a specified bucket * @param bucketNumber The bucket number to read the median price from * @return The median price stored for the bucket */ function getPastPrice(uint256 bucketNumber) public view returns (uint256) { BlockBucket storage bucket = blockBuckets[bucketNumber]; if (bucket.medianPrice == 0) revert MedianNotRecorded(); return bucket.medianPrice; } /** * @notice Returns the price and slashed status of a node at a given bucket * @param nodeAddress The address of the node to get the data for * @param bucketNumber The bucket number to get the data from * @return price The price of the node at the specified bucket * @return slashed The slashed status of the node at the specified bucket */ function getSlashedStatus( address nodeAddress, uint256 bucketNumber ) public view returns (uint256 price, bool slashed) { BlockBucket storage bucket = blockBuckets[bucketNumber]; for (uint256 i = 0; i < bucket.reporters.length; i++) { if (bucket.reporters[i] == nodeAddress) { price = bucket.prices[i]; slashed = bucket.slashedOffenses[nodeAddress]; } } } /** * @notice Returns the effective stake accounting for inactivity penalties via missed buckets * @dev Effective stake = stakedAmount - (missedBuckets * INACTIVITY_PENALTY), floored at 0 */ function getEffectiveStake(address nodeAddress) public view returns (uint256) { OracleNode memory n = nodes[nodeAddress]; // get node detail if (!n.active) return 0; // inactive node uint256 currentBucket = getCurrentBucketNumber(); if (currentBucket == n.firstBucket) return n.stakedAmount; // basically the node just registered itself uint256 expectedReports = currentBucket - n.firstBucket; // get the amount of "buckets" since registration uint256 actualReportsCompleted = n.reportCount; if (n.lastReportedBucket == currentBucket && actualReportsCompleted > 0) { actualReportsCompleted -= 1; // we remove the report from current bucket } if (actualReportsCompleted >= expectedReports) return n.stakedAmount; // no penalty uint256 missed = expectedReports - actualReportsCompleted; uint256 penalty = missed * INACTIVITY_PENALTY; if (penalty > n.stakedAmount) return 0; // the amount of penalty is more than the staked amount return n.stakedAmount - penalty; } /** * @notice Returns the addresses of nodes in a bucket whose reported price deviates beyond the threshold * @param bucketNumber The bucket number to get the outliers from * @return Array of node addresses considered outliers */ function getOutlierNodes(uint256 bucketNumber) public view returns (address[] memory) { BlockBucket storage bucket = blockBuckets[bucketNumber]; if (bucket.medianPrice == 0) revert MedianNotRecorded(); address[] memory outliers = new address[](bucket.reporters.length); uint256 outlierCount = 0; for (uint256 i = 0; i < bucket.reporters.length; i++) { address reporter = bucket.reporters[i]; if (bucket.slashedOffenses[reporter]) continue; uint256 reportedPrice = bucket.prices[i]; if (reportedPrice == 0) continue; bool isOutlier = _checkPriceDeviated(reportedPrice, bucket.medianPrice); if (isOutlier) { outliers[outlierCount] = reporter; outlierCount++; } } address[] memory fixedOutliers = new address[](outlierCount); for (uint256 i = 0; i < outlierCount; i++) { fixedOutliers[i] = outliers[i]; } return fixedOutliers; } ////////////////////////// /// Internal Functions /// ////////////////////////// /** * @notice Removes a node from the nodeAddresses array * @param nodeAddress The address of the node to remove * @param index The index of the node to remove */ function _removeNode(address nodeAddress, uint256 index) internal { if (index >= nodeAddresses.length) revert IndexOutOfBounds(); address storedNodeAddress = nodeAddresses[index]; if (storedNodeAddress != nodeAddress) revert NodeNotAtGivenIndex(); if (index != nodeAddresses.length - 1) { nodeAddresses[index] = nodeAddresses[nodeAddresses.length - 1]; } nodeAddresses.pop(); nodes[nodeAddress].active = false; } /** * @notice Checks if the price deviation is greater than the threshold * @param reportedPrice The price reported by the node * @param medianPrice The average price of the bucket * @return True if the price deviation is greater than the threshold, false otherwise */ function _checkPriceDeviated(uint256 reportedPrice, uint256 medianPrice) internal pure returns (bool) { uint256 deviation = reportedPrice > medianPrice ? reportedPrice - medianPrice : medianPrice - reportedPrice; uint256 deviationBps = (deviation * 10_000) / medianPrice; return deviationBps > MAX_DEVIATION_BPS; } }