426 lines
16 KiB
Solidity
426 lines
16 KiB
Solidity
// 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;
|
|
}
|
|
}
|