diff --git a/README.md b/README.md index d8a2c9f8..9e9378f7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,2043 @@ -# Base Challenge Extension Template -> โš ๏ธ This is not meant to be used as an extension by itself. Follow instructions [here](https://github.com/scaffold-eth/se-2-challenges/blob/main/README.md#-contributing-guide-and-hints-to-create-new-challenges) to see how to use this set of files. +# ๐Ÿ”ฎ Oracle Challenge -You can use these files to help set up a new extension that matches the style and feel of [SpeedRunEthereum](https://speedrunethereum.com/). +![readme-oracle](https://raw.githubusercontent.com/scaffold-eth/se-2-challenges/challenge-oracles/extension/packages/nextjs/public/hero.png) -There are `// CHALLENGE-TODO:` comments placed throughout that provide instructions on key changes to adapt this template for use with a new challenge. +๐Ÿ”— Build your own decentralized oracle systems! In this challenge, you'll explore three fundamental oracle architectures that power the decentralized web: **Whitelist Oracle**, **Staking Oracle**, and **Optimistic Oracle**. + +๐Ÿง  You'll dive deep into the mechanics of bringing real-world data onto the blockchain, understanding the critical trade-offs between security, decentralization, and efficiency. Each oracle design represents a different approach to solving the fundamental problem: How can we trust data from outside the blockchain, and how do we securely bring it on-chain? + +
โ“ Wondering what an oracle is? Read the overview here. + +Oracles are bridges between blockchains and the external world. They solve a fundamental problem: smart contracts can only access data that exists on the blockchain, but most real-world data (prices, weather, sports scores, etc.) exists off-chain. + +๐Ÿค” Why are oracles important? + +- **DeFi Protocols**: Need accurate price feeds for lending, trading, and liquidation +- **Insurance**: Require real-world event verification (weather, flight delays) +- **Gaming**: Need random numbers and external event outcomes +- **Supply Chain**: Track real-world goods and events + +๐Ÿ”’ Why are oracles difficult? + +- **Trust**: How do we know the oracle is telling the truth? +- **Centralization**: Single points of failure can compromise entire protocols +- **Incentives**: How do we align oracle behavior with protocol needs? +- **Latency**: Real-time data needs to be fresh and accurate + +๐Ÿ‘ Now that you understand the basics, let's look at three different oracle systems! + +
+ +--- + +๐ŸŒŸ The final deliverable is a comprehensive understanding of oracle architectures through hands-on implementation. You'll explore three oracle systems, a Whitelist oracle, Staking-based oracle and an Optimistic oracle, implementing each one. In the end you will deploy your optimistic oracle to a testnet and demonstrate how it handles assertions, proposals, disputes, and settlements. + +๐Ÿ” First, let's understand why we need multiple oracle designs. Each approach has different strengths: + +- **Whitelist Oracle**: Simple and fast, but requires trust in a centralized authority +- **Staking Oracle**: Decentralized with economic incentives, but more complex +- **Optimistic Oracle**: Dispute-based with strong security guarantees, but higher latency + +๐Ÿ“š This challenge is inspired by real-world oracle systems like [Chainlink](https://chain.link/), [Pyth Network](https://www.pyth.network/), and [UMA Protocol](https://uma.xyz/). + +๐Ÿ’ฌ Meet other builders working on this challenge and get help in the [Oracle Challenge Telegram](https://t.me/+AkmcMB3jC3A0NDcx) + +--- + +## Checkpoint 0: ๐Ÿ“ฆ Environment ๐Ÿ“š + +> ๐Ÿ’ป Start your local network (a blockchain emulator in your computer): + +```sh + +npx create-eth@1.0.2 -e scaffold-eth/se-2-challenges:challenge-oracles challenge-oracles + +cd challenge-oracles + +``` + +> ๐Ÿ’ป In the same terminal, start your local network (a blockchain emulator in your computer): + +```sh + +yarn chain + +``` + +> ๐Ÿ›ฐ๏ธ In a second terminal window, deploy your contract (locally): + +```sh + +yarn deploy + +``` + +> ๐Ÿšจ This will likely fail when you run it since the contracts aren't ready to be deployed yet + +> ๐Ÿ“ฑ In a third terminal window, start your frontend: + +```sh + +yarn start + +``` + +๐Ÿ“ฑ Open http://localhost:3000 to see the app. + +> ๐Ÿ‘ฉโ€๐Ÿ’ป Rerun `yarn deploy` whenever you want to deploy new contracts to the frontend. If you haven't made any contract changes, you can run `yarn deploy --reset` for a completely fresh deploy. + +--- + +## Checkpoint 1: ๐Ÿ›๏ธ Whitelist Oracle Overview + +๐Ÿ” Let's start with the simplest of the three oracle designs we'll cover: the Whitelist Oracle. This design uses a centralized authority to control which data sources can provide information, making it simple and fast but requiring trust. + +๐Ÿ’ฐ The implementation we'll be looking at is a **price** oracle. Price oracles are one of the most common and critical types of oracles in DeFi, as they enable smart contracts to make decisions based on real-world asset prices. Our whitelist price oracle collects price reports from multiple trusted sources (instances of `SimpleOracle`) and returns their median value. + +๐Ÿงญ Let's understand how this oracle system works. We'll examine both the basic building block (SimpleOracle) and how multiple simple oracles can be combined into a more robust system (WhitelistOracle). + +### ๐Ÿ”— Simple Oracle - The Building Block + +๐Ÿ” Open the `packages/hardhat/contracts/00_Whitelist/SimpleOracle.sol` file to examine the basic oracle functionality. + +#### ๐Ÿ“– Understanding the Code: + +๐Ÿงฉ The `SimpleOracle` contract is the fundamental building block of this oracle system: + +1. **Constructor** - Takes an `_owner` address parameter to set who can update the oracle price + +2. **`setPrice(uint256 _newPrice)`** - This function allows the contract owner to update the current price + + * ๐Ÿ”„ Updates the `price` state variable with the new value + + * โฑ๏ธ Updates the `timestamp` to the current block timestamp + + * ๐Ÿ“ฃ Emits the `PriceUpdated` event with the new price + +3. **`getPrice()`** - This function returns both the current price and timestamp + + * โ†ฉ๏ธ Returns them as a tuple: `(price, timestamp)` + +#### ๐Ÿค” Key Insights: + +- **Single Source**: Each SimpleOracle represents one data source +- **Trust Model**: Requires complete trust in whoever updates the price +- **Limitations**: No consensus mechanism, no economic incentives + +### ๐Ÿ›๏ธ Whitelist Oracle - Aggregating Multiple Sources + +๐ŸŽฏ **Your Mission**: Complete the missing function implementations in the `WhitelistOracle.sol` contract. + +๐Ÿ” Open the `packages/hardhat/contracts/00_Whitelist/WhitelistOracle.sol` file to implement the whitelist oracle functionality. + +#### ๐Ÿ“– Understanding the Relationship: + +The `WhitelistOracle` contract **creates and manages multiple SimpleOracle contracts**: + +```solidity + +SimpleOracle[] public oracles; // Array of SimpleOracle contract instances + +``` + +๐Ÿ—๏ธ This creates a **hierarchical oracle system**: + +- **Individual Level**: Each SimpleOracle contract is managed by a trusted data provider (set during oracle creation) +- **Aggregation Level**: The WhitelistOracle creates, manages, and processes data from all whitelisted SimpleOracle contracts + +### โœ๏ธ Tasks: + +1. **Implement `addOracle(address _owner)`** + +* ๐Ÿญ This function allows the contract owner to add a new oracle to the whitelist by deploying a SimpleOracle contract + +* ๐Ÿงฉ It should create a new `SimpleOracle` instance with the specified `_owner` + +* โž• It should add the newly created SimpleOracle to the `oracles` array + +* ๐Ÿ“ฃ It should emit the `OracleAdded` event with both the oracle address and its owner + +
+ +๐Ÿ’ก Hint: Creating and Adding Oracles + +Here's what you need to do: +- Create a new SimpleOracle contract instance using `new SimpleOracle(_owner)` +- Get the address of the newly created oracle using `address(newOracle)` +- Push the oracle instance to the `oracles` array +- Emit the `OracleAdded` event with the oracle address and owner + +
+ +๐ŸŽฏ Solution + +```solidity +function addOracle(address _owner) public onlyOwner { + SimpleOracle newOracle = new SimpleOracle(_owner); + address oracleAddress = address(newOracle); + + oracles.push(newOracle); + emit OracleAdded(oracleAddress, _owner); +} +``` + +
+
+ +--- + +2. **Implement `removeOracle(uint256 index)`** + +* โœ”๏ธ This function allows the contract owner to remove an oracle from the whitelist by its array index + +* ๐Ÿ” It should validate that the provided index is within bounds, otherwise revert with `IndexOutOfBounds` + +* ๐Ÿ“ It should record the oracle address before removal for the event + +* โž– It should efficiently remove the oracle using swap-and-pop pattern (swap with last element, then pop) + +* ๐Ÿ“ฃ It should emit the `OracleRemoved` event with the oracle address + +
+ +๐Ÿ’ก Hint: Safe Array Removal + +The swap-and-pop pattern: +- Check if index is valid (< oracles.length) +- Store the oracle address for the event +- If not the last element, swap with the last element +- Pop the last element +- Emit the removal event + +This is much more gas efficient than deleting the element and moving all the entries beyond it over one space. O(1) vs O(n). + +
+ +๐ŸŽฏ Solution + +```solidity +function removeOracle(uint256 index) public onlyOwner { + if (index >= oracles.length) revert IndexOutOfBounds(); + + address oracleAddress = address(oracles[index]); + + if (index != oracles.length - 1) { + oracles[index] = oracles[oracles.length - 1]; + } + + oracles.pop(); + + emit OracleRemoved(oracleAddress); +} +``` + +
+
+ +--- + +3. **Implement `getPrice()`** + +* ๐Ÿ“Š This function aggregates prices from all active oracles using median calculation + +* โ›”๏ธ It should revert with `NoOraclesAvailable` if no oracles exist in the whitelist + +* ๐Ÿ” It should loop through each oracle and call `getPrice()` to get `(price, timestamp)` + +* ๐Ÿงน It should filter out stale prices (older than `STALE_DATA_WINDOW = 24 seconds`) + +* ๐Ÿ“ฆ It should collect only fresh prices into a properly sized array + +* ๐Ÿงฎ It should use StatisticsUtils library to sort prices and calculate the median + +
+ +๐Ÿ’ก Hint: Price Aggregation with Freshness Check + +Here's the process: +- Check if any oracles exist +- Create a temporary array to collect fresh prices +- Loop through all oracles, get their (price, timestamp) +- Check if timestamp is within STALE_DATA_WINDOW of current time +- Collect valid prices and count them +- Create a right-sized array with only valid prices +- Sort and get median using StatisticsUtils + +
+ +๐ŸŽฏ Solution + +```solidity +function getPrice() public view returns (uint256) { + if (oracles.length == 0) revert NoOraclesAvailable(); + + // Collect prices and timestamps from all oracles + uint256[] memory prices = new uint256[](oracles.length); + uint256 validCount = 0; // Count of valid prices + uint256 currentTime = block.timestamp; + + for (uint256 i = 0; i < oracles.length; i++) { + (uint256 price, uint256 timestamp) = oracles[i].getPrice(); + // Check if the timestamp is within the last STALE_DATA_WINDOW + if (currentTime - timestamp < STALE_DATA_WINDOW) { + prices[validCount] = price; + validCount++; + } + } + + uint256[] memory validPrices = new uint256[](validCount); + for (uint256 i = 0; i < validCount; i++) { + validPrices[i] = prices[i]; + } + + validPrices.sort(); + return validPrices.getMedian(); +} +``` + +
+
+ +--- + +4. **Implement `getActiveOracleNodes()`** + +* ๐Ÿ“Š This function returns the addresses of all oracles that have updated their price within the last `STALE_DATA_WINDOW` + +* ๐Ÿ” It should iterate through all oracles and filter those with recent timestamps + +* ๐Ÿ“ฆ It should use a temporary array to collect active nodes, then create a right-sized return array for gas optimization + +* ๐ŸŽฏ It should return an array of addresses representing the currently active oracle contracts + +
+ +๐Ÿ’ก Hint: Active Node Filtering + +Similar to getPrice(), but instead of collecting prices, collect oracle addresses: +- Create temporary array to store addresses +- Loop through oracles, check timestamp freshness +- Count and collect active oracle addresses +- Create properly sized result array +- Return the active oracle addresses + +
+ +๐ŸŽฏ Solution + +```solidity +function getActiveOracleNodes() public view returns (address[] memory) { + address[] memory tempNodes = new address[](oracles.length); + uint256 count = 0; + + for (uint256 i = 0; i < oracles.length; i++) { + (, uint256 timestamp) = oracles[i].getPrice(); + if (timestamp > block.timestamp - STALE_DATA_WINDOW) { + tempNodes[count] = address(oracles[i]); + count++; + } + } + + address[] memory activeNodes = new address[](count); + for (uint256 j = 0; j < count; j++) { + activeNodes[j] = tempNodes[j]; + } + + return activeNodes; +} +``` + +
+
+ +### ๐Ÿค” Key Insights: + +- **Factory Pattern**: WhitelistOracle creates and manages SimpleOracle contracts +- **Centralized Authority**: Only the owner can add/remove SimpleOracle contracts +- **Consensus Mechanism**: Uses median calculation with StatisticsUtils library to resist outliers +- **Freshness Check**: Filters out stale data from any SimpleOracle +- **Trust Model**: Requires trust in the whitelist authority and each SimpleOracle provider +- **Use Cases**: Good for controlled environments where you trust the centralized entity or where things fall back to the rule of law (RWAs) + +### ๐Ÿ”„ How They Work Together: + +1. **Data Flow**: + +``` + +SimpleOracle A โ†’ setPrice(100) โ†’ getPrice() โ†’ (100, timestamp) + +SimpleOracle B โ†’ setPrice(102) โ†’ getPrice() โ†’ (102, timestamp) + +SimpleOracle C โ†’ setPrice(98) โ†’ getPrice() โ†’ (98, timestamp) + +``` + +2. **Aggregation**: + +``` + +WhitelistOracle โ†’ getPrice() โ†’ [100, 102, 98] โ†’ sort โ†’ [98, 100, 102] โ†’ median(100) โ†’ 100 + +``` + +3. **Benefits**: + +- **Redundancy**: If one SimpleOracle fails, others continue providing data + +- **Outlier Resistance**: Median calculation ignores extreme values + +- **Freshness**: Stale data from any SimpleOracle is filtered out + +### ๐Ÿค” Critical Thinking: Security Vulnerabilities + +- **Question**: How could this whitelist oracle design be exploited or taken advantage of? What are the main attack vectors? + +
+ +๐Ÿ’ก Click to see potential vulnerabilities + +1. ๐Ÿ”“ **Whitelist Authority Compromise**: If the owner's private key is compromised, an attacker could: + + - Remove all legitimate oracles and add malicious ones + + - Manipulate which data sources are trusted + + - Add multiple oracles they control to skew the median + +2. ๐Ÿ‘ฅ **Collusion Among Whitelisted Providers**: If enough whitelisted oracle providers collude, they could: + + - Report coordinated false prices to manipulate the median + + - Extract value from protocols relying on the oracle + +3. ๐Ÿ”“ **Data Provider Compromise**: Individual SimpleOracle operators could: + + - Be hacked or coerced to report false prices + + - Sell their influence to manipulators + +๐Ÿ’ก *Real-World Impact*: These vulnerabilities explain why protocols like [MakerDAO/Sky](https://github.com/sky-ecosystem/medianizer) eventually moved to more decentralized oracle systems as the stakes grew higher! + +
+ +--- + +### Testing your progress + +๐Ÿ” Run the following command to check if you implemented the functions correctly. + +```sh + +yarn test --grep "Checkpoint1" + +``` + +โœ… Did the tests pass? You can dig into any errors by viewing the tests at `packages/hardhat/test/WhitelistOracle.ts`. + +### Try it out! + +๐Ÿ”„ Run `yarn deploy --reset` then test the whitelist oracle. Try adding and removing oracles, and observing how the aggregated price changes. + +![WhiteListOracle](https://github.com/user-attachments/assets/1fabc2d8-a1a4-4b0e-b00c-49f3fefec7fc) + +๐Ÿ‘Š Notice how the onlyOwner modifiers are commented out to allow you to have full control. Try manually changing the price of individual SimpleOracle contracts and adding new oracle nodes to see how the aggregated price changes: + +1. **Change Prices**: Use the frontend to modify individual oracle prices + +2. **Add New Nodes**: Create new SimpleOracle contracts through the whitelist oracle + +3. **Observe Aggregation**: Watch how the median price changes as you add/remove oracles + +๐Ÿงช **Live Simulation**: Run the `yarn simulate:whitelist` command to see what a live version of this protocol might look like in action: + +```sh + +yarn simulate:whitelist + +``` + +๐Ÿค– This will start automated bots that simulate real oracle behavior, showing you how the system would work in production with multiple active price feeds. + +### ๐Ÿฅ… Goals: + +- You can add new SimpleOracle instances to the whitelist +- System aggregates prices from active oracles using median calculation +- Stale data is automatically filtered out based on timestamps +- You can query which oracle nodes are currently active +- The system correctly handles edge cases and invalid states +- Understand the benefits of aggregating multiple data sources +- Look at these examples "in the wild" from early DeFi: [Simple Oracle](https://github.com/dapphub/ds-value), +[Whitelist Oracle](https://github.com/sky-ecosystem/medianizer) + +--- + +## Checkpoint 2: ๐Ÿ’ฐ Staking Oracle - Economic Incentives + +๐Ÿงญ Now let's explore a decentralized oracle that uses economic incentives to ensure honest behavior. Nodes stake ETH to participate and can be slashed for reporting prices that deviate too far from the average. The system uses a bucket-based approach where prices are organized into discrete time windows (24 blocks per bucket), and nodes must report once per bucket or else there will be a small inactivity leak in their stake. We will also issue rewards in the form of an ERC20 token called ORA (1 ORA per report) to incentivise participation in the system. + +๐Ÿ‘ฉโ€๐Ÿ’ป This section challenges you to implement the staking oracle system from scratch. You'll write the core functions that handle node registration, bucket-based price reporting, reward distribution based on report count, and slashing mechanisms for price deviations. + +๐ŸŽฏ **Your Mission**: Complete the missing function implementations in the `StakingOracle.sol` contract. The contract skeleton is already provided with all the necessary structs, events, and modifiers but you need to fill in the logic. + +๐Ÿ” Open the `packages/hardhat/contracts/01_Staking/StakingOracle.sol` file to implement the staking oracle functionality. + +### โœ๏ธ Tasks: + +1. **Implement `getCurrentBucketNumber()`** + +* ๐Ÿ•’ This view function maps the current `block.number` into a bucket index (24-block window) + +* ๐Ÿงฎ It should divide the block number by `BUCKET_WINDOW` and add 1 (buckets are indexed starting from 1, not 0) + +
+ +๐Ÿ’ก Hint: Bucket Number + +- Buckets advance every `BUCKET_WINDOW` blocks +- Integer division will floor the result automatically +- Remember to add 1 so the very first bucket starts at index 1 + +
+ +๐ŸŽฏ Solution + +```solidity +function getCurrentBucketNumber() public view returns (uint256) { + return (block.number / BUCKET_WINDOW) + 1; +} +``` + +
+
+ +--- + +2. **Implement `getEffectiveStake(address nodeAddress)`** + +* ๐Ÿ“‰ This view function returns a node's stake after inactivity penalties + +* ๐Ÿ” It should return `0` for inactive nodes + +* ๐Ÿงฎ It should compute expected reports based on completed buckets since registration + +* โœ‚๏ธ For each missed report, subtract `INACTIVITY_PENALTY`, floored at zero + +
+ +๐Ÿ’ก Hint: Effective Stake + +- Load the node into memory for cheaper reads +- If the node is inactive, return `0` +- Determine `currentBucket` using `getCurrentBucketNumber()` +- Expected reports = `currentBucket - n.firstBucket` +- Use `n.reportCount` as completed reports but subtract one if the last report happened in the current bucket (it isn't completed yet) +- Penalty = `missed * INACTIVITY_PENALTY`; cap at the staked amount + +
+ +๐ŸŽฏ Solution + +```solidity +function getEffectiveStake(address nodeAddress) public view returns (uint256) { + OracleNode memory n = nodes[nodeAddress]; + if (!n.active) return 0; + uint256 currentBucket = getCurrentBucketNumber(); + if (currentBucket == n.firstBucket) return n.stakedAmount; + uint256 expectedReports = currentBucket - n.firstBucket; + uint256 actualReportsCompleted = n.reportCount; + if (n.lastReportedBucket == currentBucket && actualReportsCompleted > 0) { + actualReportsCompleted -= 1; + } + if (actualReportsCompleted >= expectedReports) return n.stakedAmount; + uint256 missed = expectedReports - actualReportsCompleted; + uint256 penalty = missed * INACTIVITY_PENALTY; + if (penalty > n.stakedAmount) return 0; + return n.stakedAmount - penalty; +} +``` + +
+
+ +--- + +3. **Implement `registerNode(uint256 price)`** + +* ๐Ÿ—๏ธ This function allows users to register as oracle nodes by staking ETH + +* โš ๏ธ It should require a minimum stake of 1 ETH, otherwise revert with `InsufficientStake` + +* ๐Ÿงช It should check that the node is not already registered, otherwise revert with `NodeAlreadyRegistered` + +* ๐Ÿ—๏ธ It should create a new `OracleNode` struct with the correct data + +* โž• It should add the node address to the `nodeAddresses` array + +* ๐Ÿ’ฒ It should call `reportPrice(price)` (you'll implement this later) to record the first report in the current bucket + +* ๐Ÿ“ฃ It should emit the `NodeRegistered` event + +
+ +๐Ÿ’ก Hint: Node Registration + +Here's what you need to set in the `OracleNode` struct: +- `stakedAmount` should be `msg.value` +- `lastReportedBucket` should be `0` (will be updated in `reportPrice`) +- `reportCount` should be `0` +- `claimedReportCount` should be `0` +- `firstBucket` should be `getCurrentBucketNumber()` (the bucket when the node registered) +- `active` should be `true` + +After creating the struct, push the node into `nodeAddresses`, call `reportPrice(price)`, and emit the event. + +
+ +๐ŸŽฏ Solution + +```solidity +function registerNode(uint256 price) public payable { + if (msg.value < MINIMUM_STAKE) revert InsufficientStake(); + if (nodes[msg.sender].active) revert NodeAlreadyRegistered(); + nodes[msg.sender] = OracleNode({ + stakedAmount: msg.value, + lastReportedBucket: 0, + reportCount: 0, + claimedReportCount: 0, + firstBucket: getCurrentBucketNumber(), + active: true + }); + nodeAddresses.push(msg.sender); + reportPrice(price); + emit NodeRegistered(msg.sender, msg.value); +} +``` + +
+
+ +--- + +4. **Implement `addStake()`** + +* ๐Ÿ’ธ This payable function lets an active node increase its stake + +* โš ๏ธ It should revert with `InsufficientStake` if `msg.value == 0` + +* โž• It should add the sent value to the node's `stakedAmount` + +* ๐Ÿ“ฃ It should emit the `StakeAdded` event + +
+ +๐Ÿ’ก Hint: Adding Stake + +- Use the `onlyNode` modifier to ensure sender is active +- Update `nodes[msg.sender].stakedAmount` +- Emit `StakeAdded` + +
+ +๐ŸŽฏ Solution + +```solidity +function addStake() public payable onlyNode { + if (msg.value == 0) revert InsufficientStake(); + nodes[msg.sender].stakedAmount += msg.value; + emit StakeAdded(msg.sender, msg.value); +} +``` + +
+
+ +--- + +5. **Implement `reportPrice(uint256 price)`** + +* ๐Ÿงช This function allows registered nodes to report new prices (uses `onlyNode` modifier) + +* ๐Ÿ” It should verify the given price is not zero, otherwise revert with `InvalidPrice` + +* ๐Ÿ” It should verify the node has sufficient stake (using `getEffectiveStake`), otherwise revert with `InsufficientStake` + +* ๐Ÿšซ It should prevent reporting twice in the same bucket, otherwise revert with `AlreadyReportedInCurrentBucket` + +* ๐Ÿ“Š It should store the node's price in the current bucket's `TimeBucket` mapping + +* ๐Ÿ“ˆ It should increment the bucket's `countReports` and add the price to `sumPrices`. This will be useful for deriving the average later + +* ๐Ÿ”„ It should update the node's `lastReportedBucket` and `reportCount` + +* ๐Ÿ“ฃ It should emit the `PriceReported` event with the sender, price, and bucket number + +
+ +๐Ÿ’ก Hint: Price Reporting + +- Pull `OracleNode storage node = nodes[msg.sender]` +- Validate price and stake before touching bucket state +- Use `timeBuckets[getCurrentBucketNumber()]` +- Update bucket mappings and aggregates +- Update the node's `lastReportedBucket` to `getCurrentBucketNumber()` +- Increment the node's `reportCount` + +
+ +๐ŸŽฏ Solution + +```solidity +function reportPrice(uint256 price) public onlyNode { + if (price == 0) revert InvalidPrice(); + OracleNode storage node = nodes[msg.sender]; + if (getEffectiveStake(msg.sender) < MINIMUM_STAKE) revert InsufficientStake(); + if (node.lastReportedBucket == getCurrentBucketNumber()) revert AlreadyReportedInCurrentBucket(); + TimeBucket storage bucket = timeBuckets[getCurrentBucketNumber()]; + bucket.prices[msg.sender] = price; + bucket.countReports++; + bucket.sumPrices += price; + + node.lastReportedBucket = getCurrentBucketNumber(); + node.reportCount++; + emit PriceReported(msg.sender, price, getCurrentBucketNumber()); +} +``` + +
+
+ +--- + +6. **Implement `claimReward()`** + +* ๐Ÿงช This function allows past and present nodes to claim their ORA token rewards + +* ๐Ÿ” It should calculate reward amount based on the difference between `reportCount` and `claimedReportCount`. We will call this number the `delta` + +* ๐Ÿ”’ It should revert with `NoRewardsAvailable` if `delta == 0` + +* ๐Ÿ”ข It should update `claimedReportCount` to `reportCount` *before* minting the tokens (reentrancy safe) + +* ๐Ÿ’ฐ It should mint `delta * REWARD_PER_REPORT` ORA tokens + +* ๐Ÿ“ฃ It should emit the `NodeRewarded` event + +
+ +๐Ÿ’ก Hint: Reward Implementation + +- Load the node in storage +- Compute `delta` +- Revert if `delta == 0` +- Update `claimedReportCount` +- Mint the reward and emit the event + +
+ +๐ŸŽฏ Solution + +```solidity +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); +} +``` + +
+
+ +--- + +7. **Implement `_removeNode(address nodeAddress, uint256 index)`** + +* ๐Ÿ—‚๏ธ This internal function removes a node from the `nodeAddresses` array while keeping the array packed. By forcing the caller to provide the index and simply verifying it is at that position we are removing the need to iterate over a potentially large array + +* ๐Ÿ” It should ensure the provided `index` is within bounds, otherwise revert with `IndexOutOfBounds` + +* โœ… It should ensure the address at the given index matches `nodeAddress`, otherwise revert with `NodeNotAtGivenIndex` + +* ๐Ÿ” It should use the pop-and-swap pattern to remove the entry efficiently + +* ๐Ÿšซ It should mark the node as inactive without deleting the entire struct (other functions may rely on historical data) + +
+ +๐Ÿ’ก Hint: Removing Nodes + +- Check `index < nodeAddresses.length` +- Check `nodeAddresses[index] == nodeAddress` +- Assign the last address into the `index` slot, then pop +- Set `nodes[nodeAddress].active = false` + +
+ +๐ŸŽฏ Solution + +```solidity +function _removeNode(address nodeAddress, uint256 index) internal { + if (nodeAddresses.length <= index) revert IndexOutOfBounds(); + if (nodeAddresses[index] != nodeAddress) revert NodeNotAtGivenIndex(); + nodeAddresses[index] = nodeAddresses[nodeAddresses.length - 1]; + nodeAddresses.pop(); + nodes[nodeAddress].active = false; +} +``` + +
+
+ +--- + +8. **Implement `_checkPriceDeviated(uint256 reportedPrice, uint256 averagePrice)`** + +* ๐Ÿงฎ This internal pure function determines whether a reported price deviates beyond the allowed threshold + +* ๐Ÿ”ข It should compute the absolute difference between the reported price and the average + +* ๐Ÿ“ It should convert the deviation to basis points and compare it against `MAX_DEVIATION_BPS` + +* ๐Ÿ” It should return `true` when the deviation is greater than the threshold, otherwise `false` + +
+ +๐Ÿ’ก Hint: Deviation Check + +- Use a simple conditional to compute the absolute deviation +- Multiply the deviation by 10,000 (basis points) before dividing by `averagePrice`. This will allow for greater precision +- Compare the result against `MAX_DEVIATION_BPS` + +
+ +๐ŸŽฏ Solution + +```solidity +function _checkPriceDeviated(uint256 reportedPrice, uint256 averagePrice) internal pure returns (bool) { + uint256 deviation = reportedPrice > averagePrice ? reportedPrice - averagePrice : averagePrice - reportedPrice; + uint256 deviationBps = (deviation * 10_000) / averagePrice; + if (deviationBps > MAX_DEVIATION_BPS) { + return true; + } + return false; +} +``` + +
+
+ +--- + +9. **Implement `slashNode(address nodeToSlash, uint256 bucketNumber, uint256 index)`** + +* ๐Ÿ”Ž This function allows anyone to slash nodes that reported prices deviating too far from the average + +* ๐Ÿงช It should verify the node is active, otherwise revert with `NodeNotRegistered` + +* โฐ It should verify the bucket is in the past (not current), otherwise revert with `OnlyPastBucketsAllowed` + +* ๐Ÿšซ It should verify the node hasn't already been slashed in this bucket, otherwise revert with `NodeAlreadySlashed` + +* ๐Ÿ“Š It should verify the node reported a price in this bucket, otherwise revert with `NodeDidNotReport` + +* โœ‚๏ธ It should mark the node as slashed and remove their price from the bucket's sum and count + +* ๐Ÿงฎ It should recalculate the average price after removing the node's price + +* ๐Ÿ” It should verify the node's price deviates beyond the threshold using `_checkPriceDeviated`, otherwise revert with `NotDeviated` + +* ๐Ÿ’ฐ It should slash the node by `MISREPORT_PENALTY` (or their full stake if less) + +* ๐Ÿ… It should send 10% of the penalty to the slasher (`msg.sender`) + +* ๐Ÿ—‘๏ธ It should remove the node if their stake reaches zero after slashing using `_removeNode` + +* โš ๏ธ It should revert with `FailedToSend` if the reward transfer fails + +* ๐Ÿ“ฃ It should emit `NodeSlashed` and `NodeExited` (if the node is removed) + +
+ +๐Ÿ’ก Hint: Complete Slashing Implementation + +Follow these steps: +- Validate node state and bucket recency +- Ensure the node actually reported in that bucket and hasn't been slashed yet +- Remove their contribution from `sumPrices`/`countReports` +- Recompute the average (excluding the offender) and check deviation with `_checkPriceDeviated` +- Apply the penalty by reducing the node's `stakedAmount` +- If the node is fully slashed (`stakedAmount` == 0), remove them with `_removeNode` +- Calculate the reward, and transfer it + +
+ +๐ŸŽฏ Solution + +```solidity +function slashNode(address nodeToSlash, uint256 bucketNumber, uint256 index) public { + if (!nodes[nodeToSlash].active) revert NodeNotRegistered(); + if (getCurrentBucketNumber() == bucketNumber) revert OnlyPastBucketsAllowed(); + TimeBucket storage bucket = timeBuckets[bucketNumber]; + if (bucket.slashedOffenses[nodeToSlash]) revert NodeAlreadySlashed(); + uint256 reportedPrice = bucket.prices[nodeToSlash]; + if (reportedPrice == 0) revert NodeDidNotReport(); + bucket.slashedOffenses[nodeToSlash] = true; + bucket.sumPrices -= reportedPrice; + bucket.countReports--; + uint256 averagePrice = bucket.sumPrices / bucket.countReports; + if (!_checkPriceDeviated(reportedPrice, averagePrice)) { + revert NotDeviated(); + } + OracleNode storage node = nodes[nodeToSlash]; + uint256 actualPenalty = MISREPORT_PENALTY > node.stakedAmount ? node.stakedAmount : MISREPORT_PENALTY; + node.stakedAmount -= actualPenalty; + + uint256 reward = (actualPenalty * SLASHER_REWARD_PERCENTAGE) / 100; + + (bool sent, ) = msg.sender.call{ value: reward }(""); + if (!sent) revert FailedToSend(); + + if (node.stakedAmount == 0) { + _removeNode(nodeToSlash, index); + emit NodeExited(nodeToSlash, 0); + } + + emit NodeSlashed(nodeToSlash, actualPenalty); +} +``` + +
+
+ +--- + +10. **Implement `exitNode(uint256 index)`** + +* ๐Ÿšช This function allows a node to exit and withdraw its stake after a waiting period + +* โณ It should ensure the node waited at least `WAITING_PERIOD` buckets since their last report, otherwise revert with `WaitingPeriodNotOver`. This way there is ample time to slash them before they exit + +* ๐Ÿ’ฐ It should compute the withdrawable stake using `getEffectiveStake` before removing the node + +* ๐Ÿ—‘๏ธ It should call `_removeNode(msg.sender, index)` to mark the node inactive and keep the node array tidy + +* ๐Ÿงน It should send the stake back to the sender + +* โš ๏ธ It should revert with `FailedToSend` if the withdrawal transfer fails + +* ๐Ÿ“ฃ It should emit the `NodeExited` event with the withdrawn amount + +
+ +๐Ÿ’ก Hint: Exit Logic + +- Fetch the node in storage +- Require `node.lastReportedBucket + WAITING_PERIOD <= getCurrentBucketNumber()` +- Compute `stake = getEffectiveStake(msg.sender)` **before** removing +- Call `_removeNode` +- Transfer the stake using `call` +- Emit the event + +
+ +๐ŸŽฏ Solution + +```solidity +function exitNode(uint256 index) public onlyNode { + OracleNode storage node = nodes[msg.sender]; + if (node.lastReportedBucket + WAITING_PERIOD > getCurrentBucketNumber()) revert WaitingPeriodNotOver(); + uint256 stake = getEffectiveStake(msg.sender); + _removeNode(msg.sender, index); + (bool sent, ) = msg.sender.call{ value: stake }(""); + if (!sent) revert FailedToSend(); + + emit NodeExited(msg.sender, stake); +} +``` + +
+
+ +--- + +11. **Implement `getNodeAddresses()`** + +* ๐Ÿ“š This view function should return every registered node address in order. This is convenient for the front-end + +
+ +๐Ÿ’ก Hint: Node List + +- The array `nodeAddresses` tracks the registration order +- Just return the array + +
+ +๐ŸŽฏ Solution + +```solidity +function getNodeAddresses() public view returns (address[] memory) { + return nodeAddresses; +} +``` + +
+
+ +--- + +12. **Implement `getLatestPrice()`** + +* ๐Ÿ“ฆ This function returns the aggregated price from the most recent completed bucket + +* ๐Ÿ” It should get the previous bucket (current bucket - 1) since the current bucket is still being filled + +* ๐Ÿ“Š It should retrieve the `TimeBucket` for that bucket + +* โ›”๏ธ It should revert with `NoValidPricesAvailable` if `bucket.countReports == 0` + +* ๐Ÿงฎ It should return the average price: `bucket.sumPrices / bucket.countReports` + +
+ +๐Ÿ’ก Hint: Latest Price + +- Use `getCurrentBucketNumber() - 1` +- Access the bucket mapping +- Guard against empty buckets +- Return the average + +
+ +๐ŸŽฏ Solution + +```solidity +function getLatestPrice() public view returns (uint256) { + TimeBucket storage bucket = timeBuckets[getCurrentBucketNumber() - 1]; + if (bucket.countReports == 0) revert NoValidPricesAvailable(); + return bucket.sumPrices / bucket.countReports; +} +``` + +
+
+ +--- + +13. **Implement `getPastPrice(uint256 bucketNumber)`** + +* ๐Ÿ•ฐ๏ธ Because we are storing pricing in time-segmented buckets we can enable retrieving a price from any one of these buckets. This view function returns the average price for any historical bucket + +* โ›”๏ธ It should revert with `NoValidPricesAvailable` if that bucket has no reports + +
+ +๐Ÿ’ก Hint: Past Price + +- Grab `TimeBucket storage bucket = timeBuckets[bucketNumber]` +- Check `bucket.countReports` +- Return `bucket.sumPrices / bucket.countReports` + +
+ +๐ŸŽฏ Solution + +```solidity +function getPastPrice(uint256 bucketNumber) public view returns (uint256) { + TimeBucket storage bucket = timeBuckets[bucketNumber]; + if (bucket.countReports == 0) revert NoValidPricesAvailable(); + return bucket.sumPrices / bucket.countReports; +} +``` + +
+
+ +--- + +14. **Implement `getAddressDataAtBucket(address nodeAddress, uint256 bucketNumber)`** + +* ๐Ÿ”Ž This view function returns the price a node reported in a bucket and whether they were slashed there + +
+ +๐Ÿ’ก Hint: Bucket Data + +- Access the bucket mapping and return both `prices[nodeAddress]` and `slashedOffenses[nodeAddress]` + +
+ +๐ŸŽฏ Solution + +```solidity +function getAddressDataAtBucket(address nodeAddress, uint256 bucketNumber) public view returns (uint256, bool) { + TimeBucket storage bucket = timeBuckets[bucketNumber]; + return (bucket.prices[nodeAddress], bucket.slashedOffenses[nodeAddress]); +} +``` + +
+
+ +--- + +15. **Implement `getOutlierNodes(uint256 bucketNumber)`** + +* ๐Ÿ“Š This view function identifies nodes whose price deviates beyond the maximum deviation in a given bucket + +* ๐Ÿ—ƒ๏ธ It should iterate over all `nodeAddresses` (this is fine since it is a view method) + +* ๐Ÿงฎ For each reported price, calculate the average of the remaining reports and test deviation with `_checkPriceDeviated` + +* ๐Ÿงน Collect only the outliers and trim the array before returning + +
+ +๐Ÿ’ก Hint: Outlier Detection + +- Allocate a temporary array of size `bucket.countReports` +- Loop through `nodeAddresses` +- Skip addresses that did not report (`reportedPrice == 0`) +- Compute `averagePrice = (bucket.sumPrices - reportedPrice) / (bucket.countReports - 1)` +- If `_checkPriceDeviated(...)` returns true, store the address in the temp array and increment a counter +- Allocate a trimmed array of length `outlierCount` and copy the collected addresses + +
+ +๐ŸŽฏ Solution + +```solidity +function getOutlierNodes(uint256 bucketNumber) public view returns (address[] memory) { + TimeBucket storage bucket = timeBuckets[bucketNumber]; + address[] memory outliers = new address[](bucket.countReports); + uint256 outlierCount = 0; + for (uint256 i = 0; i < nodeAddresses.length; i++) { + address nodeAddress = nodeAddresses[i]; + uint256 reportedPrice = bucket.prices[nodeAddress]; + if (reportedPrice == 0) continue; + uint256 averagePrice = (bucket.sumPrices - reportedPrice) / (bucket.countReports - 1); + if (_checkPriceDeviated(reportedPrice, averagePrice)) { + outliers[outlierCount] = nodeAddress; + outlierCount++; + } + } + address[] memory trimmed = new address[](outlierCount); + for (uint256 i = 0; i < outlierCount; i++) { + trimmed[i] = outliers[i]; + } + return trimmed; +} +``` + +
+
+ +--- + +### ๐Ÿค” Key Insights: + +- **Bucket-Based System**: Prices are organized into time buckets (24 blocks each), allowing for discrete time windows and preventing double-reporting within the same bucket +- **Economic Incentives**: Nodes stake ETH and can be slashed for reporting prices that deviate too far from the average, while good behavior rewards nodes with ORA tokens (1 ORA per report) +- **Effective Stake**: Nodes face inactivity penalties for missed buckets, reducing their effective stake over time if they fail to report regularly +- **Decentralized**: Anyone can participate by staking, no central authority needed +- **Self-Correcting**: Slashing mechanism punishes nodes that report prices deviating beyond the threshold (10% by default) +- **Average Aggregation**: Prices are aggregated using a simple average from all reports in a completed bucket, providing a fair representation of the collective price +- **Use Cases**: Excellent for DeFi applications where economic alignment is crucial and price updates occur at regular intervals + +### ๐Ÿค” Critical Thinking: Security Vulnerabilities + +- **Robustness vs. Whitelist Oracle**: Unlike the whitelist oracle which relies on a single trusted authority, the staking oracle's design distributes trust among all staking nodes. Manipulating the output requires a majority of nodes to collude, which is economically disincentivized due to the risk of slashing. As a result, unless an attacker controls a majority of the total effective stake, they cannot egregiously manipulate the reported priceโ€”making the system considerably more robust than one with simple whitelist control. + +--- + +### Testing your progress + +๐Ÿ” Run the following command to check if you implemented the functions correctly. + +```sh + +yarn test --grep "Checkpoint2" + +``` + +โœ… Did the tests pass? You can dig into any errors by viewing the tests at `packages/hardhat/test/StakingOracle.ts`. + +### Try it out! + +๐ŸŒŽ In the real world this oracle would have too much latency to be very useful due to the need for 24 block windows. However, this was done to make it possible for you to see how the oracle operates in real time without everything happening too fast to comprehend. There is no reason why this couldn't work with single block windows although in decentralized systems you must always consider that the lower the latency requirements, the fewer nodes can participate + +๐Ÿ”„ Run `yarn deploy --reset` then test the staking oracle. Go to the `Staking` page and try registering your own node and reporting prices. + +๐Ÿšฐ Make sure you get a couple ETH from the faucet and then press the "Register Node" button. + +![Staking Buttons Panel](https://github.com/user-attachments/assets/c32b9bdc-eb1e-4630-ae9f-e57a34deac45) + +> ๐Ÿ—บ๏ธ You can navigate to past buckets using the arrows. + +โœ๏ธ Now you can press the pencil icon to report a new price. Enter your price and press the checkmark button to confirm. If you want to report the same price in the next block then just press the refresh icon next to the pencil. + +![SelfNodeRow](https://github.com/user-attachments/assets/32952de3-bd72-4cc6-98ba-a6b374539533) + +> โ€ผ๏ธ "Insufficient Stake" errors? Look at your staked balance ๐Ÿ‘€. It has fallen below the minimum amount of stake because you let some blocks pass without reporting. Just press the + button next to your stake to add an extra ETH (get it from the faucet if you have less than 1 in your wallet). + +๐Ÿ˜ฎโ€๐Ÿ’จ *Whew!* That was a lot of work pressing all those buttons to keep from getting the inactive penalty! Much easier when bots are doing all the work and you can just watch. Exit your node (if it stresses you) and lets have some fun. + +๐Ÿงช **Live Simulation**: Run the `yarn simulate:staking` command to watch a live simulation of staking oracle behavior with multiple nodes: + +```sh + +yarn simulate:staking + +``` + +๐Ÿค– This will start automated bots and demonstrate how slashing and average aggregation impact the reported price. Right now they are all on default settings so the price won't deviate, but... + +โš™๏ธ You can update the price deviation and skip probability by pressing the gear icon. Go ahead and make some bots start to produce wild deviations then view the past buckets (by using the arrows) to see the "slash" button activated. Press it to slash any deviated nodes. + +๐Ÿฅฑ If you get tired of slashing deviated nodes but still want to see them get slashed you can re-run the command with this environment variable: + +```sh +AUTO_SLASH=true yarn simulate:staking +``` + +### ๐Ÿฅ… Goals: + +- You can register as an oracle node by staking ETH +- Registered nodes can report prices once per bucket and claim ORA token rewards based on report count +- Anyone can slash nodes that report prices deviating too far from the average and earn rewards +- System aggregates prices from completed buckets using average calculation +- Inactivity penalties reduce effective stake for nodes that miss reporting in buckets +- Economic incentives drive honest behavior and regular participation +- Understand the trade-offs between decentralization and latency +- See examples in the wild: [Chainlink](https://chain.link) and [PYTH](https://www.pyth.network/) + +--- + +## Checkpoint 3: ๐Ÿง  Optimistic Oracle Architecture + +๐Ÿคฟ Now let's dive into the most sophisticated of this challenge's three designs: the **Optimistic Oracle**. Unlike the previous two designs that focus on price data, this one will handle any type of binary (true/false) question about real-world events. + +๐ŸŽฏ **What makes it "optimistic"?** The system assumes proposals are correct unless someone disputes them. This creates a game-theoretic mechanism where economic incentives encourage honest behavior while providing strong security guarantees through dispute resolution. + +๐Ÿ’ก **Key Innovation**: Instead of requiring constant active participation from multiple parties (like staking oracles), optimistic oracles only require intervention when something goes wrong. This makes them highly efficient for events that don't need frequent updates. + +๐Ÿ” **Real-World Applications**: +- **Cross-chain bridges**: "Did transaction X happen on chain Y?" +- **Insurance claims**: "Did flight ABC get delayed by more than 2 hours?" +- **Prediction markets**: "Did candidate X win the election?" +- **DeFi protocols**: "Did token X reach price Y on date Z?" + +๐Ÿงญ Before coding, let's understand the flow at a glance. + +**Roles**: +- **asserter**: posts an assertion + reward +- **proposer**: posts an outcome + bond +- **disputer**: challenges the proposal + bond +- **decider**: resolves disputes and sets the winner + +**Windows**: +- Assertion window: when proposals are allowed +- Dispute window: short period after a proposal when disputes are allowed + +**Incentives**: +- Reward + a bond refund flow to the winner; the loser's bond goes to the decider in disputes + +```mermaid + +sequenceDiagram + participant A as Asserter + participant P as Proposer + participant D as Disputer + participant C as Decider + participant O as OptimisticOracle + A->>O: assertEvent(description, startTime, endTime) + reward + Note over O: Wait until startTime + alt No proposal before endTime + A->>O: claimRefund(assertionId) + O-->>A: refund reward + else Proposal received + P->>O: proposeOutcome(assertionId, outcome) + bond + Note over O: Start dispute window + alt No dispute before deadline + O-->>P: Claim undisputed rewards -> reward + bond refund + else Dispute filed in window + D->>O: disputeOutcome(assertionId) + bond + C->>O: settleAssertion(assertionId, resolvedOutcome) + O-->>Winner: claimDisputedReward() -> reward + bond refund + end + end +``` + +๐Ÿงฉ The way this system works is someone creates an **assertion**; +- Something that needs a boolean answer (`true` or `false`) +- After a certain time +- Before a specific deadline +- With a reward + +๐Ÿฆ— If no one answers before the end of the assertion window, the asserter can claim a refund. + +๐Ÿ’ก If someone knows the answer within the correct time then they **propose** the answer, posting a bond. This bond is a risk to them because if their answer is thought to be wrong by someone else then they might lose it. This keeps people economically tied to the **proposals** they make. + +โณ Then if no one **disputes** the proposal before the dispute window is over the proposal is considered to be true, and the proposer may claim the reward and get back their bond. The dispute window should give anyone ample time to submit a dispute. + +โš–๏ธ If someone does **dispute** during the dispute window then they must also post a bond equal to the proposer's bond. This kicks the assertion out of any particular timeline and puts it in a state where it is waiting for a decision from the **decider**. Once the decider contract has **settled** the assertion, the winner can claim the reward and their posted bond. The decider gets the loser's bond. + +๐Ÿง‘โ€โš–๏ธ Now, as we mentioned earlier, this oracle has a role called the **decider**. For this example it is just a simple contract that anyone can call to settle disputes. One could imagine in a live oracle you would want something more robust such as a group of people who vote to settle disputes. + +๐Ÿ”— Look at how [UMA](https://uma.xyz/) does this with their Optimistic Oracle (OO). **This contract is based UMA's OO design**. + +## Checkpoint 4: โšก Optimistic Oracle - Core Functions + +๐Ÿ‘ฉโ€๐Ÿ’ป This section challenges you to implement the optimistic oracle system from scratch. You'll write the core functions that handle assertions, proposals, disputes, and settlements. + +๐ŸŽฏ **Your Mission**: Complete the missing function implementations in the `OptimisticOracle.sol` contract. The contract skeleton is already provided with all the necessary structs, events, and modifiers - you just need to fill in the logic. + +๐Ÿงช **Testing Strategy**: Each function you implement can be tested individually using the provided test suite. Run `yarn test` after implementing each function to verify your solution works correctly. + +๐Ÿ” Open the `packages/hardhat/contracts/02_Optimistic/OptimisticOracle.sol` file to implement the optimistic oracle functionality. + +### โœ๏ธ Tasks: + +1. **Implement `assertEvent(string memory description, uint256 startTime, uint256 endTime)`** + +* ๐Ÿ“ฃ This function allows users to assert that an event will have a true/false outcome + +* ๐Ÿ’ธ It should require that the reward (`msg.value`) is greater than 0 . If it is not then revert with `InvalidValue` + +* โฑ๏ธ It should accept 0 for `startTime` and set it to `block.timestamp` + +* โณ It should accept 0 for `endTime` and default to `startTime + MINIMUM_ASSERTION_WINDOW` + +* ๐Ÿ•ฐ๏ธ It should check that the given `startTime` is less than the current time (`block.timestamp`) and revert with `InvalidTime` if it is + +* ๐Ÿงญ It should validate the time window given is >= `MINIMUM_ASSERTION_WINDOW`, otherwise revert with `InvalidTime` + +* ๐Ÿ—๏ธ It should create a new `EventAssertion` struct with relevant properties set - see if you can figure it out + +* ๐Ÿ—‚๏ธ That struct should be stored in the `assertions` mapping. You can use `nextAssertionId` but don't forget to increment it afterwards! + +* ๐Ÿ“ฃ It should emit the `EventAsserted` event + +
+ +๐Ÿ’ก Hint: Asserting Events + +Here are more granular instructions on setting up the EventAssertion struct: +- asserter should be `msg.sender` +- reward should be `msg.value` +- bond should be the reward x 2 (You will know why as you understand the economics and game theory) +- startTime = `startTime` +- endTime = `endTime` +- description = `description` +- any remaining properties can be initialized with the default values (`false`, `address(0)`, etc.) + +
+ +๐ŸŽฏ Solution + +```solidity + function assertEvent(string memory description, uint256 startTime, uint256 endTime) external payable returns (uint256) { + uint256 assertionId = nextAssertionId; + nextAssertionId++; + if (msg.value == 0) revert InvalidValue(); + + // Set default times if not provided + if (startTime == 0) { + startTime = block.timestamp; + } + if (endTime == 0) { + endTime = startTime + MINIMUM_ASSERTION_WINDOW; + } + + if (startTime < block.timestamp) revert InvalidTime(); + if (endTime < startTime + MINIMUM_ASSERTION_WINDOW) revert InvalidTime(); + + assertions[assertionId] = EventAssertion({ + asserter: msg.sender, + proposer: address(0), + disputer: address(0), + proposedOutcome: false, + resolvedOutcome: false, + reward: msg.value, + bond: msg.value * 2, + startTime: startTime, + endTime: endTime, + claimed: false, + winner: address(0), + description: description + }); + + emit EventAsserted(assertionId, msg.sender, description, msg.value); + return assertionId; + } +``` + +
+
+ +--- + +2. **Implement `proposeOutcome(uint256 assertionId, bool outcome)`** + +* ๐Ÿ—ณ๏ธ This function allows users to propose the outcome for an asserted event + +* ๐Ÿ” It should check that the assertion exists and hasn't been proposed yet. Otherwise revert with `AssertionNotFound` or `AssertionProposed` + +* โฑ๏ธ It should validate the timing constraints - it has to be after `startTime` but before the `endTime` or else revert with `InvalidTime` + +* ๐Ÿ’ธ It should enforce the correct bond amount is provided or revert with `InvalidValue` + +* โœ๏ธ It should update the assertion with the proposal + +* โณ It should set the `endTime` to `block.timestamp + MINIMUM_DISPUTE_WINDOW` + +* ๐Ÿ“ฃ It should emit `OutcomeProposed` + +
+ +๐Ÿ’ก Hint: Proposing Outcomes + +You want to set these properties on the assertion: +- proposer should be `msg.sender` +- proposedOutcome should be `outcome` +- endTime should be updated to `block.timestamp + MINIMUM_DISPUTE_WINDOW` + +
+ +๐ŸŽฏ Solution + +```solidity + function proposeOutcome(uint256 assertionId, bool outcome) external payable { + EventAssertion storage assertion = assertions[assertionId]; + + if (assertion.asserter == address(0)) revert AssertionNotFound(); + if (assertion.proposer != address(0)) revert AssertionProposed(); + if (block.timestamp < assertion.startTime) revert InvalidTime(); + if (block.timestamp > assertion.endTime) revert InvalidTime(); + if (msg.value != assertion.bond) revert InvalidValue(); + + assertion.proposer = msg.sender; + assertion.proposedOutcome = outcome; + assertion.endTime = block.timestamp + MINIMUM_DISPUTE_WINDOW; + + emit OutcomeProposed(assertionId, msg.sender, outcome); + } +``` + +
+
+ +--- + +3. **Implement `disputeOutcome(uint256 assertionId)`** + +* โš–๏ธ This function allows users to dispute a proposed outcome + +* ๐Ÿ” It should check that a proposal exists and hasn't been disputed yet, if not then revert with `NotProposedAssertion` or `ProposalDisputed` + +* โณ It should validate the timing constraints to make sure the `endTime` has not been passed or else it should revert with `InvalidTime` + +* ๐Ÿ’ธ It should require the correct bond amount (as set on the assertion) + +* ๐Ÿ“ It should record the disputer on the assertion struct + +
+ +๐Ÿ’ก Hint: Disputing Outcomes + +The bond amount should be the bond set on the assertion. The same amount that the proposer paid. + +
+ +๐ŸŽฏ Solution + +```solidity + function disputeOutcome(uint256 assertionId) external payable { + EventAssertion storage assertion = assertions[assertionId]; + + if (assertion.proposer == address(0)) revert NotProposedAssertion(); + if (assertion.disputer != address(0)) revert ProposalDisputed(); + if (block.timestamp > assertion.endTime) revert InvalidTime(); + if (msg.value != assertion.bond) revert InvalidValue(); + + assertion.disputer = msg.sender; + + emit OutcomeDisputed(assertionId, msg.sender); + } +``` + +
+
+ +--- + +### Testing your progress + +๐Ÿ” Run the following command to check if you implemented the functions correctly. + +```sh + +yarn test --grep "Checkpoint4" + +``` + +### ๐Ÿฅ… Goals: + +- You can assert events with descriptions and time windows +- You can propose outcomes for asserted events +- You can dispute proposed outcomes +- The system correctly handles timing constraints +- Bond amounts are properly validated + +## Checkpoint 5: ๐Ÿ’ฐ Optimistic Oracle - Reward Claims + +๐ŸŽฏ **Your Mission**: Implement the reward claiming mechanisms that allow participants to collect their earnings based on the outcomes of assertions, proposals, and disputes. + +๐Ÿ’ก **Key Concept**: The optimistic oracle has three different scenarios for claiming rewards: +- **Undisputed proposals**: Proposer gets reward + bond back +- **Disputed proposals**: Winner (determined by decider) gets reward + bond back +- **Refunds**: Asserter gets reward back when no proposals are made + +### โœ๏ธ Tasks: + +1. **Implement `claimUndisputedReward(uint256 assertionId)`** + +The proposer can claim the reward only after the deadline, as long as no dispute was submitted before it. + +* ๐Ÿงฉ A proposal must exist (revert with `NotProposedAssertion`) + +* ๐Ÿšซ No dispute must have been raised (revert with `ProposalDisputed`) + +* โฐ Current time must be after the dispute `endTime` (revert with `InvalidTime`) + +* ๐Ÿ”’ Not already claimed (revert with `AlreadyClaimed`) + +* ๐Ÿ’ธ Transfer `reward + proposer bond` to the proposer + +* ๐Ÿ“ฃ Emit `RewardClaimed` + +
+ +๐Ÿ’ก Hint: Claiming Undisputed Rewards + +- Validate the assertion has a proposer and no disputer +- Check the deadline has passed +- Mark as claimed first +- Set `resolvedOutcome` to the proposed outcome and `winner` to the proposer +- Compute `totalReward = reward + bond` +- Use safe ETH send with revert on failure + +
+ +๐ŸŽฏ Solution + +```solidity + function claimUndisputedReward(uint256 assertionId) external { + EventAssertion storage assertion = assertions[assertionId]; + + if (assertion.proposer == address(0)) revert NotProposedAssertion(); + if (assertion.disputer != address(0)) revert ProposalDisputed(); + if (block.timestamp <= assertion.endTime) revert InvalidTime(); + if (assertion.claimed) revert AlreadyClaimed(); + + assertion.claimed = true; + assertion.resolvedOutcome = assertion.proposedOutcome; + assertion.winner = assertion.proposer; + + uint256 totalReward = (assertion.reward + assertion.bond); + + (bool winnerSuccess, ) = payable(assertion.proposer).call{value: totalReward}(""); + if (!winnerSuccess) revert TransferFailed(); + + emit RewardClaimed(assertionId, assertion.proposer, totalReward); + } +``` + +
+
+ +--- + +2. **Implement `claimDisputedReward(uint256 assertionId)`** + +Very similar to the last function except this one allows the winner of the dispute to claim *only after the Decider has resolved the dispute*. + +* ๐Ÿงฉ A proposal must exist (revert with `NotProposedAssertion`) + +* โš–๏ธ A dispute must exist (revert with `NotDisputedAssertion`) + +* ๐Ÿง‘โ€โš–๏ธ The decider must have set a winner (revert with `AwaitingDecider`) + +* ๐Ÿ”’ Not already claimed (revert with `AlreadyClaimed`) + +* ๐Ÿ“ Set the `claimed` property on the assertion to `true` + +* ๐Ÿ’ธ Transfer the loser's bond to the decider, then send the reward and bond refund to the winner + +* ๐Ÿ“ฃ Emit `RewardClaimed` + +
+๐Ÿ’ก Hint: Claiming Disputed Rewards + +- Validate assertion state: proposed, disputed, winner set, not yet claimed +- Mark as claimed *before* paying to avoid re-entrancy +- Pay the losers bond to the `decider` +- Winner receives `(reward + bond)` +- Use safe ETH sending pattern with revert on failure (`TransferFailed`) + +
+๐ŸŽฏ Solution + +```solidity + function claimDisputedReward(uint256 assertionId) external { + EventAssertion storage assertion = assertions[assertionId]; + + if (assertion.proposer == address(0)) revert NotProposedAssertion(); + if (assertion.disputer == address(0)) revert NotDisputedAssertion(); + if (assertion.winner == address(0)) revert AwaitingDecider(); + if (assertion.claimed) revert AlreadyClaimed(); + + assertion.claimed = true; + + (bool deciderSuccess, ) = payable(decider).call{value: assertion.bond}(""); + if (!deciderSuccess) revert TransferFailed(); + + uint256 totalReward = assertion.reward + assertion.bond; + + (bool winnerSuccess, ) = payable(assertion.winner).call{value: totalReward}(""); + if (!winnerSuccess) revert TransferFailed(); + + emit RewardClaimed(assertionId, assertion.winner, totalReward); + } +``` + +
+
+ +--- + +3. **Implement `claimRefund(uint256 assertionId)`** + +This function enables the asserter to get a refund of their posted reward when no proposal arrives by the deadline. + +* ๐Ÿšซ No proposer exists (revert with `AssertionProposed`) + +* โฐ After assertion endTime ( revert with `InvalidTime`) + +* ๐Ÿ”’ Not already claimed (revert with `AlreadyClaimed`) + +* ๐Ÿ›ก๏ธ Mark the assertion as claimed to avoid re-entrancy + +* ๐Ÿ’ธ Refund the reward to the asserter + +* โœ… Check for successful transfer (revert with `TransferFailed`) + +* ๐Ÿ“ฃ Emit `RefundClaimed` + +
+๐Ÿ’ก Hint: No Proposal Refund + +- Validate: no proposal, now > endTime, not claimed +- Mark as claimed then refund +- Emit refund event + +
+๐ŸŽฏ Solution + +```solidity + function claimRefund(uint256 assertionId) external { + EventAssertion storage assertion = assertions[assertionId]; + + if (assertion.proposer != address(0)) revert AssertionProposed(); + if (block.timestamp <= assertion.endTime) revert InvalidTime(); + if (assertion.claimed) revert AlreadyClaimed(); + + assertion.claimed = true; + + (bool refundSuccess, ) = payable(assertion.asserter).call{value: assertion.reward}(""); + if (!refundSuccess) revert TransferFailed(); + emit RefundClaimed(assertionId, assertion.asserter, assertion.reward); + } +``` + +
+
+ +--- + +4. **Implement `settleAssertion(uint256 assertionId, bool resolvedOutcome)`** + +This is the method that the decider will call to settle whether the proposer or disputer are correct. + +It should be: + +* ๐Ÿง‘โ€โš–๏ธ Only callable by the `decider` contract + +* โš–๏ธ The assertion must be both proposed and disputed (or revert with `NotProposedAssertion` or `NotDisputedAssertion`) + +* ๐Ÿ”’ We need to make sure the winner has not already been set (or revert with `AlreadySettled`) + +* โœ๏ธ Now we should set the resolvedOutcome property + +* ๐Ÿ Winner = proposer if proposedOutcome == resolvedOutcome, else disputer + +* ๐Ÿ“ฃ Emit `AssertionSettled` + +
+๐Ÿ’ก Hint: Decider Sets Winner + +We just need the decider to use the remaining unused properties to establish which party is correct, hence, which party gets to claim the reward. + +Set resolvedOutcome to true or false based on what is the actual truth regarding an assertion. + +Then set the winner to the proposer if the proposer was correct *or* set it to the disputer is the disputer was correct. + +
+๐ŸŽฏ Solution + +```solidity + function settleAssertion(uint256 assertionId, bool resolvedOutcome) external onlyDecider { + EventAssertion storage assertion = assertions[assertionId]; + + if (assertion.proposer == address(0)) revert NotProposedAssertion(); + if (assertion.disputer == address(0)) revert NotDisputedAssertion(); + if (assertion.winner != address(0)) revert AlreadySettled(); + + assertion.resolvedOutcome = resolvedOutcome; + + assertion.winner = (resolvedOutcome == assertion.proposedOutcome) + ? assertion.proposer + : assertion.disputer; + + emit AssertionSettled(assertionId, resolvedOutcome, assertion.winner); + } +``` + +
+
+ +--- + +### Testing your progress + +๐Ÿ” Run the following command to check if you implemented the functions correctly. + +```sh + +yarn test --grep "Checkpoint5" + +``` + +### ๐Ÿฅ… Goals: + +- Proposers can claim rewards for undisputed assertions +- Winners can claim rewards after disputes are settled +- Asserters can claim refunds when no proposals are made +- The decider can settle disputed assertions +- The system prevents double-claiming and re-entrancy attacks +- All transfers are handled safely with proper error checking + + +--- + +## Checkpoint 6: ๐Ÿง‘โ€โš–๏ธ Optimistic Oracle - State Management + +๐ŸŽฏ **Your Mission**: Implement the final pieces of the optimistic oracle: utility functions for querying assertion states and resolutions. + +### โœ๏ธ Tasks: + +1. **Implement `getState(uint256 assertionId)`** + +This function returns a simple state machine view for UI/testing. + +The states are defined as follows in an enum at the top of the contract. +**States**: Invalid, Asserted, Proposed, Disputed, Settled, Expired + +Think through how you can check which properties have been set to derive the current state of the assertion. + +For instance, if the `asserter` property is empty then you would return an `Invalid` state. + +Try to deduce the rest without any help. + +
+๐Ÿ’ก Hint: Derive State + +- `Invalid` if no assertion +- Winner set => `Settled` +- Disputer set => `Disputed` +- No proposer: if past endTime => `Expired`, else `Asserted` +- Proposer present: if past endTime => `Settled`, else `Proposed` + +
+๐ŸŽฏ Solution + +```solidity + function getState(uint256 assertionId) external view returns (State) { + EventAssertion storage a = assertions[assertionId]; + + if (a.asserter == address(0)) return State.Invalid; + + // If there's a winner, it's settled + if (a.winner != address(0)) return State.Settled; + + // If there's a dispute, it's disputed + if (a.disputer != address(0)) return State.Disputed; + + // If no proposal yet, check if deadline has passed + if (a.proposer == address(0)) { + if (block.timestamp > a.endTime) return State.Expired; + return State.Asserted; + } + + // If no dispute and deadline passed, it's settled (can be claimed) + if (block.timestamp > a.endTime) return State.Settled; + + // Otherwise it's proposed + return State.Proposed; + } +``` + +
+
+ +--- + +2. **Implement `getResolution(uint256 assertionId)`** + +This function will help everyone know the exact outcome of the assertion. + +* ๐Ÿ”Ž It should revert with `AssertionNotFound` if it doesn't exist + +* โณ Then we just need to check if anyone disputed it and that the dispute window is up to know we can rely on the `proposedOutcome` (if the time isn't over then revert with `InvalidTime`) + +* ๐Ÿง‘โ€โš–๏ธ Otherwise, if a disupte has been made, then we just need to make sure the `winner` has been set by the decider (or else revert with `AwaitingDecider`) + +
+๐Ÿ’ก Hint: Read Outcome Carefully + +- Handle undisputed vs disputed paths +- Enforce timing and readiness conditions with appropriate errors + +The important thing here is that it reverts if it is not settled and if it has been then it returns the correct outcome, whether that be a proposal that was undisputed or a disputed proposal that was then settled by the decider. + +
+๐ŸŽฏ Solution + +```solidity + function getResolution(uint256 assertionId) external view returns (bool) { + EventAssertion storage a = assertions[assertionId]; + if (a.asserter == address(0)) revert AssertionNotFound(); + + if (a.disputer == address(0)) { + if (block.timestamp <= a.endTime) revert InvalidTime(); + return a.proposedOutcome; + } else { + if (a.winner == address(0)) revert AwaitingDecider(); + return a.resolvedOutcome; + } + } +``` + +
+
+ +--- + +### Testing your progress + +๐Ÿ” Run the following command to check if you implemented the functions correctly. + +```sh + +yarn test --grep "Checkpoint6" + +``` + +โœ… Did the tests pass? You can dig into any errors by viewing the tests at `packages/hardhat/test/OptimisticOracle.ts`. + +### Try it out! + +๐Ÿ”„ Run `yarn deploy --reset` then test the optimistic oracle. Try creating assertions, proposing outcomes, and disputing them. + +๐Ÿ–ฅ๏ธ Go to the Optimistic page to interact with your new protocol + +![OptimisticOracle](https://github.com/user-attachments/assets/9ed4f066-152b-43a9-a5d4-933221137905) + +1. **Submit a New Assertion**: + Go to the "Optimistic" page and fill in the required fields to create a new assertion. + - Enter the assertion details and submit. + +2. **Propose an Outcome**: + Once an assertion is created, use the UI to propose an outcome for your assertion. + +3. **Dispute an Outcome**: + If someone disagrees with the proposed outcome, they can dispute it using the dispute button shown in the table for pending assertions. + +4. **Wait for Dispute Window & Settlement**: + - Wait for the dispute window (the protocolโ€™s timer) to expire. + - If no dispute is made, the assertion settles automatically. + - If disputed, the decider must choose the winner; monitor status updates in the table. + +5. **Check Outcomes**: + View the resolution and settlement status for each assertion directly in the UI. + +๐Ÿง‘โ€๐Ÿ’ป Experiment by creating, proposing, and disputing assertions to observe the full optimistic oracle workflow in the frontend. + +๐Ÿงช **Live Simulation**: Run the `yarn simulate:optimistic` command to see the full optimistic oracle lifecycle in action: + +```sh + +yarn simulate:optimistic + +``` + +๐Ÿค– This will start automated bots that create assertions, propose outcomes, and dispute proposals, so you can observe rewards, bonds, fees, and timing windows in a realistic flow. It is up to you to settle disputes! + +### ๐Ÿฅ… Goals: + +- The system provides clear state information for all assertions +- Users can query resolved outcomes for both disputed and undisputed assertions +- All functions handle edge cases and invalid states appropriately +- The complete optimistic oracle system works end-to-end +--- + +## Checkpoint 7: ๐Ÿ” Oracle Comparison & Trade-offs + +๐Ÿง  Now let's analyze the strengths and weaknesses of each oracle design. + +### ๐Ÿ“Š Comparison Table: +| Aspect | Whitelist Oracle | Staking Oracle | Optimistic Oracle | +|--------|------------------|----------------|-------------------| +| **Speed** | Fast | Medium | Slow | +| **Security** | Low (trusted authority) | High (economic incentives) | High (dispute resolution) | +| **Decentralization** | Low | High | Depends on Decider Implementation | +| **Cost** | Low | Medium (stake) | High (rewards and bonds) | +| **Complexity** | Simple | Medium | Complex | + +### ๐Ÿค” Key Trade-offs: + +1. **Whitelist Oracle:** + +- โœ… Simple and fast + +- โœ… Low gas costs + +- โŒ Requires trust in centralized authority + +- โŒ Single point of failure + +2. **Staking Oracle:** + +- โœ… Decentralized with economic incentives + +- โœ… Self-correcting through slashing + +- โŒ More complex to implement + +- โŒ Higher gas costs + +3. **Optimistic Oracle:** + +- โœ… Economic security + +- โœ… Can be used for any type of data (not just prices) + +- โœด๏ธ Decider role is the weakest link and should be carefully implemented though it is up to the consuming application whether it wants to wait for a resolution or post another assertion and hope a proposal passes without dispute + +- โŒ Higher latency + +- โŒ More complex + +### ๐ŸŽฏ Understanding the "Why": + +Each oracle design solves different problems: + +- **Whitelist Oracle**: Best for setups with trusted intermediaries already in the loop such as RWAs where speed and accuracy are more important than decentralization. +- **Staking Oracle**: Best for high-value DeFi applications where decentralization and security are crucial. Decentralization and latency rise and fall together. +- **Optimistic Oracle**: Best for answering complex questions and where more latency is not a huge problem. Flexible enough to resolve open-ended questions that don't have a strict binary format (e.g., "Which team won the match?"). + +--- + +## Checkpoint 8: ๐Ÿ’พ Deploy your contract! ๐Ÿ›ฐ + +๐ŸŽ‰ Well done on building the optimistic oracle system! Now, let's get it on a public testnet. + +๐Ÿ“ก Edit the `defaultNetwork` to [your choice of public EVM networks](https://ethereum.org/en/developers/docs/networks/) in `packages/hardhat/hardhat.config.ts` (e.g., `sepolia`). + +๐Ÿ” You will need to generate a **deployer address** using `yarn generate`. This creates a mnemonic and saves it locally. + +๐Ÿ‘ฉโ€๐Ÿš€ Use `yarn account` to view your deployer account balances. + +โ›ฝ๏ธ You will need to send ETH to your **deployer address** with your wallet, or get it from a public faucet of your chosen network. + +๐Ÿš€ Run `yarn deploy` to deploy your optimistic oracle contracts to a public network (selected in `hardhat.config.ts`) + +> ๐Ÿ’ฌ Hint: You can set the `defaultNetwork` in `hardhat.config.ts` to `sepolia` **OR** you can `yarn deploy --network sepolia`. + +--- + +## Checkpoint 9: ๐Ÿšข Ship your frontend! ๐Ÿš + +โœ๏ธ Edit your frontend config in `packages/nextjs/scaffold.config.ts` to change the `targetNetwork` to `chains.sepolia` (or your chosen deployed network). + +๐Ÿ’ป View your frontend at http://localhost:3000 and verify you see the correct network. + +๐Ÿ“ก When you are ready to ship the frontend app... + +๐Ÿ“ฆ Run `yarn vercel` to package up your frontend and deploy. + +> You might need to log in to Vercel first by running `yarn vercel:login`. Once you log in (email, GitHub, etc), the default options should work. + +> If you want to redeploy to the same production URL you can run `yarn vercel --prod`. If you omit the `--prod` flag it will deploy it to a preview/test URL. + +> Follow the steps to deploy to Vercel. It'll give you a public URL. + +> ๐ŸฆŠ Since we have deployed to a public testnet, you will now need to connect using a wallet you own or use a burner wallet. By default ๐Ÿ”ฅ `burner wallets` are only available on `hardhat` . You can enable them on every chain by setting `onlyLocalBurnerWallet: false` in your frontend config (`scaffold.config.ts` in `packages/nextjs/`) + +#### Configuration of Third-Party Services for Production-Grade Apps. + +By default, ๐Ÿ— Scaffold-ETH 2 provides predefined API keys for popular services such as Alchemy and Etherscan. This allows you to begin developing and testing your applications more easily, avoiding the need to register for these services. + +This is great to complete your **SpeedRunEthereum**. + +For production-grade applications, it's recommended to obtain your own API keys (to prevent rate limiting issues). You can configure these at: + +- ๐Ÿ”ท`ALCHEMY_API_KEY` variable in `packages/hardhat/.env` and `packages/nextjs/.env.local`. You can create API keys from the [Alchemy dashboard](https://dashboard.alchemy.com/). +- ๐Ÿ“ƒ`ETHERSCAN_API_KEY` variable in `packages/hardhat/.env` with your generated API key. You can get your key [here](https://etherscan.io/myapikey). + +> ๐Ÿ’ฌ Hint: It's recommended to store env's for nextjs in Vercel/system env config for live apps and use .env.local for local testing. + +--- + +## Checkpoint 10: ๐Ÿ“œ Contract Verification + +๐Ÿ“ Run the `yarn verify --network your_network` command to verify your optimistic oracle contracts on Etherscan ๐Ÿ›ฐ. + +๐Ÿ‘‰ Search your deployed optimistic oracle contract addresses on [Sepolia Etherscan](https://sepolia.etherscan.io/) to get the URL you submit to ๐Ÿƒโ€โ™€๏ธ[SpeedRunEthereum.com](https://speedrunethereum.com). + +--- + +> ๐ŸŽ‰ Congratulations on completing the Oracle Challenge! You've gained valuable insights into the mechanics of decentralized oracle systems and their critical role in the blockchain ecosystem. You've explored different oracle architectures and built a sophisticated optimistic oracle system from scratch. + +> ๐Ÿƒ Head to your next challenge [here](https://speedrunethereum.com). + +> ๐Ÿ’ฌ Problems, questions, comments on the stack? Post them to the [๐Ÿ— scaffold-eth developers chat](https://t.me/joinchat/F7nCRK3kI93PoCOk) + +## Checkpoint 11: More On Oracles + +Oracles are fundamental infrastructure for the decentralized web. They enable smart contracts to interact with real-world data, making blockchain applications truly useful beyond simple token transfers. + +๐Ÿงญ The three oracle designs you've implemented represent the main architectural patterns used in production systems: + +- **Whitelist Oracles** are used by protocols that prioritize speed and simplicity over decentralization +- **Staking Oracles** power most DeFi applications where economic incentives help enforce honest behavior +- **Optimistic Oracles** are extremely flexible and can be used for anything from world events to cross-chain transfer verification systems + +๐Ÿš€ As you continue your blockchain development journey, you'll encounter many variations and combinations of these patterns. Understanding the fundamental trade-offs will help you choose the right oracle design for your specific use case. + +๐Ÿง  Remember: the best oracle is the one that provides the right balance of security, speed, flexibility and cost for your application's needs! diff --git a/extension/README.md.args.mjs b/extension/README.md.args.mjs index 49b9e6a6..7e0a9a78 100644 --- a/extension/README.md.args.mjs +++ b/extension/README.md.args.mjs @@ -1,46 +1,77 @@ export const skipQuickStart = true; -// CHALLENGE-TODO: Update the readme to reflect your challenge. In the very end you will need a non-template -// README.md file in the extension root so it is recommended to copy the template in a markdown file and then -// update extraContents after confirming the template is correct. -// include following code at the start of checkpoint 0 of the README.md file -// *Start of the code block* -// \`\`\`sh -// npx create-eth@ -e {challengeName} {challengeName} -// cd {challengeName} -// \`\`\` -// > in the same terminal, start your local network (a blockchain emulator in your computer): -// *End of the code block* +export const extraContents = `# ๐Ÿ”ฎ Oracle Challenge -export const extraContents = `# {challengeEmoji} {challengeTitle} +![readme-oracle](https://raw.githubusercontent.com/scaffold-eth/se-2-challenges/challenge-oracles/extension/packages/nextjs/public/hero.png) -A {challengeDescription}. +๐Ÿ”— Build your own decentralized oracle systems! In this challenge, you'll explore three fundamental oracle architectures that power the decentralized web: **Whitelist Oracle**, **Staking Oracle**, and **Optimistic Oracle**. -๐ŸŒŸ The final deliverable is an app that {challengeDeliverable}. -Deploy your contracts to a testnet then build and upload your app to a public web server. Submit the url on [SpeedRunEthereum.com](https://speedrunethereum.com)! +๐Ÿง  You'll dive deep into the mechanics of bringing real-world data onto the blockchain, understanding the critical trade-offs between security, decentralization, and efficiency. Each oracle design represents a different approach to solving the fundamental problem: How can we trust data from outside the blockchain, and how do we securely bring it on-chain? -๐Ÿ’ฌ Meet other builders working on this challenge and get help in the {challengeTelegramLink} +
โ“ Wondering what an oracle is? Read the overview here. + +Oracles are bridges between blockchains and the external world. They solve a fundamental problem: smart contracts can only access data that exists on the blockchain, but most real-world data (prices, weather, sports scores, etc.) exists off-chain. + +๐Ÿค” Why are oracles important? + +- **DeFi Protocols**: Need accurate price feeds for lending, trading, and liquidation +- **Insurance**: Require real-world event verification (weather, flight delays) +- **Gaming**: Need random numbers and external event outcomes +- **Supply Chain**: Track real-world goods and events + +๐Ÿ”’ Why are oracles difficult? + +- **Trust**: How do we know the oracle is telling the truth? +- **Centralization**: Single points of failure can compromise entire protocols +- **Incentives**: How do we align oracle behavior with protocol needs? +- **Latency**: Real-time data needs to be fresh and accurate + +๐Ÿ‘ Now that you understand the basics, let's look at three different oracle systems! + +
+ +--- + +๐ŸŒŸ The final deliverable is a comprehensive understanding of oracle architectures through hands-on implementation. You'll explore three oracle systems, a Whitelist oracle, Staking-based oracle and an Optimistic oracle, implementing each one. In the end you will deploy your optimistic oracle to a testnet and demonstrate how it handles assertions, proposals, disputes, and settlements. + +๐Ÿ” First, let's understand why we need multiple oracle designs. Each approach has different strengths: + +- **Whitelist Oracle**: Simple and fast, but requires trust in a centralized authority +- **Staking Oracle**: Decentralized with economic incentives, but more complex +- **Optimistic Oracle**: Dispute-based with strong security guarantees, but higher latency + +๐Ÿ“š This challenge is inspired by real-world oracle systems like [Chainlink](https://chain.link/), [Pyth Network](https://www.pyth.network/), and [UMA Protocol](https://uma.xyz/). + +๐Ÿ’ฌ Meet other builders working on this challenge and get help in the [Oracle Challenge Telegram](https://t.me/+AkmcMB3jC3A0NDcx) --- ## Checkpoint 0: ๐Ÿ“ฆ Environment ๐Ÿ“š -> Start your local network (a blockchain emulator in your computer): +> ๐Ÿ’ป Start your local network (a blockchain emulator in your computer): \`\`\`sh + yarn chain + \`\`\` -> in a second terminal window, ๐Ÿ›ฐ deploy your contract (locally): +> ๐Ÿ›ฐ๏ธ In a second terminal window, deploy your contract (locally): \`\`\`sh + yarn deploy + \`\`\` -> in a third terminal window, start your ๐Ÿ“ฑ frontend: +> ๐Ÿšจ This will likely fail when you run it since the contracts aren't ready to be deployed yet + +> ๐Ÿ“ฑ In a third terminal window, start your frontend: \`\`\`sh + yarn start + \`\`\` ๐Ÿ“ฑ Open http://localhost:3000 to see the app. @@ -57,23 +88,1957 @@ yarn start --- -_Other commonly used Checkpoints (check one Challenge and adapt the texts for your own):_ +## Checkpoint 1: ๐Ÿ›๏ธ Whitelist Oracle Overview + +๐Ÿ” Let's start with the simplest of the three oracle designs we'll cover: the Whitelist Oracle. This design uses a centralized authority to control which data sources can provide information, making it simple and fast but requiring trust. + +๐Ÿ’ฐ The implementation we'll be looking at is a **price** oracle. Price oracles are one of the most common and critical types of oracles in DeFi, as they enable smart contracts to make decisions based on real-world asset prices. Our whitelist price oracle collects price reports from multiple trusted sources (instances of \`SimpleOracle\`) and returns their median value. + +๐Ÿงญ Let's understand how this oracle system works. We'll examine both the basic building block (SimpleOracle) and how multiple simple oracles can be combined into a more robust system (WhitelistOracle). + +### ๐Ÿ”— Simple Oracle - The Building Block + +๐Ÿ” Open the \`packages/hardhat/contracts/00_Whitelist/SimpleOracle.sol\` file to examine the basic oracle functionality. + +#### ๐Ÿ“– Understanding the Code: + +๐Ÿงฉ The \`SimpleOracle\` contract is the fundamental building block of this oracle system: + +1. **Constructor** - Takes an \`_owner\` address parameter to set who can update the oracle price + +2. **\`setPrice(uint256 _newPrice)\`** - This function allows the contract owner to update the current price + + * ๐Ÿ”„ Updates the \`price\` state variable with the new value + + * โฑ๏ธ Updates the \`timestamp\` to the current block timestamp -## Checkpoint {num}: ๐Ÿ’พ Deploy your contract! ๐Ÿ›ฐ + * ๐Ÿ“ฃ Emits the \`PriceUpdated\` event with the new price -## Checkpoint {num}: ๐Ÿšข Ship your frontend! ๐Ÿš +3. **\`getPrice()\`** - This function returns both the current price and timestamp -## Checkpoint {num}: ๐Ÿ“œ Contract Verification + * โ†ฉ๏ธ Returns them as a tuple: \`(price, timestamp)\` + +#### ๐Ÿค” Key Insights: + +- **Single Source**: Each SimpleOracle represents one data source +- **Trust Model**: Requires complete trust in whoever updates the price +- **Limitations**: No consensus mechanism, no economic incentives + +### ๐Ÿ›๏ธ Whitelist Oracle - Aggregating Multiple Sources + +๐ŸŽฏ **Your Mission**: Complete the missing function implementations in the \`WhitelistOracle.sol\` contract. + +๐Ÿ” Open the \`packages/hardhat/contracts/00_Whitelist/WhitelistOracle.sol\` file to implement the whitelist oracle functionality. + +#### ๐Ÿ“– Understanding the Relationship: + +The \`WhitelistOracle\` contract **creates and manages multiple SimpleOracle contracts**: + +\`\`\`solidity + +SimpleOracle[] public oracles; // Array of SimpleOracle contract instances + +\`\`\` + +๐Ÿ—๏ธ This creates a **hierarchical oracle system**: + +- **Individual Level**: Each SimpleOracle contract is managed by a trusted data provider (set during oracle creation) +- **Aggregation Level**: The WhitelistOracle creates, manages, and processes data from all whitelisted SimpleOracle contracts + +### โœ๏ธ Tasks: + +1. **Implement \`addOracle(address _owner)\`** + +* ๐Ÿญ This function allows the contract owner to add a new oracle to the whitelist by deploying a SimpleOracle contract + +* ๐Ÿงฉ It should create a new \`SimpleOracle\` instance with the specified \`_owner\` + +* โž• It should add the newly created SimpleOracle to the \`oracles\` array + +* ๐Ÿ“ฃ It should emit the \`OracleAdded\` event with both the oracle address and its owner + +
+ +๐Ÿ’ก Hint: Creating and Adding Oracles + +Here's what you need to do: +- Create a new SimpleOracle contract instance using \`new SimpleOracle(_owner)\` +- Get the address of the newly created oracle using \`address(newOracle)\` +- Push the oracle instance to the \`oracles\` array +- Emit the \`OracleAdded\` event with the oracle address and owner + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function addOracle(address _owner) public onlyOwner { + SimpleOracle newOracle = new SimpleOracle(_owner); + address oracleAddress = address(newOracle); + + oracles.push(newOracle); + emit OracleAdded(oracleAddress, _owner); +} +\`\`\` + +
+
--- -_Create all the required Checkpoints for the Challenge, can also add Side Quests you think may be interesting to complete. Check other Challenges for inspiration._ +2. **Implement \`removeOracle(uint256 index)\`** -### โš”๏ธ Side Quests +* โœ”๏ธ This function allows the contract owner to remove an oracle from the whitelist by its array index -_To finish your README, can add these links_ +* ๐Ÿ” It should validate that the provided index is within bounds, otherwise revert with \`IndexOutOfBounds\` -> ๐Ÿƒ Head to your next challenge [here](https://speedrunethereum.com). +* ๐Ÿ“ It should record the oracle address before removal for the event -> ๐Ÿ’ฌ Problems, questions, comments on the stack? Post them to the [๐Ÿ— scaffold-eth developers chat](https://t.me/joinchat/F7nCRK3kI93PoCOk) +* โž– It should efficiently remove the oracle using swap-and-pop pattern (swap with last element, then pop) + +* ๐Ÿ“ฃ It should emit the \`OracleRemoved\` event with the oracle address + +
+ +๐Ÿ’ก Hint: Safe Array Removal + +The swap-and-pop pattern: +- Check if index is valid (< oracles.length) +- Store the oracle address for the event +- If not the last element, swap with the last element +- Pop the last element +- Emit the removal event + +This is much more gas efficient than deleting the element and moving all the entries beyond it over one space. O(1) vs O(n). + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function removeOracle(uint256 index) public onlyOwner { + if (index >= oracles.length) revert IndexOutOfBounds(); + + address oracleAddress = address(oracles[index]); + + if (index != oracles.length - 1) { + oracles[index] = oracles[oracles.length - 1]; + } + + oracles.pop(); + + emit OracleRemoved(oracleAddress); +} +\`\`\` + +
+
+ +--- + +3. **Implement \`getPrice()\`** + +* ๐Ÿ“Š This function aggregates prices from all active oracles using median calculation + +* โ›”๏ธ It should revert with \`NoOraclesAvailable\` if no oracles exist in the whitelist + +* ๐Ÿ” It should loop through each oracle and call \`getPrice()\` to get \`(price, timestamp)\` + +* ๐Ÿงน It should filter out stale prices (older than \`STALE_DATA_WINDOW = 24 seconds\`) + +* ๐Ÿ“ฆ It should collect only fresh prices into a properly sized array + +* ๐Ÿงฎ It should use StatisticsUtils library to sort prices and calculate the median + +
+ +๐Ÿ’ก Hint: Price Aggregation with Freshness Check + +Here's the process: +- Check if any oracles exist +- Create a temporary array to collect fresh prices +- Loop through all oracles, get their (price, timestamp) +- Check if timestamp is within STALE_DATA_WINDOW of current time +- Collect valid prices and count them +- Create a right-sized array with only valid prices +- Sort and get median using StatisticsUtils + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function getPrice() public view returns (uint256) { + if (oracles.length == 0) revert NoOraclesAvailable(); + + // Collect prices and timestamps from all oracles + uint256[] memory prices = new uint256[](oracles.length); + uint256 validCount = 0; // Count of valid prices + uint256 currentTime = block.timestamp; + + for (uint256 i = 0; i < oracles.length; i++) { + (uint256 price, uint256 timestamp) = oracles[i].getPrice(); + // Check if the timestamp is within the last STALE_DATA_WINDOW + if (currentTime - timestamp < STALE_DATA_WINDOW) { + prices[validCount] = price; + validCount++; + } + } + + uint256[] memory validPrices = new uint256[](validCount); + for (uint256 i = 0; i < validCount; i++) { + validPrices[i] = prices[i]; + } + + validPrices.sort(); + return validPrices.getMedian(); +} +\`\`\` + +
+
+ +--- + +4. **Implement \`getActiveOracleNodes()\`** + +* ๐Ÿ“Š This function returns the addresses of all oracles that have updated their price within the last \`STALE_DATA_WINDOW\` + +* ๐Ÿ” It should iterate through all oracles and filter those with recent timestamps + +* ๐Ÿ“ฆ It should use a temporary array to collect active nodes, then create a right-sized return array for gas optimization + +* ๐ŸŽฏ It should return an array of addresses representing the currently active oracle contracts + +
+ +๐Ÿ’ก Hint: Active Node Filtering + +Similar to getPrice(), but instead of collecting prices, collect oracle addresses: +- Create temporary array to store addresses +- Loop through oracles, check timestamp freshness +- Count and collect active oracle addresses +- Create properly sized result array +- Return the active oracle addresses + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function getActiveOracleNodes() public view returns (address[] memory) { + address[] memory tempNodes = new address[](oracles.length); + uint256 count = 0; + + for (uint256 i = 0; i < oracles.length; i++) { + (, uint256 timestamp) = oracles[i].getPrice(); + if (timestamp > block.timestamp - STALE_DATA_WINDOW) { + tempNodes[count] = address(oracles[i]); + count++; + } + } + + address[] memory activeNodes = new address[](count); + for (uint256 j = 0; j < count; j++) { + activeNodes[j] = tempNodes[j]; + } + + return activeNodes; +} +\`\`\` + +
+
+ +### ๐Ÿค” Key Insights: + +- **Factory Pattern**: WhitelistOracle creates and manages SimpleOracle contracts +- **Centralized Authority**: Only the owner can add/remove SimpleOracle contracts +- **Consensus Mechanism**: Uses median calculation with StatisticsUtils library to resist outliers +- **Freshness Check**: Filters out stale data from any SimpleOracle +- **Trust Model**: Requires trust in the whitelist authority and each SimpleOracle provider +- **Use Cases**: Good for controlled environments where you trust the centralized entity or where things fall back to the rule of law (RWAs) + +### ๐Ÿ”„ How They Work Together: + +1. **Data Flow**: + +\`\`\` + +SimpleOracle A โ†’ setPrice(100) โ†’ getPrice() โ†’ (100, timestamp) + +SimpleOracle B โ†’ setPrice(102) โ†’ getPrice() โ†’ (102, timestamp) + +SimpleOracle C โ†’ setPrice(98) โ†’ getPrice() โ†’ (98, timestamp) + +\`\`\` + +2. **Aggregation**: + +\`\`\` + +WhitelistOracle โ†’ getPrice() โ†’ [100, 102, 98] โ†’ sort โ†’ [98, 100, 102] โ†’ median(100) โ†’ 100 + +\`\`\` + +3. **Benefits**: + +- **Redundancy**: If one SimpleOracle fails, others continue providing data + +- **Outlier Resistance**: Median calculation ignores extreme values + +- **Freshness**: Stale data from any SimpleOracle is filtered out + +### ๐Ÿค” Critical Thinking: Security Vulnerabilities + +- **Question**: How could this whitelist oracle design be exploited or taken advantage of? What are the main attack vectors? + +
+ +๐Ÿ’ก Click to see potential vulnerabilities + +1. ๐Ÿ”“ **Whitelist Authority Compromise**: If the owner's private key is compromised, an attacker could: + + - Remove all legitimate oracles and add malicious ones + + - Manipulate which data sources are trusted + + - Add multiple oracles they control to skew the median + +2. ๐Ÿ‘ฅ **Collusion Among Whitelisted Providers**: If enough whitelisted oracle providers collude, they could: + + - Report coordinated false prices to manipulate the median + + - Extract value from protocols relying on the oracle + +3. ๐Ÿ”“ **Data Provider Compromise**: Individual SimpleOracle operators could: + + - Be hacked or coerced to report false prices + + - Sell their influence to manipulators + +๐Ÿ’ก *Real-World Impact*: These vulnerabilities explain why protocols like [MakerDAO/Sky](https://github.com/sky-ecosystem/medianizer) eventually moved to more decentralized oracle systems as the stakes grew higher! + +
+ +--- + +### Testing your progress + +๐Ÿ” Run the following command to check if you implemented the functions correctly. + +\`\`\`sh + +yarn test --grep "Checkpoint1" + +\`\`\` + +โœ… Did the tests pass? You can dig into any errors by viewing the tests at \`packages/hardhat/test/WhitelistOracle.ts\`. + +### Try it out! + +๐Ÿ”„ Run \`yarn deploy --reset\` then test the whitelist oracle. Try adding and removing oracles, and observing how the aggregated price changes. + +![WhiteListOracle](https://github.com/user-attachments/assets/1fabc2d8-a1a4-4b0e-b00c-49f3fefec7fc) + +๐Ÿ‘Š Notice how the onlyOwner modifiers are commented out to allow you to have full control. Try manually changing the price of individual SimpleOracle contracts and adding new oracle nodes to see how the aggregated price changes: + +1. **Change Prices**: Use the frontend to modify individual oracle prices + +2. **Add New Nodes**: Create new SimpleOracle contracts through the whitelist oracle + +3. **Observe Aggregation**: Watch how the median price changes as you add/remove oracles + +๐Ÿงช **Live Simulation**: Run the \`yarn simulate:whitelist\` command to see what a live version of this protocol might look like in action: + +\`\`\`sh + +yarn simulate:whitelist + +\`\`\` + +๐Ÿค– This will start automated bots that simulate real oracle behavior, showing you how the system would work in production with multiple active price feeds. + +### ๐Ÿฅ… Goals: + +- You can add new SimpleOracle instances to the whitelist +- System aggregates prices from active oracles using median calculation +- Stale data is automatically filtered out based on timestamps +- You can query which oracle nodes are currently active +- The system correctly handles edge cases and invalid states +- Understand the benefits of aggregating multiple data sources +- Look at these examples "in the wild" from early DeFi: [Simple Oracle](https://github.com/dapphub/ds-value), +[Whitelist Oracle](https://github.com/sky-ecosystem/medianizer) + +--- + +## Checkpoint 2: ๐Ÿ’ฐ Staking Oracle - Economic Incentives + +๐Ÿงญ Now let's explore a decentralized oracle that uses economic incentives to ensure honest behavior. Nodes stake ETH to participate and can be slashed for reporting prices that deviate too far from the average. The system uses a bucket-based approach where prices are organized into discrete time windows (24 blocks per bucket), and nodes must report once per bucket or else there will be a small inactivity leak in their stake. We will also issue rewards in the form of an ERC20 token called ORA (1 ORA per report) to incentivise participation in the system. + +๐Ÿ‘ฉโ€๐Ÿ’ป This section challenges you to implement the staking oracle system from scratch. You'll write the core functions that handle node registration, bucket-based price reporting, reward distribution based on report count, and slashing mechanisms for price deviations. + +๐ŸŽฏ **Your Mission**: Complete the missing function implementations in the \`StakingOracle.sol\` contract. The contract skeleton is already provided with all the necessary structs, events, and modifiers but you need to fill in the logic. + +๐Ÿ” Open the \`packages/hardhat/contracts/01_Staking/StakingOracle.sol\` file to implement the staking oracle functionality. + +### โœ๏ธ Tasks: + +1. **Implement \`getCurrentBucketNumber()\`** + +* ๐Ÿ•’ This view function maps the current \`block.number\` into a bucket index (24-block window) + +* ๐Ÿงฎ It should divide the block number by \`BUCKET_WINDOW\` and add 1 (buckets are indexed starting from 1, not 0) + +
+ +๐Ÿ’ก Hint: Bucket Number + +- Buckets advance every \`BUCKET_WINDOW\` blocks +- Integer division will floor the result automatically +- Remember to add 1 so the very first bucket starts at index 1 + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function getCurrentBucketNumber() public view returns (uint256) { + return (block.number / BUCKET_WINDOW) + 1; +} +\`\`\` + +
+
+ +--- + +2. **Implement \`getEffectiveStake(address nodeAddress)\`** + +* ๐Ÿ“‰ This view function returns a node's stake after inactivity penalties + +* ๐Ÿ” It should return \`0\` for inactive nodes + +* ๐Ÿงฎ It should compute expected reports based on completed buckets since registration + +* โœ‚๏ธ For each missed report, subtract \`INACTIVITY_PENALTY\`, floored at zero + +
+ +๐Ÿ’ก Hint: Effective Stake + +- Load the node into memory for cheaper reads +- If the node is inactive, return \`0\` +- Determine \`currentBucket\` using \`getCurrentBucketNumber()\` +- Expected reports = \`currentBucket - n.firstBucket\` +- Use \`n.reportCount\` as completed reports but subtract one if the last report happened in the current bucket (it isn't completed yet) +- Penalty = \`missed * INACTIVITY_PENALTY\`; cap at the staked amount + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function getEffectiveStake(address nodeAddress) public view returns (uint256) { + OracleNode memory n = nodes[nodeAddress]; + if (!n.active) return 0; + uint256 currentBucket = getCurrentBucketNumber(); + if (currentBucket == n.firstBucket) return n.stakedAmount; + uint256 expectedReports = currentBucket - n.firstBucket; + uint256 actualReportsCompleted = n.reportCount; + if (n.lastReportedBucket == currentBucket && actualReportsCompleted > 0) { + actualReportsCompleted -= 1; + } + if (actualReportsCompleted >= expectedReports) return n.stakedAmount; + uint256 missed = expectedReports - actualReportsCompleted; + uint256 penalty = missed * INACTIVITY_PENALTY; + if (penalty > n.stakedAmount) return 0; + return n.stakedAmount - penalty; +} +\`\`\` + +
+
+ +--- + +3. **Implement \`registerNode(uint256 price)\`** + +* ๐Ÿ—๏ธ This function allows users to register as oracle nodes by staking ETH + +* โš ๏ธ It should require a minimum stake of 1 ETH, otherwise revert with \`InsufficientStake\` + +* ๐Ÿงช It should check that the node is not already registered, otherwise revert with \`NodeAlreadyRegistered\` + +* ๐Ÿ—๏ธ It should create a new \`OracleNode\` struct with the correct data + +* โž• It should add the node address to the \`nodeAddresses\` array + +* ๐Ÿ’ฒ It should call \`reportPrice(price)\` (you'll implement this later) to record the first report in the current bucket + +* ๐Ÿ“ฃ It should emit the \`NodeRegistered\` event + +
+ +๐Ÿ’ก Hint: Node Registration + +Here's what you need to set in the \`OracleNode\` struct: +- \`stakedAmount\` should be \`msg.value\` +- \`lastReportedBucket\` should be \`0\` (will be updated in \`reportPrice\`) +- \`reportCount\` should be \`0\` +- \`claimedReportCount\` should be \`0\` +- \`firstBucket\` should be \`getCurrentBucketNumber()\` (the bucket when the node registered) +- \`active\` should be \`true\` + +After creating the struct, push the node into \`nodeAddresses\`, call \`reportPrice(price)\`, and emit the event. + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function registerNode(uint256 price) public payable { + if (msg.value < MINIMUM_STAKE) revert InsufficientStake(); + if (nodes[msg.sender].active) revert NodeAlreadyRegistered(); + nodes[msg.sender] = OracleNode({ + stakedAmount: msg.value, + lastReportedBucket: 0, + reportCount: 0, + claimedReportCount: 0, + firstBucket: getCurrentBucketNumber(), + active: true + }); + nodeAddresses.push(msg.sender); + reportPrice(price); + emit NodeRegistered(msg.sender, msg.value); +} +\`\`\` + +
+
+ +--- + +4. **Implement \`addStake()\`** + +* ๐Ÿ’ธ This payable function lets an active node increase its stake + +* โš ๏ธ It should revert with \`InsufficientStake\` if \`msg.value == 0\` + +* โž• It should add the sent value to the node's \`stakedAmount\` + +* ๐Ÿ“ฃ It should emit the \`StakeAdded\` event + +
+ +๐Ÿ’ก Hint: Adding Stake + +- Use the \`onlyNode\` modifier to ensure sender is active +- Update \`nodes[msg.sender].stakedAmount\` +- Emit \`StakeAdded\` + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function addStake() public payable onlyNode { + if (msg.value == 0) revert InsufficientStake(); + nodes[msg.sender].stakedAmount += msg.value; + emit StakeAdded(msg.sender, msg.value); +} +\`\`\` + +
+
+ +--- + +5. **Implement \`reportPrice(uint256 price)\`** + +* ๐Ÿงช This function allows registered nodes to report new prices (uses \`onlyNode\` modifier) + +* ๐Ÿ” It should verify the given price is not zero, otherwise revert with \`InvalidPrice\` + +* ๐Ÿ” It should verify the node has sufficient stake (using \`getEffectiveStake\`), otherwise revert with \`InsufficientStake\` + +* ๐Ÿšซ It should prevent reporting twice in the same bucket, otherwise revert with \`AlreadyReportedInCurrentBucket\` + +* ๐Ÿ“Š It should store the node's price in the current bucket's \`TimeBucket\` mapping + +* ๐Ÿ“ˆ It should increment the bucket's \`countReports\` and add the price to \`sumPrices\`. This will be useful for deriving the average later + +* ๐Ÿ”„ It should update the node's \`lastReportedBucket\` and \`reportCount\` + +* ๐Ÿ“ฃ It should emit the \`PriceReported\` event with the sender, price, and bucket number + +
+ +๐Ÿ’ก Hint: Price Reporting + +- Pull \`OracleNode storage node = nodes[msg.sender]\` +- Validate price and stake before touching bucket state +- Use \`timeBuckets[getCurrentBucketNumber()]\` +- Update bucket mappings and aggregates +- Update the node's \`lastReportedBucket\` to \`getCurrentBucketNumber()\` +- Increment the node's \`reportCount\` + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function reportPrice(uint256 price) public onlyNode { + if (price == 0) revert InvalidPrice(); + OracleNode storage node = nodes[msg.sender]; + if (getEffectiveStake(msg.sender) < MINIMUM_STAKE) revert InsufficientStake(); + if (node.lastReportedBucket == getCurrentBucketNumber()) revert AlreadyReportedInCurrentBucket(); + TimeBucket storage bucket = timeBuckets[getCurrentBucketNumber()]; + bucket.prices[msg.sender] = price; + bucket.countReports++; + bucket.sumPrices += price; + + node.lastReportedBucket = getCurrentBucketNumber(); + node.reportCount++; + emit PriceReported(msg.sender, price, getCurrentBucketNumber()); +} +\`\`\` + +
+
+ +--- + +6. **Implement \`claimReward()\`** + +* ๐Ÿงช This function allows past and present nodes to claim their ORA token rewards + +* ๐Ÿ” It should calculate reward amount based on the difference between \`reportCount\` and \`claimedReportCount\`. We will call this number the \`delta\` + +* ๐Ÿ”’ It should revert with \`NoRewardsAvailable\` if \`delta == 0\` + +* ๐Ÿ”ข It should update \`claimedReportCount\` to \`reportCount\` *before* minting the tokens (reentrancy safe) + +* ๐Ÿ’ฐ It should mint \`delta * REWARD_PER_REPORT\` ORA tokens + +* ๐Ÿ“ฃ It should emit the \`NodeRewarded\` event + +
+ +๐Ÿ’ก Hint: Reward Implementation + +- Load the node in storage +- Compute \`delta\` +- Revert if \`delta == 0\` +- Update \`claimedReportCount\` +- Mint the reward and emit the event + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +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); +} +\`\`\` + +
+
+ +--- + +7. **Implement \`_removeNode(address nodeAddress, uint256 index)\`** + +* ๐Ÿ—‚๏ธ This internal function removes a node from the \`nodeAddresses\` array while keeping the array packed. By forcing the caller to provide the index and simply verifying it is at that position we are removing the need to iterate over a potentially large array + +* ๐Ÿ” It should ensure the provided \`index\` is within bounds, otherwise revert with \`IndexOutOfBounds\` + +* โœ… It should ensure the address at the given index matches \`nodeAddress\`, otherwise revert with \`NodeNotAtGivenIndex\` + +* ๐Ÿ” It should use the pop-and-swap pattern to remove the entry efficiently + +* ๐Ÿšซ It should mark the node as inactive without deleting the entire struct (other functions may rely on historical data) + +
+ +๐Ÿ’ก Hint: Removing Nodes + +- Check \`index < nodeAddresses.length\` +- Check \`nodeAddresses[index] == nodeAddress\` +- Assign the last address into the \`index\` slot, then pop +- Set \`nodes[nodeAddress].active = false\` + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function _removeNode(address nodeAddress, uint256 index) internal { + if (nodeAddresses.length <= index) revert IndexOutOfBounds(); + if (nodeAddresses[index] != nodeAddress) revert NodeNotAtGivenIndex(); + nodeAddresses[index] = nodeAddresses[nodeAddresses.length - 1]; + nodeAddresses.pop(); + nodes[nodeAddress].active = false; +} +\`\`\` + +
+
+ +--- + +8. **Implement \`_checkPriceDeviated(uint256 reportedPrice, uint256 averagePrice)\`** + +* ๐Ÿงฎ This internal pure function determines whether a reported price deviates beyond the allowed threshold + +* ๐Ÿ”ข It should compute the absolute difference between the reported price and the average + +* ๐Ÿ“ It should convert the deviation to basis points and compare it against \`MAX_DEVIATION_BPS\` + +* ๐Ÿ” It should return \`true\` when the deviation is greater than the threshold, otherwise \`false\` + +
+ +๐Ÿ’ก Hint: Deviation Check + +- Use a simple conditional to compute the absolute deviation +- Multiply the deviation by 10,000 (basis points) before dividing by \`averagePrice\`. This will allow for greater precision +- Compare the result against \`MAX_DEVIATION_BPS\` + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function _checkPriceDeviated(uint256 reportedPrice, uint256 averagePrice) internal pure returns (bool) { + uint256 deviation = reportedPrice > averagePrice ? reportedPrice - averagePrice : averagePrice - reportedPrice; + uint256 deviationBps = (deviation * 10_000) / averagePrice; + if (deviationBps > MAX_DEVIATION_BPS) { + return true; + } + return false; +} +\`\`\` + +
+
+ +--- + +9. **Implement \`slashNode(address nodeToSlash, uint256 bucketNumber, uint256 index)\`** + +* ๐Ÿ”Ž This function allows anyone to slash nodes that reported prices deviating too far from the average + +* ๐Ÿงช It should verify the node is active, otherwise revert with \`NodeNotRegistered\` + +* โฐ It should verify the bucket is in the past (not current), otherwise revert with \`OnlyPastBucketsAllowed\` + +* ๐Ÿšซ It should verify the node hasn't already been slashed in this bucket, otherwise revert with \`NodeAlreadySlashed\` + +* ๐Ÿ“Š It should verify the node reported a price in this bucket, otherwise revert with \`NodeDidNotReport\` + +* โœ‚๏ธ It should mark the node as slashed and remove their price from the bucket's sum and count + +* ๐Ÿงฎ It should recalculate the average price after removing the node's price + +* ๐Ÿ” It should verify the node's price deviates beyond the threshold using \`_checkPriceDeviated\`, otherwise revert with \`NotDeviated\` + +* ๐Ÿ’ฐ It should slash the node by \`MISREPORT_PENALTY\` (or their full stake if less) + +* ๐Ÿ… It should send 10% of the penalty to the slasher (\`msg.sender\`) + +* ๐Ÿ—‘๏ธ It should remove the node if their stake reaches zero after slashing using \`_removeNode\` + +* โš ๏ธ It should revert with \`FailedToSend\` if the reward transfer fails + +* ๐Ÿ“ฃ It should emit \`NodeSlashed\` and \`NodeExited\` (if the node is removed) + +
+ +๐Ÿ’ก Hint: Complete Slashing Implementation + +Follow these steps: +- Validate node state and bucket recency +- Ensure the node actually reported in that bucket and hasn't been slashed yet +- Remove their contribution from \`sumPrices\`/\`countReports\` +- Recompute the average (excluding the offender) and check deviation with \`_checkPriceDeviated\` +- Apply the penalty by reducing the node's \`stakedAmount\` +- If the node is fully slashed (\`stakedAmount\` == 0), remove them with \`_removeNode\` +- Calculate the reward, and transfer it + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function slashNode(address nodeToSlash, uint256 bucketNumber, uint256 index) public { + if (!nodes[nodeToSlash].active) revert NodeNotRegistered(); + if (getCurrentBucketNumber() == bucketNumber) revert OnlyPastBucketsAllowed(); + TimeBucket storage bucket = timeBuckets[bucketNumber]; + if (bucket.slashedOffenses[nodeToSlash]) revert NodeAlreadySlashed(); + uint256 reportedPrice = bucket.prices[nodeToSlash]; + if (reportedPrice == 0) revert NodeDidNotReport(); + bucket.slashedOffenses[nodeToSlash] = true; + bucket.sumPrices -= reportedPrice; + bucket.countReports--; + uint256 averagePrice = bucket.sumPrices / bucket.countReports; + if (!_checkPriceDeviated(reportedPrice, averagePrice)) { + revert NotDeviated(); + } + OracleNode storage node = nodes[nodeToSlash]; + uint256 actualPenalty = MISREPORT_PENALTY > node.stakedAmount ? node.stakedAmount : MISREPORT_PENALTY; + node.stakedAmount -= actualPenalty; + + uint256 reward = (actualPenalty * SLASHER_REWARD_PERCENTAGE) / 100; + + (bool sent, ) = msg.sender.call{ value: reward }(""); + if (!sent) revert FailedToSend(); + + if (node.stakedAmount == 0) { + _removeNode(nodeToSlash, index); + emit NodeExited(nodeToSlash, 0); + } + + emit NodeSlashed(nodeToSlash, actualPenalty); +} +\`\`\` + +
+
+ +--- + +10. **Implement \`exitNode(uint256 index)\`** + +* ๐Ÿšช This function allows a node to exit and withdraw its stake after a waiting period + +* โณ It should ensure the node waited at least \`WAITING_PERIOD\` buckets since their last report, otherwise revert with \`WaitingPeriodNotOver\`. This way there is ample time to slash them before they exit + +* ๐Ÿ’ฐ It should compute the withdrawable stake using \`getEffectiveStake\` before removing the node + +* ๐Ÿ—‘๏ธ It should call \`_removeNode(msg.sender, index)\` to mark the node inactive and keep the node array tidy + +* ๐Ÿงน It should send the stake back to the sender + +* โš ๏ธ It should revert with \`FailedToSend\` if the withdrawal transfer fails + +* ๐Ÿ“ฃ It should emit the \`NodeExited\` event with the withdrawn amount + +
+ +๐Ÿ’ก Hint: Exit Logic + +- Fetch the node in storage +- Require \`node.lastReportedBucket + WAITING_PERIOD <= getCurrentBucketNumber()\` +- Compute \`stake = getEffectiveStake(msg.sender)\` **before** removing +- Call \`_removeNode\` +- Transfer the stake using \`call\` +- Emit the event + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function exitNode(uint256 index) public onlyNode { + OracleNode storage node = nodes[msg.sender]; + if (node.lastReportedBucket + WAITING_PERIOD > getCurrentBucketNumber()) revert WaitingPeriodNotOver(); + uint256 stake = getEffectiveStake(msg.sender); + _removeNode(msg.sender, index); + (bool sent, ) = msg.sender.call{ value: stake }(""); + if (!sent) revert FailedToSend(); + + emit NodeExited(msg.sender, stake); +} +\`\`\` + +
+
+ +--- + +11. **Implement \`getNodeAddresses()\`** + +* ๐Ÿ“š This view function should return every registered node address in order. This is convenient for the front-end + +
+ +๐Ÿ’ก Hint: Node List + +- The array \`nodeAddresses\` tracks the registration order +- Just return the array + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function getNodeAddresses() public view returns (address[] memory) { + return nodeAddresses; +} +\`\`\` + +
+
+ +--- + +12. **Implement \`getLatestPrice()\`** + +* ๐Ÿ“ฆ This function returns the aggregated price from the most recent completed bucket + +* ๐Ÿ” It should get the previous bucket (current bucket - 1) since the current bucket is still being filled + +* ๐Ÿ“Š It should retrieve the \`TimeBucket\` for that bucket + +* โ›”๏ธ It should revert with \`NoValidPricesAvailable\` if \`bucket.countReports == 0\` + +* ๐Ÿงฎ It should return the average price: \`bucket.sumPrices / bucket.countReports\` + +
+ +๐Ÿ’ก Hint: Latest Price + +- Use \`getCurrentBucketNumber() - 1\` +- Access the bucket mapping +- Guard against empty buckets +- Return the average + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function getLatestPrice() public view returns (uint256) { + TimeBucket storage bucket = timeBuckets[getCurrentBucketNumber() - 1]; + if (bucket.countReports == 0) revert NoValidPricesAvailable(); + return bucket.sumPrices / bucket.countReports; +} +\`\`\` + +
+
+ +--- + +13. **Implement \`getPastPrice(uint256 bucketNumber)\`** + +* ๐Ÿ•ฐ๏ธ Because we are storing pricing in time-segmented buckets we can enable retrieving a price from any one of these buckets. This view function returns the average price for any historical bucket + +* โ›”๏ธ It should revert with \`NoValidPricesAvailable\` if that bucket has no reports + +
+ +๐Ÿ’ก Hint: Past Price + +- Grab \`TimeBucket storage bucket = timeBuckets[bucketNumber]\` +- Check \`bucket.countReports\` +- Return \`bucket.sumPrices / bucket.countReports\` + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function getPastPrice(uint256 bucketNumber) public view returns (uint256) { + TimeBucket storage bucket = timeBuckets[bucketNumber]; + if (bucket.countReports == 0) revert NoValidPricesAvailable(); + return bucket.sumPrices / bucket.countReports; +} +\`\`\` + +
+
+ +--- + +14. **Implement \`getAddressDataAtBucket(address nodeAddress, uint256 bucketNumber)\`** + +* ๐Ÿ”Ž This view function returns the price a node reported in a bucket and whether they were slashed there + +
+ +๐Ÿ’ก Hint: Bucket Data + +- Access the bucket mapping and return both \`prices[nodeAddress]\` and \`slashedOffenses[nodeAddress]\` + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function getAddressDataAtBucket(address nodeAddress, uint256 bucketNumber) public view returns (uint256, bool) { + TimeBucket storage bucket = timeBuckets[bucketNumber]; + return (bucket.prices[nodeAddress], bucket.slashedOffenses[nodeAddress]); +} +\`\`\` + +
+
+ +--- + +15. **Implement \`getOutlierNodes(uint256 bucketNumber)\`** + +* ๐Ÿ“Š This view function identifies nodes whose price deviates beyond the maximum deviation in a given bucket + +* ๐Ÿ—ƒ๏ธ It should iterate over all \`nodeAddresses\` (this is fine since it is a view method) + +* ๐Ÿงฎ For each reported price, calculate the average of the remaining reports and test deviation with \`_checkPriceDeviated\` + +* ๐Ÿงน Collect only the outliers and trim the array before returning + +
+ +๐Ÿ’ก Hint: Outlier Detection + +- Allocate a temporary array of size \`bucket.countReports\` +- Loop through \`nodeAddresses\` +- Skip addresses that did not report (\`reportedPrice == 0\`) +- Compute \`averagePrice = (bucket.sumPrices - reportedPrice) / (bucket.countReports - 1)\` +- If \`_checkPriceDeviated(...)\` returns true, store the address in the temp array and increment a counter +- Allocate a trimmed array of length \`outlierCount\` and copy the collected addresses + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity +function getOutlierNodes(uint256 bucketNumber) public view returns (address[] memory) { + TimeBucket storage bucket = timeBuckets[bucketNumber]; + address[] memory outliers = new address[](bucket.countReports); + uint256 outlierCount = 0; + for (uint256 i = 0; i < nodeAddresses.length; i++) { + address nodeAddress = nodeAddresses[i]; + uint256 reportedPrice = bucket.prices[nodeAddress]; + if (reportedPrice == 0) continue; + uint256 averagePrice = (bucket.sumPrices - reportedPrice) / (bucket.countReports - 1); + if (_checkPriceDeviated(reportedPrice, averagePrice)) { + outliers[outlierCount] = nodeAddress; + outlierCount++; + } + } + address[] memory trimmed = new address[](outlierCount); + for (uint256 i = 0; i < outlierCount; i++) { + trimmed[i] = outliers[i]; + } + return trimmed; +} +\`\`\` + +
+
+ +--- + +### ๐Ÿค” Key Insights: + +- **Bucket-Based System**: Prices are organized into time buckets (24 blocks each), allowing for discrete time windows and preventing double-reporting within the same bucket +- **Economic Incentives**: Nodes stake ETH and can be slashed for reporting prices that deviate too far from the average, while good behavior rewards nodes with ORA tokens (1 ORA per report) +- **Effective Stake**: Nodes face inactivity penalties for missed buckets, reducing their effective stake over time if they fail to report regularly +- **Decentralized**: Anyone can participate by staking, no central authority needed +- **Self-Correcting**: Slashing mechanism punishes nodes that report prices deviating beyond the threshold (10% by default) +- **Average Aggregation**: Prices are aggregated using a simple average from all reports in a completed bucket, providing a fair representation of the collective price +- **Use Cases**: Excellent for DeFi applications where economic alignment is crucial and price updates occur at regular intervals + +### ๐Ÿค” Critical Thinking: Security Vulnerabilities + +- **Robustness vs. Whitelist Oracle**: Unlike the whitelist oracle which relies on a single trusted authority, the staking oracle's design distributes trust among all staking nodes. Manipulating the output requires a majority of nodes to collude, which is economically disincentivized due to the risk of slashing. As a result, unless an attacker controls a majority of the total effective stake, they cannot egregiously manipulate the reported priceโ€”making the system considerably more robust than one with simple whitelist control. + +--- + +### Testing your progress + +๐Ÿ” Run the following command to check if you implemented the functions correctly. + +\`\`\`sh + +yarn test --grep "Checkpoint2" + +\`\`\` + +โœ… Did the tests pass? You can dig into any errors by viewing the tests at \`packages/hardhat/test/StakingOracle.ts\`. + +### Try it out! + +๐ŸŒŽ In the real world this oracle would have too much latency to be very useful due to the need for 24 block windows. However, this was done to make it possible for you to see how the oracle operates in real time without everything happening too fast to comprehend. There is no reason why this couldn't work with single block windows although in decentralized systems you must always consider that the lower the latency requirements, the fewer nodes can participate + +๐Ÿ”„ Run \`yarn deploy --reset\` then test the staking oracle. Go to the \`Staking\` page and try registering your own node and reporting prices. + +๐Ÿšฐ Make sure you get a couple ETH from the faucet and then press the "Register Node" button. + +![Staking Buttons Panel](https://github.com/user-attachments/assets/c32b9bdc-eb1e-4630-ae9f-e57a34deac45) + +> ๐Ÿ—บ๏ธ You can navigate to past buckets using the arrows. + +โœ๏ธ Now you can press the pencil icon to report a new price. Enter your price and press the checkmark button to confirm. If you want to report the same price in the next block then just press the refresh icon next to the pencil. + +![SelfNodeRow](https://github.com/user-attachments/assets/32952de3-bd72-4cc6-98ba-a6b374539533) + +> โ€ผ๏ธ "Insufficient Stake" errors? Look at your staked balance ๐Ÿ‘€. It has fallen below the minimum amount of stake because you let some blocks pass without reporting. Just press the + button next to your stake to add an extra ETH (get it from the faucet if you have less than 1 in your wallet). + +๐Ÿ˜ฎโ€๐Ÿ’จ *Whew!* That was a lot of work pressing all those buttons to keep from getting the inactive penalty! Much easier when bots are doing all the work and you can just watch. Exit your node (if it stresses you) and lets have some fun. + +๐Ÿงช **Live Simulation**: Run the \`yarn simulate:staking\` command to watch a live simulation of staking oracle behavior with multiple nodes: + +\`\`\`sh + +yarn simulate:staking + +\`\`\` + +๐Ÿค– This will start automated bots and demonstrate how slashing and average aggregation impact the reported price. Right now they are all on default settings so the price won't deviate, but... + +โš™๏ธ You can update the price deviation and skip probability by pressing the gear icon. Go ahead and make some bots start to produce wild deviations then view the past buckets (by using the arrows) to see the "slash" button activated. Press it to slash any deviated nodes. + +๐Ÿฅฑ If you get tired of slashing deviated nodes but still want to see them get slashed you can re-run the command with this environment variable: + +\`\`\`sh +AUTO_SLASH=true yarn simulate:staking +\`\`\` + +### ๐Ÿฅ… Goals: + +- You can register as an oracle node by staking ETH +- Registered nodes can report prices once per bucket and claim ORA token rewards based on report count +- Anyone can slash nodes that report prices deviating too far from the average and earn rewards +- System aggregates prices from completed buckets using average calculation +- Inactivity penalties reduce effective stake for nodes that miss reporting in buckets +- Economic incentives drive honest behavior and regular participation +- Understand the trade-offs between decentralization and latency +- See examples in the wild: [Chainlink](https://chain.link) and [PYTH](https://www.pyth.network/) + +--- + +## Checkpoint 3: ๐Ÿง  Optimistic Oracle Architecture + +๐Ÿคฟ Now let's dive into the most sophisticated of this challenge's three designs: the **Optimistic Oracle**. Unlike the previous two designs that focus on price data, this one will handle any type of binary (true/false) question about real-world events. + +๐ŸŽฏ **What makes it "optimistic"?** The system assumes proposals are correct unless someone disputes them. This creates a game-theoretic mechanism where economic incentives encourage honest behavior while providing strong security guarantees through dispute resolution. + +๐Ÿ’ก **Key Innovation**: Instead of requiring constant active participation from multiple parties (like staking oracles), optimistic oracles only require intervention when something goes wrong. This makes them highly efficient for events that don't need frequent updates. + +๐Ÿ” **Real-World Applications**: +- **Cross-chain bridges**: "Did transaction X happen on chain Y?" +- **Insurance claims**: "Did flight ABC get delayed by more than 2 hours?" +- **Prediction markets**: "Did candidate X win the election?" +- **DeFi protocols**: "Did token X reach price Y on date Z?" + +๐Ÿงญ Before coding, let's understand the flow at a glance. + +**Roles**: +- **asserter**: posts an assertion + reward +- **proposer**: posts an outcome + bond +- **disputer**: challenges the proposal + bond +- **decider**: resolves disputes and sets the winner + +**Windows**: +- Assertion window: when proposals are allowed +- Dispute window: short period after a proposal when disputes are allowed + +**Incentives**: +- Reward + a bond refund flow to the winner; the loser's bond goes to the decider in disputes + +\`\`\`mermaid + +sequenceDiagram + participant A as Asserter + participant P as Proposer + participant D as Disputer + participant C as Decider + participant O as OptimisticOracle + A->>O: assertEvent(description, startTime, endTime) + reward + Note over O: Wait until startTime + alt No proposal before endTime + A->>O: claimRefund(assertionId) + O-->>A: refund reward + else Proposal received + P->>O: proposeOutcome(assertionId, outcome) + bond + Note over O: Start dispute window + alt No dispute before deadline + O-->>P: Claim undisputed rewards -> reward + bond refund + else Dispute filed in window + D->>O: disputeOutcome(assertionId) + bond + C->>O: settleAssertion(assertionId, resolvedOutcome) + O-->>Winner: claimDisputedReward() -> reward + bond refund + end + end +\`\`\` + +๐Ÿงฉ The way this system works is someone creates an **assertion**; +- Something that needs a boolean answer (\`true\` or \`false\`) +- After a certain time +- Before a specific deadline +- With a reward + +๐Ÿฆ— If no one answers before the end of the assertion window, the asserter can claim a refund. + +๐Ÿ’ก If someone knows the answer within the correct time then they **propose** the answer, posting a bond. This bond is a risk to them because if their answer is thought to be wrong by someone else then they might lose it. This keeps people economically tied to the **proposals** they make. + +โณ Then if no one **disputes** the proposal before the dispute window is over the proposal is considered to be true, and the proposer may claim the reward and get back their bond. The dispute window should give anyone ample time to submit a dispute. + +โš–๏ธ If someone does **dispute** during the dispute window then they must also post a bond equal to the proposer's bond. This kicks the assertion out of any particular timeline and puts it in a state where it is waiting for a decision from the **decider**. Once the decider contract has **settled** the assertion, the winner can claim the reward and their posted bond. The decider gets the loser's bond. + +๐Ÿง‘โ€โš–๏ธ Now, as we mentioned earlier, this oracle has a role called the **decider**. For this example it is just a simple contract that anyone can call to settle disputes. One could imagine in a live oracle you would want something more robust such as a group of people who vote to settle disputes. + +๐Ÿ”— Look at how [UMA](https://uma.xyz/) does this with their Optimistic Oracle (OO). **This contract is based UMA's OO design**. + +## Checkpoint 4: โšก Optimistic Oracle - Core Functions + +๐Ÿ‘ฉโ€๐Ÿ’ป This section challenges you to implement the optimistic oracle system from scratch. You'll write the core functions that handle assertions, proposals, disputes, and settlements. + +๐ŸŽฏ **Your Mission**: Complete the missing function implementations in the \`OptimisticOracle.sol\` contract. The contract skeleton is already provided with all the necessary structs, events, and modifiers - you just need to fill in the logic. + +๐Ÿงช **Testing Strategy**: Each function you implement can be tested individually using the provided test suite. Run \`yarn test\` after implementing each function to verify your solution works correctly. + +๐Ÿ” Open the \`packages/hardhat/contracts/02_Optimistic/OptimisticOracle.sol\` file to implement the optimistic oracle functionality. + +### โœ๏ธ Tasks: + +1. **Implement \`assertEvent(string memory description, uint256 startTime, uint256 endTime)\`** + +* ๐Ÿ“ฃ This function allows users to assert that an event will have a true/false outcome + +* ๐Ÿ’ธ It should require that the reward (\`msg.value\`) is greater than 0 . If it is not then revert with \`InvalidValue\` + +* โฑ๏ธ It should accept 0 for \`startTime\` and set it to \`block.timestamp\` + +* โณ It should accept 0 for \`endTime\` and default to \`startTime + MINIMUM_ASSERTION_WINDOW\` + +* ๐Ÿ•ฐ๏ธ It should check that the given \`startTime\` is less than the current time (\`block.timestamp\`) and revert with \`InvalidTime\` if it is + +* ๐Ÿงญ It should validate the time window given is >= \`MINIMUM_ASSERTION_WINDOW\`, otherwise revert with \`InvalidTime\` + +* ๐Ÿ—๏ธ It should create a new \`EventAssertion\` struct with relevant properties set - see if you can figure it out + +* ๐Ÿ—‚๏ธ That struct should be stored in the \`assertions\` mapping. You can use \`nextAssertionId\` but don't forget to increment it afterwards! + +* ๐Ÿ“ฃ It should emit the \`EventAsserted\` event + +
+ +๐Ÿ’ก Hint: Asserting Events + +Here are more granular instructions on setting up the EventAssertion struct: +- asserter should be \`msg.sender\` +- reward should be \`msg.value\` +- bond should be the reward x 2 (You will know why as you understand the economics and game theory) +- startTime = \`startTime\` +- endTime = \`endTime\` +- description = \`description\` +- any remaining properties can be initialized with the default values (\`false\`, \`address(0)\`, etc.) + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity + function assertEvent(string memory description, uint256 startTime, uint256 endTime) external payable returns (uint256) { + uint256 assertionId = nextAssertionId; + nextAssertionId++; + if (msg.value == 0) revert InvalidValue(); + + // Set default times if not provided + if (startTime == 0) { + startTime = block.timestamp; + } + if (endTime == 0) { + endTime = startTime + MINIMUM_ASSERTION_WINDOW; + } + + if (startTime < block.timestamp) revert InvalidTime(); + if (endTime < startTime + MINIMUM_ASSERTION_WINDOW) revert InvalidTime(); + + assertions[assertionId] = EventAssertion({ + asserter: msg.sender, + proposer: address(0), + disputer: address(0), + proposedOutcome: false, + resolvedOutcome: false, + reward: msg.value, + bond: msg.value * 2, + startTime: startTime, + endTime: endTime, + claimed: false, + winner: address(0), + description: description + }); + + emit EventAsserted(assertionId, msg.sender, description, msg.value); + return assertionId; + } +\`\`\` + +
+
+ +--- + +2. **Implement \`proposeOutcome(uint256 assertionId, bool outcome)\`** + +* ๐Ÿ—ณ๏ธ This function allows users to propose the outcome for an asserted event + +* ๐Ÿ” It should check that the assertion exists and hasn't been proposed yet. Otherwise revert with \`AssertionNotFound\` or \`AssertionProposed\` + +* โฑ๏ธ It should validate the timing constraints - it has to be after \`startTime\` but before the \`endTime\` or else revert with \`InvalidTime\` + +* ๐Ÿ’ธ It should enforce the correct bond amount is provided or revert with \`InvalidValue\` + +* โœ๏ธ It should update the assertion with the proposal + +* โณ It should set the \`endTime\` to \`block.timestamp + MINIMUM_DISPUTE_WINDOW\` + +* ๐Ÿ“ฃ It should emit \`OutcomeProposed\` + +
+ +๐Ÿ’ก Hint: Proposing Outcomes + +You want to set these properties on the assertion: +- proposer should be \`msg.sender\` +- proposedOutcome should be \`outcome\` +- endTime should be updated to \`block.timestamp + MINIMUM_DISPUTE_WINDOW\` + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity + function proposeOutcome(uint256 assertionId, bool outcome) external payable { + EventAssertion storage assertion = assertions[assertionId]; + + if (assertion.asserter == address(0)) revert AssertionNotFound(); + if (assertion.proposer != address(0)) revert AssertionProposed(); + if (block.timestamp < assertion.startTime) revert InvalidTime(); + if (block.timestamp > assertion.endTime) revert InvalidTime(); + if (msg.value != assertion.bond) revert InvalidValue(); + + assertion.proposer = msg.sender; + assertion.proposedOutcome = outcome; + assertion.endTime = block.timestamp + MINIMUM_DISPUTE_WINDOW; + + emit OutcomeProposed(assertionId, msg.sender, outcome); + } +\`\`\` + +
+
+ +--- + +3. **Implement \`disputeOutcome(uint256 assertionId)\`** + +* โš–๏ธ This function allows users to dispute a proposed outcome + +* ๐Ÿ” It should check that a proposal exists and hasn't been disputed yet, if not then revert with \`NotProposedAssertion\` or \`ProposalDisputed\` + +* โณ It should validate the timing constraints to make sure the \`endTime\` has not been passed or else it should revert with \`InvalidTime\` + +* ๐Ÿ’ธ It should require the correct bond amount (as set on the assertion) + +* ๐Ÿ“ It should record the disputer on the assertion struct + +
+ +๐Ÿ’ก Hint: Disputing Outcomes + +The bond amount should be the bond set on the assertion. The same amount that the proposer paid. + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity + function disputeOutcome(uint256 assertionId) external payable { + EventAssertion storage assertion = assertions[assertionId]; + + if (assertion.proposer == address(0)) revert NotProposedAssertion(); + if (assertion.disputer != address(0)) revert ProposalDisputed(); + if (block.timestamp > assertion.endTime) revert InvalidTime(); + if (msg.value != assertion.bond) revert InvalidValue(); + + assertion.disputer = msg.sender; + + emit OutcomeDisputed(assertionId, msg.sender); + } +\`\`\` + +
+
+ +--- + +### Testing your progress + +๐Ÿ” Run the following command to check if you implemented the functions correctly. + +\`\`\`sh + +yarn test --grep "Checkpoint4" + +\`\`\` + +### ๐Ÿฅ… Goals: + +- You can assert events with descriptions and time windows +- You can propose outcomes for asserted events +- You can dispute proposed outcomes +- The system correctly handles timing constraints +- Bond amounts are properly validated + +## Checkpoint 5: ๐Ÿ’ฐ Optimistic Oracle - Reward Claims + +๐ŸŽฏ **Your Mission**: Implement the reward claiming mechanisms that allow participants to collect their earnings based on the outcomes of assertions, proposals, and disputes. + +๐Ÿ’ก **Key Concept**: The optimistic oracle has three different scenarios for claiming rewards: +- **Undisputed proposals**: Proposer gets reward + bond back +- **Disputed proposals**: Winner (determined by decider) gets reward + bond back +- **Refunds**: Asserter gets reward back when no proposals are made + +### โœ๏ธ Tasks: + +1. **Implement \`claimUndisputedReward(uint256 assertionId)\`** + +The proposer can claim the reward only after the deadline, as long as no dispute was submitted before it. + +* ๐Ÿงฉ A proposal must exist (revert with \`NotProposedAssertion\`) + +* ๐Ÿšซ No dispute must have been raised (revert with \`ProposalDisputed\`) + +* โฐ Current time must be after the dispute \`endTime\` (revert with \`InvalidTime\`) + +* ๐Ÿ”’ Not already claimed (revert with \`AlreadyClaimed\`) + +* ๐Ÿ’ธ Transfer \`reward + proposer bond\` to the proposer + +* ๐Ÿ“ฃ Emit \`RewardClaimed\` + +
+ +๐Ÿ’ก Hint: Claiming Undisputed Rewards + +- Validate the assertion has a proposer and no disputer +- Check the deadline has passed +- Mark as claimed first +- Set \`resolvedOutcome\` to the proposed outcome and \`winner\` to the proposer +- Compute \`totalReward = reward + bond\` +- Use safe ETH send with revert on failure + +
+ +๐ŸŽฏ Solution + +\`\`\`solidity + function claimUndisputedReward(uint256 assertionId) external { + EventAssertion storage assertion = assertions[assertionId]; + + if (assertion.proposer == address(0)) revert NotProposedAssertion(); + if (assertion.disputer != address(0)) revert ProposalDisputed(); + if (block.timestamp <= assertion.endTime) revert InvalidTime(); + if (assertion.claimed) revert AlreadyClaimed(); + + assertion.claimed = true; + assertion.resolvedOutcome = assertion.proposedOutcome; + assertion.winner = assertion.proposer; + + uint256 totalReward = (assertion.reward + assertion.bond); + + (bool winnerSuccess, ) = payable(assertion.proposer).call{value: totalReward}(""); + if (!winnerSuccess) revert TransferFailed(); + + emit RewardClaimed(assertionId, assertion.proposer, totalReward); + } +\`\`\` + +
+
+ +--- + +2. **Implement \`claimDisputedReward(uint256 assertionId)\`** + +Very similar to the last function except this one allows the winner of the dispute to claim *only after the Decider has resolved the dispute*. + +* ๐Ÿงฉ A proposal must exist (revert with \`NotProposedAssertion\`) + +* โš–๏ธ A dispute must exist (revert with \`NotDisputedAssertion\`) + +* ๐Ÿง‘โ€โš–๏ธ The decider must have set a winner (revert with \`AwaitingDecider\`) + +* ๐Ÿ”’ Not already claimed (revert with \`AlreadyClaimed\`) + +* ๐Ÿ“ Set the \`claimed\` property on the assertion to \`true\` + +* ๐Ÿ’ธ Transfer the loser's bond to the decider, then send the reward and bond refund to the winner + +* ๐Ÿ“ฃ Emit \`RewardClaimed\` + +
+๐Ÿ’ก Hint: Claiming Disputed Rewards + +- Validate assertion state: proposed, disputed, winner set, not yet claimed +- Mark as claimed *before* paying to avoid re-entrancy +- Pay the losers bond to the \`decider\` +- Winner receives \`(reward + bond)\` +- Use safe ETH sending pattern with revert on failure (\`TransferFailed\`) + +
+๐ŸŽฏ Solution + +\`\`\`solidity + function claimDisputedReward(uint256 assertionId) external { + EventAssertion storage assertion = assertions[assertionId]; + + if (assertion.proposer == address(0)) revert NotProposedAssertion(); + if (assertion.disputer == address(0)) revert NotDisputedAssertion(); + if (assertion.winner == address(0)) revert AwaitingDecider(); + if (assertion.claimed) revert AlreadyClaimed(); + + assertion.claimed = true; + + (bool deciderSuccess, ) = payable(decider).call{value: assertion.bond}(""); + if (!deciderSuccess) revert TransferFailed(); + + uint256 totalReward = assertion.reward + assertion.bond; + + (bool winnerSuccess, ) = payable(assertion.winner).call{value: totalReward}(""); + if (!winnerSuccess) revert TransferFailed(); + + emit RewardClaimed(assertionId, assertion.winner, totalReward); + } +\`\`\` + +
+
+ +--- + +3. **Implement \`claimRefund(uint256 assertionId)\`** + +This function enables the asserter to get a refund of their posted reward when no proposal arrives by the deadline. + +* ๐Ÿšซ No proposer exists (revert with \`AssertionProposed\`) + +* โฐ After assertion endTime ( revert with \`InvalidTime\`) + +* ๐Ÿ”’ Not already claimed (revert with \`AlreadyClaimed\`) + +* ๐Ÿ›ก๏ธ Mark the assertion as claimed to avoid re-entrancy + +* ๐Ÿ’ธ Refund the reward to the asserter + +* โœ… Check for successful transfer (revert with \`TransferFailed\`) + +* ๐Ÿ“ฃ Emit \`RefundClaimed\` + +
+๐Ÿ’ก Hint: No Proposal Refund + +- Validate: no proposal, now > endTime, not claimed +- Mark as claimed then refund +- Emit refund event + +
+๐ŸŽฏ Solution + +\`\`\`solidity + function claimRefund(uint256 assertionId) external { + EventAssertion storage assertion = assertions[assertionId]; + + if (assertion.proposer != address(0)) revert AssertionProposed(); + if (block.timestamp <= assertion.endTime) revert InvalidTime(); + if (assertion.claimed) revert AlreadyClaimed(); + + assertion.claimed = true; + + (bool refundSuccess, ) = payable(assertion.asserter).call{value: assertion.reward}(""); + if (!refundSuccess) revert TransferFailed(); + emit RefundClaimed(assertionId, assertion.asserter, assertion.reward); + } +\`\`\` + +
+
+ +--- + +4. **Implement \`settleAssertion(uint256 assertionId, bool resolvedOutcome)\`** + +This is the method that the decider will call to settle whether the proposer or disputer are correct. + +It should be: + +* ๐Ÿง‘โ€โš–๏ธ Only callable by the \`decider\` contract + +* โš–๏ธ The assertion must be both proposed and disputed (or revert with \`NotProposedAssertion\` or \`NotDisputedAssertion\`) + +* ๐Ÿ”’ We need to make sure the winner has not already been set (or revert with \`AlreadySettled\`) + +* โœ๏ธ Now we should set the resolvedOutcome property + +* ๐Ÿ Winner = proposer if proposedOutcome == resolvedOutcome, else disputer + +* ๐Ÿ“ฃ Emit \`AssertionSettled\` + +
+๐Ÿ’ก Hint: Decider Sets Winner + +We just need the decider to use the remaining unused properties to establish which party is correct, hence, which party gets to claim the reward. + +Set resolvedOutcome to true or false based on what is the actual truth regarding an assertion. + +Then set the winner to the proposer if the proposer was correct *or* set it to the disputer is the disputer was correct. + +
+๐ŸŽฏ Solution + +\`\`\`solidity + function settleAssertion(uint256 assertionId, bool resolvedOutcome) external onlyDecider { + EventAssertion storage assertion = assertions[assertionId]; + + if (assertion.proposer == address(0)) revert NotProposedAssertion(); + if (assertion.disputer == address(0)) revert NotDisputedAssertion(); + if (assertion.winner != address(0)) revert AlreadySettled(); + + assertion.resolvedOutcome = resolvedOutcome; + + assertion.winner = (resolvedOutcome == assertion.proposedOutcome) + ? assertion.proposer + : assertion.disputer; + + emit AssertionSettled(assertionId, resolvedOutcome, assertion.winner); + } +\`\`\` + +
+
+ +--- + +### Testing your progress + +๐Ÿ” Run the following command to check if you implemented the functions correctly. + +\`\`\`sh + +yarn test --grep "Checkpoint5" + +\`\`\` + +### ๐Ÿฅ… Goals: + +- Proposers can claim rewards for undisputed assertions +- Winners can claim rewards after disputes are settled +- Asserters can claim refunds when no proposals are made +- The decider can settle disputed assertions +- The system prevents double-claiming and re-entrancy attacks +- All transfers are handled safely with proper error checking + + +--- + +## Checkpoint 6: ๐Ÿง‘โ€โš–๏ธ Optimistic Oracle - State Management + +๐ŸŽฏ **Your Mission**: Implement the final pieces of the optimistic oracle: utility functions for querying assertion states and resolutions. + +### โœ๏ธ Tasks: + +1. **Implement \`getState(uint256 assertionId)\`** + +This function returns a simple state machine view for UI/testing. + +The states are defined as follows in an enum at the top of the contract. +**States**: Invalid, Asserted, Proposed, Disputed, Settled, Expired + +Think through how you can check which properties have been set to derive the current state of the assertion. + +For instance, if the \`asserter\` property is empty then you would return an \`Invalid\` state. + +Try to deduce the rest without any help. + +
+๐Ÿ’ก Hint: Derive State + +- \`Invalid\` if no assertion +- Winner set => \`Settled\` +- Disputer set => \`Disputed\` +- No proposer: if past endTime => \`Expired\`, else \`Asserted\` +- Proposer present: if past endTime => \`Settled\`, else \`Proposed\` + +
+๐ŸŽฏ Solution + +\`\`\`solidity + function getState(uint256 assertionId) external view returns (State) { + EventAssertion storage a = assertions[assertionId]; + + if (a.asserter == address(0)) return State.Invalid; + + // If there's a winner, it's settled + if (a.winner != address(0)) return State.Settled; + + // If there's a dispute, it's disputed + if (a.disputer != address(0)) return State.Disputed; + + // If no proposal yet, check if deadline has passed + if (a.proposer == address(0)) { + if (block.timestamp > a.endTime) return State.Expired; + return State.Asserted; + } + + // If no dispute and deadline passed, it's settled (can be claimed) + if (block.timestamp > a.endTime) return State.Settled; + + // Otherwise it's proposed + return State.Proposed; + } +\`\`\` + +
+
+ +--- + +2. **Implement \`getResolution(uint256 assertionId)\`** + +This function will help everyone know the exact outcome of the assertion. + +* ๐Ÿ”Ž It should revert with \`AssertionNotFound\` if it doesn't exist + +* โณ Then we just need to check if anyone disputed it and that the dispute window is up to know we can rely on the \`proposedOutcome\` (if the time isn't over then revert with \`InvalidTime\`) + +* ๐Ÿง‘โ€โš–๏ธ Otherwise, if a disupte has been made, then we just need to make sure the \`winner\` has been set by the decider (or else revert with \`AwaitingDecider\`) + +
+๐Ÿ’ก Hint: Read Outcome Carefully + +- Handle undisputed vs disputed paths +- Enforce timing and readiness conditions with appropriate errors + +The important thing here is that it reverts if it is not settled and if it has been then it returns the correct outcome, whether that be a proposal that was undisputed or a disputed proposal that was then settled by the decider. + +
+๐ŸŽฏ Solution + +\`\`\`solidity + function getResolution(uint256 assertionId) external view returns (bool) { + EventAssertion storage a = assertions[assertionId]; + if (a.asserter == address(0)) revert AssertionNotFound(); + + if (a.disputer == address(0)) { + if (block.timestamp <= a.endTime) revert InvalidTime(); + return a.proposedOutcome; + } else { + if (a.winner == address(0)) revert AwaitingDecider(); + return a.resolvedOutcome; + } + } +\`\`\` + +
+
+ +--- + +### Testing your progress + +๐Ÿ” Run the following command to check if you implemented the functions correctly. + +\`\`\`sh + +yarn test --grep "Checkpoint6" + +\`\`\` + +โœ… Did the tests pass? You can dig into any errors by viewing the tests at \`packages/hardhat/test/OptimisticOracle.ts\`. + +### Try it out! + +๐Ÿ”„ Run \`yarn deploy --reset\` then test the optimistic oracle. Try creating assertions, proposing outcomes, and disputing them. + +๐Ÿ–ฅ๏ธ Go to the Optimistic page to interact with your new protocol + +![OptimisticOracle](https://github.com/user-attachments/assets/9ed4f066-152b-43a9-a5d4-933221137905) + +1. **Submit a New Assertion**: + Go to the "Optimistic" page and fill in the required fields to create a new assertion. + - Enter the assertion details and submit. + +2. **Propose an Outcome**: + Once an assertion is created, use the UI to propose an outcome for your assertion. + +3. **Dispute an Outcome**: + If someone disagrees with the proposed outcome, they can dispute it using the dispute button shown in the table for pending assertions. + +4. **Wait for Dispute Window & Settlement**: + - Wait for the dispute window (the protocolโ€™s timer) to expire. + - If no dispute is made, the assertion settles automatically. + - If disputed, the decider must choose the winner; monitor status updates in the table. + +5. **Check Outcomes**: + View the resolution and settlement status for each assertion directly in the UI. + +๐Ÿง‘โ€๐Ÿ’ป Experiment by creating, proposing, and disputing assertions to observe the full optimistic oracle workflow in the frontend. + +๐Ÿงช **Live Simulation**: Run the \`yarn simulate:optimistic\` command to see the full optimistic oracle lifecycle in action: + +\`\`\`sh + +yarn simulate:optimistic + +\`\`\` + +๐Ÿค– This will start automated bots that create assertions, propose outcomes, and dispute proposals, so you can observe rewards, bonds, fees, and timing windows in a realistic flow. It is up to you to settle disputes! + +### ๐Ÿฅ… Goals: + +- The system provides clear state information for all assertions +- Users can query resolved outcomes for both disputed and undisputed assertions +- All functions handle edge cases and invalid states appropriately +- The complete optimistic oracle system works end-to-end +--- + +## Checkpoint 7: ๐Ÿ” Oracle Comparison & Trade-offs + +๐Ÿง  Now let's analyze the strengths and weaknesses of each oracle design. + +### ๐Ÿ“Š Comparison Table: +| Aspect | Whitelist Oracle | Staking Oracle | Optimistic Oracle | +|--------|------------------|----------------|-------------------| +| **Speed** | Fast | Medium | Slow | +| **Security** | Low (trusted authority) | High (economic incentives) | High (dispute resolution) | +| **Decentralization** | Low | High | Depends on Decider Implementation | +| **Cost** | Low | Medium (stake) | High (rewards and bonds) | +| **Complexity** | Simple | Medium | Complex | + +### ๐Ÿค” Key Trade-offs: + +1. **Whitelist Oracle:** + +- โœ… Simple and fast + +- โœ… Low gas costs + +- โŒ Requires trust in centralized authority + +- โŒ Single point of failure + +2. **Staking Oracle:** + +- โœ… Decentralized with economic incentives + +- โœ… Self-correcting through slashing + +- โŒ More complex to implement + +- โŒ Higher gas costs + +3. **Optimistic Oracle:** + +- โœ… Economic security + +- โœ… Can be used for any type of data (not just prices) + +- โœด๏ธ Decider role is the weakest link and should be carefully implemented though it is up to the consuming application whether it wants to wait for a resolution or post another assertion and hope a proposal passes without dispute + +- โŒ Higher latency + +- โŒ More complex + +### ๐ŸŽฏ Understanding the "Why": + +Each oracle design solves different problems: + +- **Whitelist Oracle**: Best for setups with trusted intermediaries already in the loop such as RWAs where speed and accuracy are more important than decentralization. +- **Staking Oracle**: Best for high-value DeFi applications where decentralization and security are crucial. Decentralization and latency rise and fall together. +- **Optimistic Oracle**: Best for answering complex questions and where more latency is not a huge problem. Flexible enough to resolve open-ended questions that don't have a strict binary format (e.g., "Which team won the match?"). + +--- + +## Checkpoint 8: ๐Ÿ’พ Deploy your contract! ๐Ÿ›ฐ + +๐ŸŽ‰ Well done on building the optimistic oracle system! Now, let's get it on a public testnet. + +๐Ÿ“ก Edit the \`defaultNetwork\` to [your choice of public EVM networks](https://ethereum.org/en/developers/docs/networks/) in \`packages/hardhat/hardhat.config.ts\` (e.g., \`sepolia\`). + +๐Ÿ” You will need to generate a **deployer address** using \`yarn generate\`. This creates a mnemonic and saves it locally. + +๐Ÿ‘ฉโ€๐Ÿš€ Use \`yarn account\` to view your deployer account balances. + +โ›ฝ๏ธ You will need to send ETH to your **deployer address** with your wallet, or get it from a public faucet of your chosen network. + +๐Ÿš€ Run \`yarn deploy\` to deploy your optimistic oracle contracts to a public network (selected in \`hardhat.config.ts\`) + +> ๐Ÿ’ฌ Hint: You can set the \`defaultNetwork\` in \`hardhat.config.ts\` to \`sepolia\` **OR** you can \`yarn deploy --network sepolia\`. + +--- + +## Checkpoint 9: ๐Ÿšข Ship your frontend! ๐Ÿš + +โœ๏ธ Edit your frontend config in \`packages/nextjs/scaffold.config.ts\` to change the \`targetNetwork\` to \`chains.sepolia\` (or your chosen deployed network). + +๐Ÿ’ป View your frontend at http://localhost:3000 and verify you see the correct network. + +๐Ÿ“ก When you are ready to ship the frontend app... + +๐Ÿ“ฆ Run \`yarn vercel\` to package up your frontend and deploy. + +> You might need to log in to Vercel first by running \`yarn vercel:login\`. Once you log in (email, GitHub, etc), the default options should work. + +> If you want to redeploy to the same production URL you can run \`yarn vercel --prod\`. If you omit the \`--prod\` flag it will deploy it to a preview/test URL. + +> Follow the steps to deploy to Vercel. It'll give you a public URL. + +> ๐ŸฆŠ Since we have deployed to a public testnet, you will now need to connect using a wallet you own or use a burner wallet. By default ๐Ÿ”ฅ \`burner wallets\` are only available on \`hardhat\` . You can enable them on every chain by setting \`onlyLocalBurnerWallet: false\` in your frontend config (\`scaffold.config.ts\` in \`packages/nextjs/\`) + +#### Configuration of Third-Party Services for Production-Grade Apps. + +By default, ๐Ÿ— Scaffold-ETH 2 provides predefined API keys for popular services such as Alchemy and Etherscan. This allows you to begin developing and testing your applications more easily, avoiding the need to register for these services. + +This is great to complete your **SpeedRunEthereum**. + +For production-grade applications, it's recommended to obtain your own API keys (to prevent rate limiting issues). You can configure these at: + +- ๐Ÿ”ท\`ALCHEMY_API_KEY\` variable in \`packages/hardhat/.env\` and \`packages/nextjs/.env.local\`. You can create API keys from the [Alchemy dashboard](https://dashboard.alchemy.com/). +- ๐Ÿ“ƒ\`ETHERSCAN_API_KEY\` variable in \`packages/hardhat/.env\` with your generated API key. You can get your key [here](https://etherscan.io/myapikey). + +> ๐Ÿ’ฌ Hint: It's recommended to store env's for nextjs in Vercel/system env config for live apps and use .env.local for local testing. + +--- + +## Checkpoint 10: ๐Ÿ“œ Contract Verification + +๐Ÿ“ Run the \`yarn verify --network your_network\` command to verify your optimistic oracle contracts on Etherscan ๐Ÿ›ฐ. + +๐Ÿ‘‰ Search your deployed optimistic oracle contract addresses on [Sepolia Etherscan](https://sepolia.etherscan.io/) to get the URL you submit to ๐Ÿƒโ€โ™€๏ธ[SpeedRunEthereum.com](https://speedrunethereum.com). + +--- + +> ๐ŸŽ‰ Congratulations on completing the Oracle Challenge! You've gained valuable insights into the mechanics of decentralized oracle systems and their critical role in the blockchain ecosystem. You've explored different oracle architectures and built a sophisticated optimistic oracle system from scratch. + +> ๐Ÿƒ Head to your next challenge [here](https://speedrunethereum.com). + +> ๐Ÿ’ฌ Problems, questions, comments on the stack? Post them to the [๐Ÿ— scaffold-eth developers chat](https://t.me/joinchat/F7nCRK3kI93PoCOk) + +## Checkpoint 11: More On Oracles + +Oracles are fundamental infrastructure for the decentralized web. They enable smart contracts to interact with real-world data, making blockchain applications truly useful beyond simple token transfers. + +๐Ÿงญ The three oracle designs you've implemented represent the main architectural patterns used in production systems: + +- **Whitelist Oracles** are used by protocols that prioritize speed and simplicity over decentralization +- **Staking Oracles** power most DeFi applications where economic incentives help enforce honest behavior +- **Optimistic Oracles** are extremely flexible and can be used for anything from world events to cross-chain transfer verification systems + +๐Ÿš€ As you continue your blockchain development journey, you'll encounter many variations and combinations of these patterns. Understanding the fundamental trade-offs will help you choose the right oracle design for your specific use case. + +๐Ÿง  Remember: the best oracle is the one that provides the right balance of security, speed, flexibility and cost for your application's needs! `; diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 00000000..4aa4d730 --- /dev/null +++ b/extension/package.json @@ -0,0 +1,7 @@ +{ + "scripts": { + "simulate:whitelist": "yarn workspace @se-2/hardhat simulate:whitelist", + "simulate:staking": "yarn workspace @se-2/hardhat simulate:staking", + "simulate:optimistic": "yarn workspace @se-2/hardhat simulate:optimistic" + } +} diff --git a/extension/packages/hardhat/contracts/00_Whitelist/SimpleOracle.sol b/extension/packages/hardhat/contracts/00_Whitelist/SimpleOracle.sol new file mode 100644 index 00000000..5ae49a5a --- /dev/null +++ b/extension/packages/hardhat/contracts/00_Whitelist/SimpleOracle.sol @@ -0,0 +1,73 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.0 <0.9.0; + +contract SimpleOracle { + ///////////////// + /// Errors ////// + ///////////////// + + error OnlyOwner(); + + ////////////////////// + /// State Variables // + ////////////////////// + + uint256 public price; + uint256 public timestamp; + address public owner; + + //////////////// + /// Events ///// + //////////////// + + event PriceUpdated(uint256 newPrice); + + /////////////////// + /// Constructor /// + /////////////////// + + constructor(address _owner) { + owner = _owner; + } + + /////////////////// + /// Modifiers ///// + /////////////////// + + /** + * @notice Modifier to restrict function access to the contract owner + * @dev Currently disabled to make it easy for you to impersonate the owner + */ + modifier onlyOwner() { + // Intentionally removing the owner requirement to make it easy for you to impersonate the owner + // if (msg.sender != owner) revert OnlyOwner(); + _; + } + + /////////////////// + /// Functions ///// + /////////////////// + + /** + * @notice Updates the oracle price with a new value (only contract owner) + * @dev Sets the price and records the current block timestamp for freshness tracking. + * Emits PriceUpdated event upon successful update. + * @param _newPrice The new price value to set for this oracle + */ + function setPrice(uint256 _newPrice) public onlyOwner { + price = _newPrice; + timestamp = block.timestamp; + emit PriceUpdated(_newPrice); + } + + /** + * @notice Returns the current price and its timestamp + * @dev Provides both the stored price value and when it was last updated. + * Used by aggregators to determine price freshness. + * @return price The current price stored in this oracle + * @return timestamp The block timestamp when the price was last updated + */ + function getPrice() public view returns (uint256, uint256) { + return (price, timestamp); + } +} diff --git a/extension/packages/hardhat/contracts/00_Whitelist/WhitelistOracle.sol b/extension/packages/hardhat/contracts/00_Whitelist/WhitelistOracle.sol new file mode 100644 index 00000000..933bbb8d --- /dev/null +++ b/extension/packages/hardhat/contracts/00_Whitelist/WhitelistOracle.sol @@ -0,0 +1,89 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.0 <0.9.0; + +import "./SimpleOracle.sol"; +import { StatisticsUtils } from "../utils/StatisticsUtils.sol"; + +contract WhitelistOracle { + using StatisticsUtils for uint256[]; + + ///////////////// + /// Errors ////// + ///////////////// + + error OnlyOwner(); + error IndexOutOfBounds(); + error NoOraclesAvailable(); + + ////////////////////// + /// State Variables // + ////////////////////// + + address public owner; + SimpleOracle[] public oracles; + uint256 public constant STALE_DATA_WINDOW = 24 seconds; + + //////////////// + /// Events ///// + //////////////// + + event OracleAdded(address oracleAddress, address oracleOwner); + event OracleRemoved(address oracleAddress); + + /////////////////// + /// Modifiers ///// + /////////////////// + + /** + * @notice Modifier to restrict function access to the contract owner + * @dev Currently disabled to make it easy for you to impersonate the owner + */ + modifier onlyOwner() { + // if (msg.sender != owner) revert OnlyOwner(); + _; + } + + /////////////////// + /// Constructor /// + /////////////////// + + constructor() { + owner = msg.sender; + } + + /////////////////// + /// Functions ///// + /////////////////// + + /** + * @notice Adds a new oracle to the whitelist by deploying a SimpleOracle contract (only contract owner) + * @dev Creates a new SimpleOracle instance and adds it to the oracles array. + * @param _owner The address that will own the newly created oracle and can update its price + */ + function addOracle(address _owner) public onlyOwner {} + + /** + * @notice Removes an oracle from the whitelist by its array index (only contract owner) + * @dev Uses swap-and-pop pattern for gas-efficient removal. Order is not preserved. + * Reverts with IndexOutOfBounds, if the provided index is >= oracles.length. + * @param index The index of the oracle to remove from the oracles array + */ + function removeOracle(uint256 index) public onlyOwner {} + + /** + * @notice Returns the aggregated price from all active oracles using median calculation + * @dev Filters oracles with timestamps older than STALE_DATA_WINDOW, then calculates median + * of remaining valid prices. Uses StatisticsUtils for sorting and median calculation. + * @return The median price from all active oracles + */ + function getPrice() public view returns (uint256) {} + + /** + * @notice Returns the addresses of all oracles that have updated their price within the last STALE_DATA_WINDOW + * @dev Iterates through all oracles and filters those with recent timestamps (within STALE_DATA_WINDOW). + * Uses a temporary array to collect active nodes, then creates a right-sized return array + * for gas optimization. + * @return An array of addresses representing the currently active oracle contracts + */ + function getActiveOracleNodes() public view returns (address[] memory) {} +} diff --git a/extension/packages/hardhat/contracts/01_Staking/OracleToken.sol b/extension/packages/hardhat/contracts/01_Staking/OracleToken.sol new file mode 100644 index 00000000..fa7a068a --- /dev/null +++ b/extension/packages/hardhat/contracts/01_Staking/OracleToken.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0 <0.9.0; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract ORA is ERC20, Ownable { + constructor() ERC20("Oracle Token", "ORA") Ownable(msg.sender) { + // Mint initial supply to the contract deployer + _mint(msg.sender, 1000000 * 10 ** decimals()); + } + + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } + + function burn(uint256 amount) public { + _burn(msg.sender, amount); + } +} diff --git a/extension/packages/hardhat/contracts/01_Staking/StakingOracle.sol b/extension/packages/hardhat/contracts/01_Staking/StakingOracle.sol new file mode 100644 index 00000000..f8d12e8b --- /dev/null +++ b/extension/packages/hardhat/contracts/01_Staking/StakingOracle.sol @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0 <0.9.0; + +import "./OracleToken.sol"; +import { StatisticsUtils } from "../utils/StatisticsUtils.sol"; + +contract StakingOracle { + using StatisticsUtils for uint256[]; + + ///////////////// + /// Errors ////// + ///////////////// + + error NodeNotRegistered(); + error InsufficientStake(); + error NodeAlreadyRegistered(); + error NoRewardsAvailable(); + error FailedToSend(); + error NoValidPricesAvailable(); + error OnlyPastBucketsAllowed(); + error NodeAlreadySlashed(); + error AlreadyReportedInCurrentBucket(); + error NotDeviated(); + error WaitingPeriodNotOver(); + error NodeDidNotReport(); + error InvalidPrice(); + error IndexOutOfBounds(); + error NodeNotAtGivenIndex(); + + ////////////////////// + /// State Variables // + ////////////////////// + + ORA public oracleToken; + + struct OracleNode { + uint256 stakedAmount; + uint256 lastReportedBucket; + uint256 reportCount; + uint256 claimedReportCount; + uint256 firstBucket; // block when node registered + bool active; + } + + struct TimeBucket { + mapping(address => uint256) prices; + mapping(address => bool) slashedOffenses; + uint256 countReports; + uint256 sumPrices; + } + + mapping(address => OracleNode) public nodes; + mapping(uint256 => TimeBucket) public timeBuckets; // one bucket per 24 blocks + address[] public nodeAddresses; + + uint256 public constant MINIMUM_STAKE = 1 ether; + uint256 public constant BUCKET_WINDOW = 24 seconds; + 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 = 0.01 ether; + uint256 public constant MISREPORT_PENALTY = 1 ether; + uint256 public constant MAX_DEVIATION_BPS = 1000; // 10% default threshold + uint256 public constant WAITING_PERIOD = 2; // 2 buckets + + //////////////// + /// Events ///// + //////////////// + + event NodeRegistered(address indexed node, uint256 stakedAmount); + event PriceReported(address indexed node, uint256 price, uint256 bucketNumber); + 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); + + address public oracleTokenAddress; + + /////////////////// + /// 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() { + oracleToken = new ORA(); + } + + /////////////////// + /// Functions ///// + /////////////////// + + /** + * @notice Registers a new oracle node with initial ETH stake and price + * @dev Creates a new OracleNode struct and adds the sender to the nodeAddresses array. + * Requires minimum stake amount and prevents duplicate registrations. + * @param initialPrice The initial price value this oracle node will report + */ + function registerNode(uint256 initialPrice) public payable {} + + /** + * @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. + * @param price The new price value to report + */ + function reportPrice(uint256 price) public onlyNode {} + + /** + * @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 {} + + /** + * @notice Allows a registered node to increase its stake + * @dev Increases the sender's stakedAmount by msg.value + */ + function addStake() public payable onlyNode {} + + /** + * @notice Slashes a node for giving a price that is deviated too far from the average excluding it's own reported price + */ + function slashNode(address nodeToSlash, uint256 bucketNumber, uint256 index) public {} + + function exitNode(uint256 index) public onlyNode {} + + //////////////////////// + /// View Functions ///// + //////////////////////// + + /** + * @notice Returns the list of registered oracle node addresses + * @return Array of registered oracle node addresses + */ + function getNodeAddresses() public view returns (address[] memory) {} + + /** + * @notice Returns the aggregated price from all active oracle nodes using median calculation + * @dev Filters out stale nodes, extracts their prices, sorts them, and calculates median. + * Uses StatisticsUtils for sorting and median calculation. + * @return The median price from all nodes with fresh data + */ + function getLatestPrice() public view returns (uint256) {} + + /** + * @notice Returns the average price from a past bucket + * @param bucketNumber The bucket number to get the average price from + * @return The average price from the bucket + */ + function getPastPrice(uint256 bucketNumber) public view returns (uint256) {} + + /** + * @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 The price and slashed status of the node at the bucket + */ + function getAddressDataAtBucket(address nodeAddress, uint256 bucketNumber) public view returns (uint256, bool) {} + + /** + * @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) {} + + /** + * @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) {} + + /** + * @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) {} + + ////////////////////////// + /// 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 {} + + /** + * @notice Checks if the price deviation is greater than the threshold + * @param reportedPrice The price reported by the node + * @param averagePrice The average price of the bucket + * @return True if the price deviation is greater than the threshold, false otherwise + */ + function _checkPriceDeviated(uint256 reportedPrice, uint256 averagePrice) internal pure returns (bool) {} +} diff --git a/extension/packages/hardhat/contracts/02_Optimistic/Decider.sol b/extension/packages/hardhat/contracts/02_Optimistic/Decider.sol new file mode 100644 index 00000000..817a272c --- /dev/null +++ b/extension/packages/hardhat/contracts/02_Optimistic/Decider.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0 <0.9.0; + +import "./OptimisticOracle.sol"; + +contract Decider { + address public owner; + OptimisticOracle public oracle; + + event DisputeSettled(uint256 indexed assertionId, bool resolvedValue); + + constructor(address _oracle) { + owner = msg.sender; + oracle = OptimisticOracle(_oracle); + } + + /** + * @notice Settle a dispute by determining the true/false outcome + * @param assertionId The ID of the assertion to settle + * @param resolvedValue The true/false outcome determined by the decider + */ + function settleDispute(uint256 assertionId, bool resolvedValue) external { + require(assertionId >= 1, "Invalid assertion ID"); + + // Call the oracle's settleAssertion function + oracle.settleAssertion(assertionId, resolvedValue); + + emit DisputeSettled(assertionId, resolvedValue); + } + + function setOracle(address newOracle) external { + require(msg.sender == owner, "Only owner can set oracle"); + oracle = OptimisticOracle(newOracle); + } + + /** + * @notice Allow the contract to receive ETH + */ + receive() external payable {} +} diff --git a/extension/packages/hardhat/contracts/02_Optimistic/OptimisticOracle.sol b/extension/packages/hardhat/contracts/02_Optimistic/OptimisticOracle.sol new file mode 100644 index 00000000..082ab172 --- /dev/null +++ b/extension/packages/hardhat/contracts/02_Optimistic/OptimisticOracle.sol @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0 <0.9.0; + +contract OptimisticOracle { + //////////////// + /// Enums ////// + //////////////// + + enum State { + Invalid, + Asserted, + Proposed, + Disputed, + Settled, + Expired + } + + ///////////////// + /// Errors ////// + ///////////////// + + error AssertionNotFound(); + error AssertionProposed(); + error InvalidValue(); + error InvalidTime(); + error ProposalDisputed(); + error NotProposedAssertion(); + error AlreadyClaimed(); + error AlreadySettled(); + error AwaitingDecider(); + error NotDisputedAssertion(); + error OnlyDecider(); + error OnlyOwner(); + error TransferFailed(); + + ////////////////////// + /// State Variables // + ////////////////////// + + struct EventAssertion { + address asserter; + address proposer; + address disputer; + bool proposedOutcome; + bool resolvedOutcome; + uint256 reward; + uint256 bond; + uint256 startTime; + uint256 endTime; + bool claimed; + address winner; + string description; + } + + uint256 public constant MINIMUM_ASSERTION_WINDOW = 3 minutes; + uint256 public constant DISPUTE_WINDOW = 3 minutes; + address public decider; + address public owner; + uint256 public nextAssertionId = 1; + mapping(uint256 => EventAssertion) public assertions; + + //////////////// + /// Events ///// + //////////////// + + event EventAsserted(uint256 assertionId, address asserter, string description, uint256 reward); + event OutcomeProposed(uint256 assertionId, address proposer, bool outcome); + event OutcomeDisputed(uint256 assertionId, address disputer); + event AssertionSettled(uint256 assertionId, bool outcome, address winner); + event DeciderUpdated(address oldDecider, address newDecider); + event RewardClaimed(uint256 assertionId, address winner, uint256 amount); + event RefundClaimed(uint256 assertionId, address asserter, uint256 amount); + + /////////////////// + /// Modifiers ///// + /////////////////// + + /** + * @notice Modifier to restrict function access to the designated decider + * @dev Ensures only the decider can settle disputed assertions + */ + modifier onlyDecider() { + if (msg.sender != decider) revert OnlyDecider(); + _; + } + + /** + * @notice Modifier to restrict function access to the contract owner + * @dev Ensures only the owner can update critical contract parameters + */ + modifier onlyOwner() { + if (msg.sender != owner) revert OnlyOwner(); + _; + } + + /////////////////// + /// Constructor /// + /////////////////// + + constructor(address _decider) { + decider = _decider; + owner = msg.sender; + } + + /////////////////// + /// Functions ///// + /////////////////// + + /** + * @notice Updates the decider address (only contract owner) + * @dev Changes the address authorized to settle disputed assertions. + * Emits DeciderUpdated event with old and new addresses. + * @param _decider The new address that will act as decider for disputed assertions + */ + function setDecider(address _decider) external onlyOwner { + address oldDecider = address(decider); + decider = _decider; + emit DeciderUpdated(oldDecider, _decider); + } + + /** + * @notice Returns the complete assertion details for a given assertion ID + * @dev Provides access to all fields of the EventAssertion struct + * @param assertionId The unique identifier of the assertion to retrieve + * @return The complete EventAssertion struct containing all assertion data + */ + function getAssertion(uint256 assertionId) external view returns (EventAssertion memory) { + return assertions[assertionId]; + } + + /** + * @notice Creates a new assertion about an event with a true/false outcome + * @dev Requires ETH payment as reward for correct proposers. Bond requirement is 2x the reward. + * Sets default timestamps if not provided. Validates timing requirements. + * @param description Human-readable description of the event (e.g. "Did X happen by time Y?") + * @param startTime When proposals can begin (0 for current block timestamp) + * @param endTime When the assertion expires (0 for startTime + minimum window) + * @return The unique assertion ID for the newly created assertion + */ + function assertEvent( + string memory description, + uint256 startTime, + uint256 endTime + ) external payable returns (uint256) {} + + /** + * @notice Proposes the outcome (true or false) for an asserted event + * @dev Requires bonding ETH equal to 2x the original reward. Sets dispute window deadline. + * Can only be called once per assertion and within the assertion time window. + * @param assertionId The unique identifier of the assertion to propose an outcome for + * @param outcome The proposed boolean outcome (true or false) for the event + */ + function proposeOutcome(uint256 assertionId, bool outcome) external payable {} + + /** + * @notice Disputes a proposed outcome by bonding ETH + * @dev Requires bonding ETH equal to the bond amount. Can only dispute once per assertion + * and must be within the dispute window after proposal. + * @param assertionId The unique identifier of the assertion to dispute + */ + function disputeOutcome(uint256 assertionId) external payable {} + + /** + * @notice Claims reward for undisputed assertions after dispute window expires + * @dev Anyone can trigger this function. Transfers reward + bond to the proposer. + * Can only be called after dispute window has passed without disputes. + * @param assertionId The unique identifier of the assertion to claim rewards for + */ + function claimUndisputedReward(uint256 assertionId) external {} + + /** + * @notice Claims reward for disputed assertions after decider settlement + * @dev Anyone can trigger this function. Pays decider fee and transfers remaining rewards to winner. + * Can only be called after decider has settled the dispute. + * @param assertionId The unique identifier of the disputed assertion to claim rewards for + */ + function claimDisputedReward(uint256 assertionId) external {} + + /** + * @notice Claims refund for assertions that receive no proposals before deadline + * @dev Anyone can trigger this function. Returns the original reward to the asserter. + * Can only be called after assertion deadline has passed without any proposals. + * @param assertionId The unique identifier of the expired assertion to claim refund for + */ + function claimRefund(uint256 assertionId) external {} + + /** + * @notice Resolves disputed assertions by determining the correct outcome (only decider) + * @dev Sets the resolved outcome and determines winner based on proposal accuracy. + * @param assertionId The unique identifier of the disputed assertion to settle + * @param resolvedOutcome The decider's determination of the true outcome + */ + function settleAssertion(uint256 assertionId, bool resolvedOutcome) external onlyDecider {} + + /** + * @notice Returns the current state of an assertion based on its lifecycle stage + * @dev Evaluates assertion progress through states: Invalid, Asserted, Proposed, Disputed, Settled, Expired + * @param assertionId The unique identifier of the assertion to check state for + * @return The current State enum value representing the assertion's status + */ + function getState(uint256 assertionId) external view returns (State) {} + + /** + * @notice Returns the final resolved outcome of a settled assertion + * @dev For undisputed assertions, returns the proposed outcome after dispute window. + * For disputed assertions, returns the decider's resolved outcome. + * @param assertionId The unique identifier of the assertion to get resolution for + * @return The final boolean outcome of the assertion + */ + function getResolution(uint256 assertionId) external view returns (bool) {} +} diff --git a/extension/packages/hardhat/contracts/utils/StatisticsUtils.sol b/extension/packages/hardhat/contracts/utils/StatisticsUtils.sol new file mode 100644 index 00000000..f46f588f --- /dev/null +++ b/extension/packages/hardhat/contracts/utils/StatisticsUtils.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0 <0.9.0; + +library StatisticsUtils { + ///////////////// + /// Errors ////// + ///////////////// + + error EmptyArray(); + + /////////////////// + /// Functions ///// + /////////////////// + + /** + * @notice Sorts an array of uint256 values in ascending order using selection sort + * @dev Uses selection sort algorithm which is not gas-efficient but acceptable for small arrays. + * This implementation mimics the early MakerDAO Medianizer exactly. + * Modifies the input array in-place. + * @param arr The array of uint256 values to sort in ascending order + */ + function sort(uint256[] memory arr) internal pure { + uint256 n = arr.length; + for (uint256 i = 0; i < n; i++) { + uint256 minIndex = i; + for (uint256 j = i + 1; j < n; j++) { + if (arr[j] < arr[minIndex]) { + minIndex = j; + } + } + if (minIndex != i) { + (arr[i], arr[minIndex]) = (arr[minIndex], arr[i]); + } + } + } + + /** + * @notice Calculates the median value from a sorted array of uint256 values + * @dev For arrays with even length, returns the average of the two middle elements. + * For arrays with odd length, returns the middle element. + * Assumes the input array is already sorted in ascending order. + * @param arr The sorted array of uint256 values to calculate median from + * @return The median value as a uint256 + */ + function getMedian(uint256[] memory arr) internal pure returns (uint256) { + uint256 length = arr.length; + if (length == 0) revert EmptyArray(); + if (length % 2 == 0) { + return (arr[length / 2 - 1] + arr[length / 2]) / 2; + } else { + return arr[length / 2]; + } + } +} diff --git a/extension/packages/hardhat/deploy/00_deploy_whitelist.ts b/extension/packages/hardhat/deploy/00_deploy_whitelist.ts new file mode 100644 index 00000000..96a768fa --- /dev/null +++ b/extension/packages/hardhat/deploy/00_deploy_whitelist.ts @@ -0,0 +1,125 @@ +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { DeployFunction } from "hardhat-deploy/types"; +import { decodeEventLog } from "viem"; +import { fetchPriceFromUniswap } from "../scripts/fetchPriceFromUniswap"; + +/** + * Deploys a WhitelistOracle contract and creates SimpleOracle instances through it + * + * @param hre HardhatRuntimeEnvironment object. + */ +const deployWhitelistOracleContracts: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { + const { deployer } = await hre.getNamedAccounts(); + const { deploy } = hre.deployments; + const { viem } = hre; + + const publicClient = await viem.getPublicClient(); + + console.log("Deploying WhitelistOracle contract..."); + const whitelistOracleDeployment = await deploy("WhitelistOracle", { + from: deployer, + args: [], + log: true, + autoMine: true, + }); + const whitelistOracleAddress = whitelistOracleDeployment.address as `0x${string}`; + const whitelistOracleAbi = whitelistOracleDeployment.abi; + + // Skip the rest of the setup if we are on a live network + if (hre.network.name === "localhost") { + // Get 10 wallet clients (accounts) to be oracle owners + const accounts = await viem.getWalletClients(); + const nodeAccounts = accounts.slice(0, 10); + + console.log("Creating SimpleOracle instances through WhitelistOracle..."); + const deployerAccount = accounts.find(a => a.account.address.toLowerCase() === deployer.toLowerCase()); + if (!deployerAccount) throw new Error("Deployer account not found in wallet clients"); + + // Create SimpleOracle instances through WhitelistOracle.addOracle() concurrently + // This creates new SimpleOracle contracts owned by the specified addresses + const nonce = await publicClient.getTransactionCount({ address: deployerAccount.account.address }); + const addOracleTxPromises = nodeAccounts.map((nodeAccount, i) => { + const ownerAddress = nodeAccount.account.address; + console.log(`Creating SimpleOracle ${i + 1}/10 with owner: ${ownerAddress}`); + return deployerAccount.writeContract({ + address: whitelistOracleAddress, + abi: whitelistOracleAbi, + functionName: "addOracle", + args: [ownerAddress], + nonce: nonce + i, + }); + }); + + const addOracleTxHashes = await Promise.all(addOracleTxPromises); + const addOracleReceipts = await Promise.all( + addOracleTxHashes.map(hash => publicClient.waitForTransactionReceipt({ hash })), + ); + + // Map owner => created oracle address from events + const ownerToOracleAddress = new Map(); + for (const receipt of addOracleReceipts) { + const oracleAddedEvent = receipt.logs.find(log => { + try { + const decoded = decodeEventLog({ + abi: whitelistOracleAbi, + data: log.data, + topics: log.topics, + }) as { eventName: string; args: { oracleAddress: string; oracleOwner: string } }; + return decoded.eventName === "OracleAdded"; + } catch { + return false; + } + }); + if (!oracleAddedEvent) continue; + const decoded = decodeEventLog({ + abi: whitelistOracleAbi, + data: oracleAddedEvent.data, + topics: oracleAddedEvent.topics, + }) as { eventName: string; args: { oracleAddress: string; oracleOwner: string } }; + ownerToOracleAddress.set(decoded.args.oracleOwner.toLowerCase(), decoded.args.oracleAddress); + console.log(`โœ… Created SimpleOracle at: ${decoded.args.oracleAddress}`); + } + + const createdOracleAddresses: string[] = nodeAccounts.map(acc => { + const addr = ownerToOracleAddress.get(acc.account.address.toLowerCase()); + if (!addr) throw new Error(`Missing oracle address for owner ${acc.account.address}`); + return addr; + }); + + // Set initial prices for each created SimpleOracle + console.log("Setting initial prices for each SimpleOracle..."); + const initialPrice = await fetchPriceFromUniswap(); + // Get SimpleOracle ABI from deployments + const simpleOracleDeployment = await hre.deployments.getArtifact("SimpleOracle"); + const simpleOracleAbi = simpleOracleDeployment.abi; + // Fire all setPrice transactions concurrently from each node owner + const setPriceTxPromises = nodeAccounts.map((account, i) => { + const oracleAddress = createdOracleAddresses[i]; + return account.writeContract({ + address: oracleAddress as `0x${string}`, + abi: simpleOracleAbi, + functionName: "setPrice", + args: [initialPrice], + }); + }); + const setPriceTxHashes = await Promise.all(setPriceTxPromises); + await Promise.all(setPriceTxHashes.map(hash => publicClient.waitForTransactionReceipt({ hash }))); + for (let i = 0; i < createdOracleAddresses.length; i++) { + console.log(`Set price for SimpleOracle ${i + 1} to: ${initialPrice}`); + } + + // Calculate initial median price + console.log("Calculating initial median price..."); + const medianPrice = await publicClient.readContract({ + address: whitelistOracleAddress, + abi: whitelistOracleAbi, + functionName: "getPrice", + args: [], + }); + console.log(`Initial median price: ${medianPrice.toString()}`); + } + console.log("WhitelistOracle contract deployed and configured successfully!"); +}; + +export default deployWhitelistOracleContracts; +deployWhitelistOracleContracts.tags = ["Oracles"]; diff --git a/extension/packages/hardhat/deploy/01_deploy_staking.ts b/extension/packages/hardhat/deploy/01_deploy_staking.ts new file mode 100644 index 00000000..ddb87097 --- /dev/null +++ b/extension/packages/hardhat/deploy/01_deploy_staking.ts @@ -0,0 +1,63 @@ +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { DeployFunction } from "hardhat-deploy/types"; +import { parseEther } from "viem"; +import { fetchPriceFromUniswap } from "../scripts/fetchPriceFromUniswap"; + +const deployStakingOracle: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { + const { deployer } = await hre.getNamedAccounts(); + const { deploy } = hre.deployments; + const { viem } = hre; + + console.log("Deploying Staking Oracle contract..."); + const deployment = await deploy("StakingOracle", { + contract: "StakingOracle", + from: deployer, + args: [], + log: true, + autoMine: true, + }); + + const stakingOracleAddress = deployment.address as `0x${string}`; + console.log("StakingOracle deployed at:", stakingOracleAddress); + + if (hre.network.name === "localhost") { + const accounts = await viem.getWalletClients(); + const nodeAccounts = accounts.slice(1, 11); + + const publicClient = await viem.getPublicClient(); + + const oraTokenAddress = await publicClient.readContract({ + address: stakingOracleAddress, + abi: deployment.abi, + functionName: "oracleToken", + args: [], + }); + console.log("ORA Token deployed at:", oraTokenAddress); + const initialPrice = await fetchPriceFromUniswap(); + + try { + await Promise.all( + nodeAccounts.map(account => { + return account.writeContract({ + address: stakingOracleAddress, + abi: deployment.abi, + functionName: "registerNode", + args: [initialPrice], + value: parseEther("15"), + }); + }), + ); + } catch (error: any) { + if (error.message?.includes("NodeAlreadyRegistered")) { + console.error("\nโŒ Deployment failed: Nodes already registered!\n"); + console.error("๐Ÿ”ง Please retry with:"); + console.error("yarn deploy --reset\n"); + process.exit(1); + } else { + throw error; + } + } + } +}; + +export default deployStakingOracle; diff --git a/extension/packages/hardhat/deploy/02_deploy_optimistic.ts b/extension/packages/hardhat/deploy/02_deploy_optimistic.ts new file mode 100644 index 00000000..a00ff5df --- /dev/null +++ b/extension/packages/hardhat/deploy/02_deploy_optimistic.ts @@ -0,0 +1,46 @@ +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { DeployFunction } from "hardhat-deploy/types"; + +const deployOptimisticOracle: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { + const { deployments, getNamedAccounts } = hre; + const { deploy } = deployments; + const { deployer } = await getNamedAccounts(); + + console.log("Deploying OptimisticOracle..."); + console.log("Deployer:", deployer); + // Get the deployer's current nonce + const deployerNonce = await hre.ethers.provider.getTransactionCount(deployer); + + const futureDeciderAddress = hre.ethers.getCreateAddress({ + from: deployer, + nonce: deployerNonce + 1, // +1 because it will be our second deployment + }); + // Deploy the OptimisticOracle contract with deployer as temporary decider + const optimisticOracle = await deploy("OptimisticOracle", { + contract: "OptimisticOracle", + from: deployer, + args: [futureDeciderAddress], + log: true, + }); + + // Deploy the Decider contract + const decider = await deploy("Decider", { + contract: "Decider", + from: deployer, + args: [optimisticOracle.address], + log: true, + }); + + // Check if the decider address matches the expected address + if (decider.address !== futureDeciderAddress) { + throw new Error("Decider address does not match expected address"); + } + + console.log("OptimisticOracle deployed to:", optimisticOracle.address); + console.log("Decider deployed to:", decider.address); +}; + +deployOptimisticOracle.id = "deploy_optimistic_oracle"; +deployOptimisticOracle.tags = ["OptimisticOracle"]; + +export default deployOptimisticOracle; diff --git a/extension/packages/hardhat/hardhat.config.ts.args.mjs b/extension/packages/hardhat/hardhat.config.ts.args.mjs new file mode 100644 index 00000000..edd7ffcd --- /dev/null +++ b/extension/packages/hardhat/hardhat.config.ts.args.mjs @@ -0,0 +1,14 @@ +export const preContent = ` +import "@nomicfoundation/hardhat-viem"; +`; + +export const configOverrides = { + networks: { + hardhat: { + mining: { + auto: false, + interval: 1000, + } + }, + }, + }; diff --git a/extension/packages/hardhat/package.json b/extension/packages/hardhat/package.json new file mode 100644 index 00000000..3cdf7454 --- /dev/null +++ b/extension/packages/hardhat/package.json @@ -0,0 +1,10 @@ +{ + "scripts": { + "simulate:whitelist": "hardhat run scripts/runWhitelistOracleBots.ts", + "simulate:staking": "hardhat run scripts/runStakingOracleBots.ts", + "simulate:optimistic": "hardhat run scripts/runOptimisticBots.ts" + }, + "dependencies": { + "@nomicfoundation/hardhat-viem": "^2.0.6" + } +} diff --git a/extension/packages/hardhat/scripts/fetchPriceFromUniswap.ts b/extension/packages/hardhat/scripts/fetchPriceFromUniswap.ts new file mode 100644 index 00000000..605d3d5f --- /dev/null +++ b/extension/packages/hardhat/scripts/fetchPriceFromUniswap.ts @@ -0,0 +1,68 @@ +import { ethers } from "hardhat"; +import * as dotenv from "dotenv"; +dotenv.config(); + +import { config as hardhatConfig } from "hardhat"; +import { getConfig, updatePriceCache } from "./utils"; +import { parseEther, formatEther } from "ethers"; + +const UNISWAP_V2_PAIR_ABI = [ + "function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)", + "function token0() external view returns (address)", + "function token1() external view returns (address)", +]; + +const DAI_ADDRESS = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; +const WETH_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; +const UNISWAP_V2_FACTORY = "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f"; +const mainnet = hardhatConfig.networks.mainnet; +const MAINNET_RPC = "url" in mainnet ? mainnet.url : ""; + +export const fetchPriceFromUniswap = async (): Promise => { + const config = getConfig(); + const cachedPrice = config.PRICE.CACHEDPRICE; + const timestamp = config.PRICE.TIMESTAMP; + + if (Date.now() - timestamp < 1000 * 60 * 60) { + return parseEther(cachedPrice.toString()); + } + console.log("Cache expired or missing, fetching fresh price from Uniswap..."); + + try { + const provider = new ethers.JsonRpcProvider(MAINNET_RPC); + const tokenAddress = WETH_ADDRESS; // Always use WETH for mainnet + + // Get pair address from Uniswap V2 Factory + const factory = new ethers.Contract( + UNISWAP_V2_FACTORY, + ["function getPair(address tokenA, address tokenB) external view returns (address pair)"], + provider, + ); + + const pairAddress = await factory.getPair(tokenAddress, DAI_ADDRESS); + if (pairAddress === ethers.ZeroAddress) { + throw new Error("No liquidity pair found"); + } + + const pairContract = new ethers.Contract(pairAddress, UNISWAP_V2_PAIR_ABI, provider); + const [reserves, token0Address] = await Promise.all([pairContract.getReserves(), pairContract.token0()]); + + // Determine which reserve is token and which is DAI + const isToken0 = token0Address.toLowerCase() === tokenAddress.toLowerCase(); + const tokenReserve = isToken0 ? reserves[0] : reserves[1]; + const daiReserve = isToken0 ? reserves[1] : reserves[0]; + + // Calculate price (DAI per token) + const price = BigInt(Math.floor((Number(daiReserve) / Number(tokenReserve)) * 1e18)); + + // Update cache with fresh price + const pricePerEther = parseFloat(formatEther(price)); + updatePriceCache(pricePerEther, Date.now()); + console.log(`Fresh price fetched and cached: ${formatEther(price)} ETH`); + + return price; + } catch (error) { + console.error("Error fetching ETH price from Uniswap: ", error); + return parseEther(cachedPrice.toString()); + } +}; diff --git a/extension/packages/hardhat/scripts/oracle-bot/balances.ts b/extension/packages/hardhat/scripts/oracle-bot/balances.ts new file mode 100644 index 00000000..6d7a5d0b --- /dev/null +++ b/extension/packages/hardhat/scripts/oracle-bot/balances.ts @@ -0,0 +1,29 @@ +import { ethers } from "hardhat"; +import { formatEther } from "ethers"; + +export async function reportBalances() { + try { + // Get all signers (accounts) + const signers = await ethers.getSigners(); + const oracleNodes = signers.slice(1, 11); // Get oracle node accounts + + // Get the StakingOracle contract + const oracleContract = await ethers.getContract("StakingOracle"); + const oracle = await ethers.getContractAt("StakingOracle", oracleContract.target); + + // Get the ORA token address and create contract instance + const oraTokenAddress = await oracle.oracleToken(); + const oraToken = await ethers.getContractAt("contracts/OracleToken.sol:ORA", oraTokenAddress); + + console.log("\nNode Balances:"); + for (const node of oracleNodes) { + const nodeInfo = await oracle.nodes(node.address); + const oraBalance = await oraToken.balanceOf(node.address); + console.log(`\nNode ${node.address}:`); + console.log(` Staked ETH: ${formatEther(nodeInfo.stakedAmount)} ETH`); + console.log(` ORA Balance: ${formatEther(oraBalance)} ORA`); + } + } catch (error) { + console.error("Error reporting balances:", error); + } +} diff --git a/extension/packages/hardhat/scripts/oracle-bot/config.json b/extension/packages/hardhat/scripts/oracle-bot/config.json new file mode 100644 index 00000000..a2b95667 --- /dev/null +++ b/extension/packages/hardhat/scripts/oracle-bot/config.json @@ -0,0 +1,56 @@ +{ + "PRICE": { + "CACHEDPRICE": 4000, + "TIMESTAMP": 1761680177006 + }, + "INTERVALS": { + "PRICE_REPORT": 1750, + "VALIDATION": 1750 + }, + "NODE_CONFIGS": { + "default": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0 + }, + "0x70997970c51812dc3a010c7d01b50e0d17dc79c8": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0 + }, + "0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0 + }, + "0x976ea74026e726554db657fa54763abd0c3a0aa9": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0 + }, + "0x90f79bf6eb2c4f870365e785982e1f101e93b906": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0 + }, + "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0 + }, + "0x15d34aaf54267db7d7c367839aaf71a00a2c6a65": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0 + }, + "0xbcd4042de499d14e55001ccbb24a551f3b954096": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0 + }, + "0x14dc79964da2c08b23698b3d3cc7ca32193d9955": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0 + }, + "0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0 + }, + "0xa0ee7a142d267c1f36714e4a8f75612f20a79720": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0 + } + } +} diff --git a/extension/packages/hardhat/scripts/oracle-bot/price.ts b/extension/packages/hardhat/scripts/oracle-bot/price.ts new file mode 100644 index 00000000..6de87c0b --- /dev/null +++ b/extension/packages/hardhat/scripts/oracle-bot/price.ts @@ -0,0 +1,16 @@ +import { getConfig } from "../utils"; + +export const getRandomPrice = async (nodeAddress: string, currentPrice: number): Promise => { + const config = getConfig(); + const nodeConfig = config.NODE_CONFIGS[nodeAddress] || config.NODE_CONFIGS.default; + + // Calculate variance range based on the node's PRICE_VARIANCE + // PRICE_VARIANCE of 0 means no variance, higher values mean wider range + const varianceRange = Math.floor(currentPrice * nodeConfig.PRICE_VARIANCE); + + // Apply variance to the base price + const finalPrice = currentPrice + (Math.random() * 2 - 1) * varianceRange; + + // Round to nearest integer + return Math.round(finalPrice); +}; diff --git a/extension/packages/hardhat/scripts/oracle-bot/reporting.ts b/extension/packages/hardhat/scripts/oracle-bot/reporting.ts new file mode 100644 index 00000000..007bb5ac --- /dev/null +++ b/extension/packages/hardhat/scripts/oracle-bot/reporting.ts @@ -0,0 +1,80 @@ +import { PublicClient } from "viem"; +import { getRandomPrice } from "./price"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { getConfig } from "../utils"; +import { fetchPriceFromUniswap } from "../fetchPriceFromUniswap"; +import { DeployedContract } from "hardhat-deploy/types"; + +const getStakedAmount = async ( + publicClient: PublicClient, + nodeAddress: `0x${string}`, + oracleContract: DeployedContract, +) => { + const nodeInfo = (await publicClient.readContract({ + address: oracleContract.address as `0x${string}`, + abi: oracleContract.abi, + functionName: "nodes", + args: [nodeAddress], + })) as any[]; + + const [, stakedAmount] = nodeInfo; + return stakedAmount as bigint; +}; + +export const reportPrices = async (hre: HardhatRuntimeEnvironment) => { + const { deployments } = hre; + const oracleContract = await deployments.get("StakingOracle"); + const config = getConfig(); + const accounts = await hre.viem.getWalletClients(); + const oracleNodeAccounts = accounts.slice(1, 11); + const publicClient = await hre.viem.getPublicClient(); + + // Get minimum stake requirement from contract + const minimumStake = (await publicClient.readContract({ + address: oracleContract.address as `0x${string}`, + abi: oracleContract.abi, + functionName: "MINIMUM_STAKE", + args: [], + })) as unknown as bigint; + + const currentPrice = Number(await fetchPriceFromUniswap()); + try { + return Promise.all( + oracleNodeAccounts.map(async account => { + const nodeConfig = config.NODE_CONFIGS[account.account.address] || config.NODE_CONFIGS.default; + const shouldReport = Math.random() > nodeConfig.PROBABILITY_OF_SKIPPING_REPORT; + const stakedAmount = await getStakedAmount(publicClient, account.account.address, oracleContract); + if (stakedAmount < minimumStake) { + console.log(`Insufficient stake for ${account.account.address} for price reporting`); + return Promise.resolve(); + } + + if (shouldReport) { + const price = BigInt(await getRandomPrice(account.account.address, currentPrice)); + console.log(`Reporting price ${price} from ${account.account.address}`); + try { + return await account.writeContract({ + address: oracleContract.address as `0x${string}`, + abi: oracleContract.abi, + functionName: "reportPrice", + args: [price], + }); + } catch (error: any) { + if (error.message && error.message.includes("Not enough stake")) { + console.log( + `Skipping price report from ${account.account.address} - insufficient stake during execution`, + ); + return Promise.resolve(); + } + throw error; + } + } else { + console.log(`Skipping price report from ${account.account.address}`); + return Promise.resolve(); + } + }), + ); + } catch (error) { + console.error("Error reporting prices:", error); + } +}; diff --git a/extension/packages/hardhat/scripts/oracle-bot/types.ts b/extension/packages/hardhat/scripts/oracle-bot/types.ts new file mode 100644 index 00000000..b1573c13 --- /dev/null +++ b/extension/packages/hardhat/scripts/oracle-bot/types.ts @@ -0,0 +1,19 @@ +interface NodeConfig { + PROBABILITY_OF_SKIPPING_REPORT: number; + PRICE_VARIANCE: number; // Higher number means wider price range +} + +export interface Config { + PRICE: { + CACHEDPRICE: number; + TIMESTAMP: number; + }; + INTERVALS: { + PRICE_REPORT: number; + VALIDATION: number; + }; + NODE_CONFIGS: { + [key: string]: NodeConfig; + default: NodeConfig; + }; +} diff --git a/extension/packages/hardhat/scripts/oracle-bot/validation.ts b/extension/packages/hardhat/scripts/oracle-bot/validation.ts new file mode 100644 index 00000000..1adcca96 --- /dev/null +++ b/extension/packages/hardhat/scripts/oracle-bot/validation.ts @@ -0,0 +1,79 @@ +import { HardhatRuntimeEnvironment } from "hardhat/types"; + +const getStakedAmount = async (publicClient: any, nodeAddress: `0x${string}`, oracleContract: any) => { + const nodeInfo = (await publicClient.readContract({ + address: oracleContract.address as `0x${string}`, + abi: oracleContract.abi, + functionName: "nodes", + args: [nodeAddress], + })) as any[]; + + const [, stakedAmount] = nodeInfo; + return stakedAmount as bigint; +}; + +export const claimRewards = async (hre: HardhatRuntimeEnvironment) => { + const { deployments } = hre; + const oracleContract = await deployments.get("StakingOracle"); + const accounts = await hre.viem.getWalletClients(); + const oracleNodeAccounts = accounts.slice(1, 11); + const publicClient = await hre.viem.getPublicClient(); + + // Get minimum stake requirement from contract + const minimumStake = (await publicClient.readContract({ + address: oracleContract.address as `0x${string}`, + abi: oracleContract.abi, + functionName: "MINIMUM_STAKE", + args: [], + })) as unknown as bigint; + + try { + return Promise.all( + oracleNodeAccounts.map(async account => { + const stakedAmount = await getStakedAmount(publicClient, account.account.address, oracleContract); + + // Only claim rewards if the node has sufficient stake + if (stakedAmount >= minimumStake) { + try { + console.log(`Claiming rewards for ${account.account.address}`); + return await account.writeContract({ + address: oracleContract.address as `0x${string}`, + abi: oracleContract.abi, + functionName: "claimReward", + args: [], + }); + } catch (error: any) { + if (error.message && error.message.includes("No rewards available")) { + console.log(`Skipping reward claim for ${account.account.address} - no rewards available`); + return Promise.resolve(); + } + throw error; + } + } else { + console.log(`Skipping reward claim for ${account.account.address} - insufficient stake`); + return Promise.resolve(); + } + }), + ); + } catch (error) { + console.error("Error claiming rewards:", error); + } +}; + +// Keep the old validateNodes function for backward compatibility if needed +export const validateNodes = async (hre: HardhatRuntimeEnvironment) => { + const { deployments } = hre; + const [account] = await hre.viem.getWalletClients(); + const oracleContract = await deployments.get("StakingOracle"); + + try { + return await account.writeContract({ + address: oracleContract.address as `0x${string}`, + abi: oracleContract.abi, + functionName: "slashNodes", + args: [], + }); + } catch (error) { + console.error("Error validating nodes:", error); + } +}; diff --git a/extension/packages/hardhat/scripts/runOptimisticBots.ts b/extension/packages/hardhat/scripts/runOptimisticBots.ts new file mode 100644 index 00000000..a47a2c43 --- /dev/null +++ b/extension/packages/hardhat/scripts/runOptimisticBots.ts @@ -0,0 +1,258 @@ +import { deployments, ethers } from "hardhat"; +import hre from "hardhat"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { getRandomQuestion, sleep } from "./utils"; +import { WalletClient } from "@nomicfoundation/hardhat-viem/types"; +import { Deployment } from "hardhat-deploy/types"; +import { zeroAddress } from "viem"; +import { OptimisticOracle } from "../typechain-types"; + +const isHalfTimePassed = (assertion: any, currentTimestamp: bigint) => { + const startTime: bigint = assertion.startTime; + const endTime: bigint = assertion.endTime; + const halfTimePassed = (endTime - startTime) / 2n; + return currentTimestamp > startTime && startTime + halfTimePassed < currentTimestamp; +}; + +const stopTrackingAssertion = ( + accountToAssertionIds: Record, + account: WalletClient, + assertionId: bigint, +) => { + accountToAssertionIds[account.account.address] = accountToAssertionIds[account.account.address].filter( + id => id !== assertionId, + ); +}; + +const canPropose = (assertion: any, currentTimestamp: bigint) => { + const rangeOfSeconds = [10n, 20n, 30n, 40n, 50n, 60n, 70n, 80n, 90n, 100n]; + const randomSeconds = rangeOfSeconds[Math.floor(Math.random() * rangeOfSeconds.length)]; + return assertion.proposer === zeroAddress && currentTimestamp > assertion.startTime + randomSeconds; +}; + +const createAssertions = async ( + optimisticDeployment: Deployment, + optimisticOracle: OptimisticOracle, + otherAccounts: WalletClient[], + accountToAssertionIds: Record, +) => { + const minReward = ethers.parseEther("0.01"); + let nextAssertionId = await optimisticOracle.nextAssertionId(); + + for (const account of otherAccounts) { + const assertionIds = accountToAssertionIds[account.account.address]; + if (assertionIds.length === 0 && Math.random() < 0.5) { + await account.writeContract({ + address: optimisticDeployment.address as `0x${string}`, + abi: optimisticDeployment.abi, + functionName: "assertEvent", + args: [getRandomQuestion(), 0n, 0n], + value: minReward + (1n * 10n ** 18n * BigInt(Math.floor(Math.random() * 100))) / 100n, + }); + console.log(`โœ… created assertion ${nextAssertionId}`); + + // Track the assertion for 80% of cases; otherwise, leave it untracked so it will expire + if (Math.random() < 0.8) { + accountToAssertionIds[account.account.address].push(nextAssertionId); + } + nextAssertionId++; + } + } +}; + +const proposeAssertions = async ( + trueResponder: WalletClient, + falseResponder: WalletClient, + randomResponder: WalletClient, + optimisticDeployment: Deployment, + optimisticOracle: OptimisticOracle, + currentTimestamp: bigint, + otherAccounts: WalletClient[], + accountToAssertionIds: Record, +) => { + for (const account of otherAccounts) { + const assertionIds = accountToAssertionIds[account.account.address]; + if (assertionIds.length !== 0) { + for (const assertionId of assertionIds) { + const assertion = await optimisticOracle.assertions(assertionId); + if (canPropose(assertion, currentTimestamp)) { + const randomness = Math.random(); + if (randomness < 0.25) { + await trueResponder.writeContract({ + address: optimisticDeployment.address as `0x${string}`, + abi: optimisticDeployment.abi, + functionName: "proposeOutcome", + args: [assertionId, true], + value: assertion.bond, + }); + console.log(`โœ… proposed outcome=true for assertion ${assertionId}`); + } else if (randomness < 0.5) { + await falseResponder.writeContract({ + address: optimisticDeployment.address as `0x${string}`, + abi: optimisticDeployment.abi, + functionName: "proposeOutcome", + args: [assertionId, false], + value: assertion.bond, + }); + console.log(`โŒ proposed outcome=false for assertion ${assertionId} `); + } else if (randomness < 0.9) { + const outcome = Math.random() < 0.5; + await randomResponder.writeContract({ + address: optimisticDeployment.address as `0x${string}`, + abi: optimisticDeployment.abi, + functionName: "proposeOutcome", + args: [assertionId, outcome], + value: assertion.bond, + }); + console.log(`${outcome ? "โœ…" : "โŒ"} proposed outcome=${outcome} for assertion ${assertionId}`); + // if randomly wallet proposed, then remove the assertion from the account (No need to track and dispute) + stopTrackingAssertion(accountToAssertionIds, account, assertionId); + } + } + } + } + } +}; + +const disputeAssertions = async ( + trueResponder: WalletClient, + falseResponder: WalletClient, + optimisticDeployment: Deployment, + optimisticOracle: OptimisticOracle, + currentTimestamp: bigint, + accountToAssertionIds: Record, + otherAccounts: WalletClient[], +) => { + for (const account of otherAccounts) { + const assertionIds = accountToAssertionIds[account.account.address]; + for (const assertionId of assertionIds) { + const assertion = await optimisticOracle.assertions(assertionId); + if ( + assertion.proposer.toLowerCase() === trueResponder.account.address.toLowerCase() && + isHalfTimePassed(assertion, currentTimestamp) + ) { + await falseResponder.writeContract({ + address: optimisticDeployment.address as `0x${string}`, + abi: optimisticDeployment.abi, + functionName: "disputeOutcome", + args: [assertionId], + value: assertion.bond, + }); + console.log(`โš”๏ธ disputed assertion ${assertionId}`); + // if disputed, then remove the assertion from the account + stopTrackingAssertion(accountToAssertionIds, account, assertionId); + } else if ( + assertion.proposer.toLowerCase() === falseResponder.account.address.toLowerCase() && + isHalfTimePassed(assertion, currentTimestamp) + ) { + await trueResponder.writeContract({ + address: optimisticDeployment.address as `0x${string}`, + abi: optimisticDeployment.abi, + functionName: "disputeOutcome", + args: [assertionId], + value: assertion.bond, + }); + console.log(`โš”๏ธ disputed assertion ${assertionId}`); + // if disputed, then remove the assertion from the account + stopTrackingAssertion(accountToAssertionIds, account, assertionId); + } + } + } +}; + +let currentAction = 0; + +const runCycle = async ( + hre: HardhatRuntimeEnvironment, + accountToAssertionIds: Record, + accounts: WalletClient[], +) => { + try { + const trueResponder = accounts[0]; + const falseResponder = accounts[1]; + const randomResponder = accounts[2]; + const otherAccounts = accounts.slice(3); + + const optimisticDeployment = await deployments.get("OptimisticOracle"); + const optimisticOracle = await ethers.getContractAt("OptimisticOracle", optimisticDeployment.address); + const publicClient = await hre.viem.getPublicClient(); + + // get current timestamp + const latestBlock = await publicClient.getBlock(); + const currentTimestamp = latestBlock.timestamp; + // also track thex of the account start from the third account + if (currentAction === 0) { + console.log(`\n๐Ÿ“ === CREATING ASSERTIONS PHASE ===`); + await createAssertions(optimisticDeployment, optimisticOracle, otherAccounts, accountToAssertionIds); + } else if (currentAction === 1) { + console.log(`\n๐ŸŽฏ === PROPOSING OUTCOMES PHASE ===`); + await proposeAssertions( + trueResponder, + falseResponder, + randomResponder, + optimisticDeployment, + optimisticOracle, + currentTimestamp, + otherAccounts, + accountToAssertionIds, + ); + } else if (currentAction === 2) { + console.log(`\nโš”๏ธ === DISPUTING ASSERTIONS PHASE ===`); + await disputeAssertions( + trueResponder, + falseResponder, + optimisticDeployment, + optimisticOracle, + currentTimestamp, + accountToAssertionIds, + otherAccounts, + ); + } + currentAction = (currentAction + 1) % 3; + } catch (error) { + console.error("Error in oracle cycle:", error); + throw error; + } +}; + +async function run() { + console.log("Starting optimistic oracle bots..."); + const accountToAssertionIds: Record = {}; + + const accounts = (await hre.viem.getWalletClients()).slice(0, 8); + for (const account of accounts) { + accountToAssertionIds[account.account.address] = []; + } + while (true) { + await runCycle(hre, accountToAssertionIds, accounts); + await sleep(3000); + } +} + +run().catch(error => { + console.error(error); + process.exitCode = 1; +}); + +// Handle process termination signals +process.on("SIGINT", async () => { + console.log("\nReceived SIGINT (Ctrl+C). Cleaning up..."); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + console.log("\nReceived SIGTERM. Cleaning up..."); + process.exit(0); +}); + +// Handle uncaught exceptions +process.on("uncaughtException", async error => { + console.error("Uncaught Exception:", error); + process.exit(1); +}); + +// Handle unhandled promise rejections +process.on("unhandledRejection", async (reason, promise) => { + console.error("Unhandled Rejection at:", promise, "reason:", reason); + process.exit(1); +}); diff --git a/extension/packages/hardhat/scripts/runStakingOracleBots.ts b/extension/packages/hardhat/scripts/runStakingOracleBots.ts new file mode 100644 index 00000000..86dfd0f1 --- /dev/null +++ b/extension/packages/hardhat/scripts/runStakingOracleBots.ts @@ -0,0 +1,416 @@ +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import hre from "hardhat"; +import { sleep, getConfig } from "./utils"; +import { parseEther } from "viem"; +import { fetchPriceFromUniswap } from "./fetchPriceFromUniswap"; + +type WalletClient = Awaited>[number]; + +const normalizeNodeInfo = (raw: any) => { + const zero = 0n; + if (!raw) + return { + stakedAmount: zero, + lastReportedBucket: zero, + reportCount: zero, + claimedReportCount: zero, + firstBucket: zero, + active: false, + }; + const get = (idx: number, name: string) => { + const byName = raw[name]; + const byIndex = Array.isArray(raw) ? raw[idx] : undefined; + if (typeof byName === "bigint") return byName as bigint; + if (typeof byIndex === "bigint") return byIndex as bigint; + const val = byName ?? byIndex ?? 0; + try { + return BigInt(String(val)); + } catch { + return zero; + } + }; + return { + stakedAmount: get(0, "stakedAmount"), + lastReportedBucket: get(1, "lastReportedBucket"), + reportCount: get(2, "reportCount"), + claimedReportCount: get(3, "claimedReportCount"), + firstBucket: get(4, "firstBucket"), + }; +}; + +// Current base price used by the bot. Initialized once at start from Uniswap +// and updated from on-chain contract prices thereafter. +let currentPrice: bigint | null = null; + +const stringToBool = (value: string | undefined | null): boolean => { + if (!value) return false; + const normalized = value.toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; +}; + +// Feature flag: enable automatic slashing when the AUTO_SLASH environment variable is truthy +const AUTO_SLASH: boolean = stringToBool(process.env.AUTO_SLASH); + +const getStakingOracleDeployment = async (runtime: HardhatRuntimeEnvironment) => { + const deployment = await runtime.deployments.get("StakingOracle"); + return { + address: deployment.address as `0x${string}`, + abi: deployment.abi, + } as const; +}; + +const getActiveNodeWalletClients = async ( + runtime: HardhatRuntimeEnvironment, + stakingAddress: `0x${string}`, + stakingAbi: any, +): Promise => { + const accounts = await runtime.viem.getWalletClients(); + // Filter to only those that are registered (firstBucket != 0) + const publicClient = await runtime.viem.getPublicClient(); + const nodeClients: WalletClient[] = []; + for (const client of accounts) { + try { + const rawNodeInfo = await publicClient.readContract({ + address: stakingAddress, + abi: stakingAbi, + functionName: "nodes", + args: [client.account.address], + }); + const node = normalizeNodeInfo(rawNodeInfo); + if (node.firstBucket !== 0n) { + nodeClients.push(client); + } + } catch { + // ignore + } + } + return nodeClients; +}; + +const findNodeIndex = async ( + runtime: HardhatRuntimeEnvironment, + stakingAddress: `0x${string}`, + stakingAbi: any, + nodeAddress: `0x${string}`, +): Promise => { + const publicClient = await runtime.viem.getPublicClient(); + // Iterate indices until out-of-bounds revert + try { + const addresses = (await publicClient.readContract({ + address: stakingAddress, + abi: stakingAbi, + functionName: "getNodeAddresses", + args: [], + })) as `0x${string}`[]; + return addresses.findIndex(addr => addr.toLowerCase() === nodeAddress.toLowerCase()); + } catch {} + return null; +}; + +const runCycle = async (runtime: HardhatRuntimeEnvironment) => { + try { + const { address, abi } = await getStakingOracleDeployment(runtime); + const publicClient = await runtime.viem.getPublicClient(); + const allWalletClients = await runtime.viem.getWalletClients(); + const blockNumber = await publicClient.getBlockNumber(); + console.log(`\n[Block ${blockNumber}] Starting new oracle cycle...`); + + // Read current bucket window and bucket number + const [bucketWindow, currentBucket] = await Promise.all([ + publicClient + .readContract({ address, abi, functionName: "BUCKET_WINDOW", args: [] }) + .then(value => BigInt(String(value))), + publicClient + .readContract({ address, abi, functionName: "getCurrentBucketNumber", args: [] }) + .then(value => BigInt(String(value))), + ]); + const previousBucket = currentBucket > 0n ? currentBucket - 1n : 0n; + console.log(`BUCKET_WINDOW=${bucketWindow} | currentBucket=${currentBucket}`); + + // Update base price from previous bucket, excluding slashable and already-slashed reports. + // Fallback to contract's latest price, then to previous cached value. + try { + // Determine adjusted average from the previous bucket excluding outliers and slashed reports + const previous = previousBucket; + if (previous > 0n) { + let adjustedAvg: bigint | null = null; + try { + const [outliers, nodeAddresses] = await Promise.all([ + publicClient.readContract({ address, abi, functionName: "getOutlierNodes", args: [previous] }) as Promise< + `0x${string}`[] + >, + publicClient.readContract({ address, abi, functionName: "getNodeAddresses", args: [] }) as Promise< + `0x${string}`[] + >, + ]); + const outlierSet = new Set(outliers.map(a => a.toLowerCase())); + const dataForNodes = await Promise.all( + nodeAddresses.map(async nodeAddr => { + try { + const result: any = await publicClient.readContract({ + address, + abi, + functionName: "getAddressDataAtBucket", + args: [nodeAddr, previous], + }); + // result could be array-like [price, slashed] or object with named props + const priceVal = Array.isArray(result) ? result[0] : (result?.[0] ?? result?.price); + const slashedVal = Array.isArray(result) ? result[1] : (result?.[1] ?? result?.slashed); + const price = BigInt(String(priceVal ?? 0)); + const slashed = Boolean(slashedVal); + return { nodeAddr, price, slashed } as const; + } catch { + return { nodeAddr, price: 0n, slashed: false } as const; + } + }), + ); + const valid = dataForNodes.filter( + d => d.price > 0n && !d.slashed && !outlierSet.has(d.nodeAddr.toLowerCase()), + ); + if (valid.length > 0) { + const sum = valid.reduce((acc, d) => acc + d.price, 0n); + adjustedAvg = sum / BigInt(valid.length); + } + } catch { + // ignore and fall back + } + + if (adjustedAvg !== null) { + currentPrice = adjustedAvg; + } else { + // Fallback to on-chain latest average (previous bucket average) + try { + const onchain = await publicClient.readContract({ address, abi, functionName: "getLatestPrice", args: [] }); + currentPrice = BigInt(String(onchain)); + } catch { + // keep prior currentPrice + } + } + } + } catch { + // keep prior currentPrice + } + + // Load config once per cycle so runtime edits to the config file are picked up + const cfg = getConfig(); + + // 1) Reporting: each node only once per bucket + const nodeWalletClients = await getActiveNodeWalletClients(runtime, address, abi); + // Ensure we have an initial price (set once at startup in run()) + if (currentPrice === null) { + currentPrice = await fetchPriceFromUniswap(); + } + const reportTxHashes: `0x${string}`[] = []; + for (const client of nodeWalletClients) { + try { + const rawNodeInfo = await publicClient.readContract({ + address, + abi, + functionName: "nodes", + args: [client.account.address], + }); + const node = normalizeNodeInfo(rawNodeInfo); + if (node.lastReportedBucket !== currentBucket) { + // Determine node config (probability to skip and variance) + const nodeCfg = cfg.NODE_CONFIGS[client.account.address.toLowerCase()] || cfg.NODE_CONFIGS.default; + const skipProb = Number(nodeCfg.PROBABILITY_OF_SKIPPING_REPORT ?? 0); + if (Math.random() < skipProb) { + console.log(`Skipping report (by probability) for ${client.account.address}`); + continue; + } + // Compute deviated price as integer math using parts-per-million (ppm) + const variancePpm = Math.floor((Number(nodeCfg.PRICE_VARIANCE) || 0) * 1_000_000); + const randomPpm = variancePpm > 0 ? Math.floor(Math.random() * (variancePpm * 2 + 1)) - variancePpm : 0; + const basePrice = currentPrice!; // derived from previous bucket excluding outliers + const delta = (basePrice * BigInt(randomPpm)) / 1_000_000n; + const priceToReport = basePrice + delta; + + console.log( + `Reporting price for node ${client.account.address} in bucket ${currentBucket} (price=${priceToReport})...`, + ); + const txHash = await client.writeContract({ + address, + abi, + functionName: "reportPrice", + args: [priceToReport], + }); + reportTxHashes.push(txHash as `0x${string}`); + } + } catch (err) { + console.warn(`Skipping report for ${client.account.address}:`, (err as Error).message); + } + } + + // Wait for report transactions to be mined so subsequent reads (claiming) see the updated state. + if (reportTxHashes.length > 0) { + try { + await Promise.all(reportTxHashes.map(hash => publicClient.waitForTransactionReceipt({ hash } as any))); + } catch (err) { + // If waiting fails, continue โ€” claims will be attempted anyway but may not see the latest reports. + console.warn("Error while waiting for report tx receipts:", (err as Error).message); + } + } + + // 2) Slashing: if previous bucket had outliers + if (AUTO_SLASH) { + try { + const outliers = (await publicClient.readContract({ + address, + abi, + functionName: "getOutlierNodes", + args: [previousBucket], + })) as `0x${string}`[]; + + if (outliers.length > 0) { + console.log(`Found ${outliers.length} outliers in bucket ${previousBucket}, attempting to slash...`); + // Use the first wallet (deployer) to slash + const slasher = allWalletClients[0]; + for (const nodeAddr of outliers) { + const index = await findNodeIndex(runtime, address, abi, nodeAddr); + if (index === null) { + console.warn(`Index not found for node ${nodeAddr}, skipping slashing.`); + continue; + } + try { + await slasher.writeContract({ + address, + abi, + functionName: "slashNode", + args: [nodeAddr, previousBucket, index], + }); + console.log(`Slashed node ${nodeAddr} for bucket ${previousBucket} at index ${index}`); + } catch (err) { + console.warn(`Failed to slash ${nodeAddr}:`, (err as Error).message); + } + } + } + } catch (err) { + // getOutlierNodes may revert for small sample sizes (e.g., 0 or 1 report) + console.log(`Skipping slashing check for bucket ${previousBucket}:`, (err as Error).message); + } + } else { + // Auto-slash disabled by flag + console.log(`Auto-slash disabled; skipping slashing for bucket ${previousBucket}`); + } + + // 3) Rewards: claim when there are unclaimed reports + // Wait a couple seconds after reports have been mined before claiming + console.log("Waiting 2s before claiming rewards..."); + await sleep(2000); + for (const client of nodeWalletClients) { + try { + const rawNodeInfo = await publicClient.readContract({ + address, + abi, + functionName: "nodes", + args: [client.account.address], + }); + const node = normalizeNodeInfo(rawNodeInfo); + if (node.reportCount > node.claimedReportCount) { + await client.writeContract({ address, abi, functionName: "claimReward", args: [] }); + console.log(`Claimed rewards for ${client.account.address}`); + } + } catch (err) { + console.warn(`Failed to claim rewards for ${client.account.address}:`, (err as Error).message); + } + } + } catch (error) { + console.error("Error in oracle cycle:", error); + } +}; + +const run = async () => { + console.log("Starting oracle bot system..."); + // Fetch Uniswap price once at startup; subsequent cycles will base price on on-chain reports + currentPrice = await fetchPriceFromUniswap(); + console.log(`Initial base price from Uniswap: ${currentPrice}`); + + // Spin up nodes (register) for local testing if they aren't registered yet. + try { + const { address, abi } = await getStakingOracleDeployment(hre); + const publicClient = await hre.viem.getPublicClient(); + const accounts = await hre.viem.getWalletClients(); + // Mirror deploy script: use accounts[1..10] as oracle nodes + const nodeAccounts = accounts.slice(1, 11); + const registerTxHashes: `0x${string}`[] = []; + + for (const account of nodeAccounts) { + try { + const rawNodeInfo = await publicClient.readContract({ + address, + abi, + functionName: "nodes", + args: [account.account.address], + }); + const node = normalizeNodeInfo(rawNodeInfo); + if (node.firstBucket !== 0n) { + console.log(`Node already registered: ${account.account.address}`); + continue; + } + } catch { + // If read fails, proceed to attempt registration + } + + try { + console.log(`Registering node ${account.account.address} with initial price ${currentPrice} and stake 15 ETH`); + const txHash = await account.writeContract({ + address, + abi, + functionName: "registerNode", + args: [currentPrice], + value: parseEther("15"), + }); + registerTxHashes.push(txHash as `0x${string}`); + } catch (err: any) { + if (err?.message?.includes("NodeAlreadyRegistered")) { + console.log(`Node already registered during attempt: ${account.account.address}`); + } else { + console.warn(`Failed to register node ${account.account.address}:`, err?.message ?? err); + } + } + } + + if (registerTxHashes.length > 0) { + try { + await Promise.all(registerTxHashes.map(h => publicClient.waitForTransactionReceipt({ hash: h } as any))); + console.log("All node registration txs mined"); + } catch (err) { + console.warn("Error waiting for registration receipts:", (err as Error).message); + } + } + } catch (err) { + console.warn("Node registration step failed:", (err as Error).message); + } + while (true) { + await runCycle(hre); + await sleep(12000); + } +}; + +run().catch(error => { + console.error("Fatal error in oracle bot system:", error); + process.exit(1); +}); + +// Handle process termination signals +process.on("SIGINT", async () => { + console.log("\nReceived SIGINT (Ctrl+C). Cleaning up..."); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + console.log("\nReceived SIGTERM. Cleaning up..."); + process.exit(0); +}); + +// Handle uncaught exceptions +process.on("uncaughtException", async error => { + console.error("Uncaught Exception:", error); + process.exit(1); +}); + +// Handle unhandled promise rejections +process.on("unhandledRejection", async (reason, promise) => { + console.error("Unhandled Rejection at:", promise, "reason:", reason); + process.exit(1); +}); diff --git a/extension/packages/hardhat/scripts/runWhitelistOracleBots.ts b/extension/packages/hardhat/scripts/runWhitelistOracleBots.ts new file mode 100644 index 00000000..1bdd7e78 --- /dev/null +++ b/extension/packages/hardhat/scripts/runWhitelistOracleBots.ts @@ -0,0 +1,112 @@ +import { ethers } from "hardhat"; +import { WhitelistOracle } from "../typechain-types"; +import hre from "hardhat"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { fetchPriceFromUniswap } from "./fetchPriceFromUniswap"; +import { sleep } from "./utils"; + +async function getAllOracles() { + const [deployer] = await ethers.getSigners(); + const whitelistContract = await ethers.getContract("WhitelistOracle", deployer.address); + + const oracleAddresses = []; + let index = 0; + + try { + while (true) { + const oracle = await whitelistContract.oracles(index); + oracleAddresses.push(oracle); + index++; + } + } catch { + // When we hit an out-of-bounds error, we've found all oracles + console.log(`Found ${oracleAddresses.length} oracles`); + } + + return oracleAddresses; +} + +function getRandomPrice(basePrice: bigint): bigint { + const percentageShifts = [1, 2, 5, 7, 10, 15, 20]; + const randomIndex = Math.floor(Math.random() * percentageShifts.length); + const percentage = BigInt(percentageShifts[randomIndex]); + + const direction = Math.random() < 0.5 ? -1n : 1n; + const offset = (basePrice * percentage * direction) / 100n; + + return basePrice + offset; +} + +const runCycle = async (hre: HardhatRuntimeEnvironment, basePrice: bigint) => { + try { + const accounts = await hre.viem.getWalletClients(); + const simpleOracleFactory = await ethers.getContractFactory("SimpleOracle"); + const publicClient = await hre.viem.getPublicClient(); + + const blockNumber = await publicClient.getBlockNumber(); + console.log(`\n[Block ${blockNumber}] Starting new whitelist oracle cycle...`); + const oracleAddresses = await getAllOracles(); + if (oracleAddresses.length === 0) { + console.log("No oracles found"); + return; + } + + for (const oracleAddress of oracleAddresses) { + if (Math.random() < 0.4) { + console.log(`Skipping oracle at ${oracleAddress}`); + continue; + } + + const randomPrice = getRandomPrice(basePrice); + console.log(`Setting price for oracle at ${oracleAddress} to ${randomPrice}`); + + await accounts[0].writeContract({ + address: oracleAddress as `0x${string}`, + abi: simpleOracleFactory.interface.fragments, + functionName: "setPrice", + args: [randomPrice], + }); + } + } catch (error) { + console.error("Error in oracle cycle:", error); + throw error; + } +}; + +async function run() { + console.log("Starting whitelist oracle bots..."); + const basePrice = await fetchPriceFromUniswap(); + + while (true) { + await runCycle(hre, basePrice); + await sleep(4000); + } +} + +run().catch(error => { + console.error(error); + process.exitCode = 1; +}); + +// Handle process termination signals +process.on("SIGINT", async () => { + console.log("\nReceived SIGINT (Ctrl+C). Cleaning up..."); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + console.log("\nReceived SIGTERM. Cleaning up..."); + process.exit(0); +}); + +// Handle uncaught exceptions +process.on("uncaughtException", async error => { + console.error("Uncaught Exception:", error); + process.exit(1); +}); + +// Handle unhandled promise rejections +process.on("unhandledRejection", async (reason, promise) => { + console.error("Unhandled Rejection at:", promise, "reason:", reason); + process.exit(1); +}); diff --git a/extension/packages/hardhat/scripts/utils.ts b/extension/packages/hardhat/scripts/utils.ts new file mode 100644 index 00000000..6e4a114d --- /dev/null +++ b/extension/packages/hardhat/scripts/utils.ts @@ -0,0 +1,102 @@ +import { Config } from "./oracle-bot/types"; +import fs from "fs"; +import path from "path"; + +const getConfigPath = (): string => { + return path.join(__dirname, "oracle-bot", "config.json"); +}; + +export const getConfig = (): Config => { + const configPath = getConfigPath(); + const configContent = fs.readFileSync(configPath, "utf-8"); + const config = JSON.parse(configContent) as Config; + return config; +}; + +export const updateConfig = (updates: Partial): void => { + const configPath = getConfigPath(); + const currentConfig = getConfig(); + const updatedConfig = { ...currentConfig, ...updates }; + fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2)); +}; + +export const updatePriceCache = (price: number, timestamp: number): void => { + updateConfig({ + PRICE: { + CACHEDPRICE: price, + TIMESTAMP: timestamp, + }, + }); +}; + +export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export const QUESTIONS_FOR_OO: string[] = [ + "Did ETH/USD exceed $3,000 at 00:00 UTC on {MONTH} {DAY}, {YEAR}?", + "Did the BTC/ETH ratio fall below 14 on {MONTH} {DAY}, {YEAR}?", + "Did Uniswap's TVL exceed $10B on {MONTH} {DAY}, {YEAR}?", + "Did the Ethereum Cancun upgrade activate before {MONTH} {DAY}, {YEAR}?", + "Did the average gas price on Ethereum exceed 200 gwei on {MONTH} {DAY}, {YEAR}?", + "Did Ethereum's staking participation rate exceed 25% on {MONTH} {DAY}, {YEAR}?", + "Did Base chain have more than 1M daily transactions on {MONTH} {DAY}, {YEAR}?", + "Did the SEC approve a Bitcoin ETF before {MONTH} {DAY}, {YEAR}?", + "Did OpenSea's trading volume exceed $500M in {MONTH} {YEAR}?", + "Did Farcaster have more than 10K active users on {MONTH} {DAY}, {YEAR}?", + "Did ENS domains exceed 5M total registrations before {MONTH} {YEAR}?", + "Did the total bridged USDC on Arbitrum exceed $2B on {MONTH} {DAY}, {YEAR}?", + "Did Optimism's native token OP increase above $1.50 on {MONTH} {DAY}, {YEAR}?", + "Did Aave v3 have higher borrow volume than v2 on {MONTH} {DAY}, {YEAR}?", + "Did Compound see more than 1,000 liquidations on {MONTH} {DAY}, {YEAR}?", + "Did BTC's 24-hour volume exceed $50B on {MONTH} {DAY}, {YEAR}?", + "Did Real Madrid win the UEFA Champions League Final in {YEAR}?", + "Did G2 Esports win a major tournament in {MONTH} {YEAR}?", + "Did the temperature in New York exceed 35ยฐC on {MONTH} {DAY}, {YEAR}?", + "Did it rain more than 50mm in London on {MONTH} {DAY}, {YEAR}?", + "Did Tokyo experience an earthquake of magnitude 5.0 or higher in {MONTH} {YEAR}?", + "Did the Nasdaq Composite fall more than 3% on {MONTH} {DAY}, {YEAR}?", + "Did the S&P 500 set a new all-time high on {MONTH} {DAY}, {YEAR}?", + "Did the US unemployment rate drop below 4% in {MONTH} {YEAR}?", + "Did the average global temperature for {MONTH} {YEAR} exceed that of the previous year?", + "Did gold price exceed $2,200/oz on {MONTH} {DAY}, {YEAR}?", + "Did YouTube's most viewed video gain more than 10M new views in {MONTH} {YEAR}?", + "Did the population of India officially surpass China according to the UN in {YEAR}?", + "Did the UEFA Euro 2024 Final have more than 80,000 attendees in the stadium?", + "Did a pigeon successfully complete a 500km race in under 10 hours in {MONTH} {YEAR}?", + "Did a goat attend a university graduation ceremony wearing a cap and gown on {MONTH} {DAY}, {YEAR}?", + "Did someone eat 100 chicken nuggets in under 10 minutes on {MONTH} {DAY}, {YEAR}?", + "Did a cat walk across a live TV weather report in {MONTH} {YEAR}?", + "Did a cow escape from a farm and get caught on camera riding a water slide in {YEAR}?", + "Did a man legally change his name to 'Bitcoin McMoneyface' on {MONTH} {DAY}, {YEAR}?", + "Did a squirrel steal a GoPro and film itself on {MONTH} {DAY}, {YEAR}?", + "Did someone cosplay as Shrek and complete a full marathon on {MONTH} {DAY}, {YEAR}?", + "Did a group of people attempt to cook the world's largest pancake using a flamethrower?", + "Did a man propose using a pizza drone delivery on {MONTH} {DAY}, {YEAR}?", + "Did a woman knit a sweater large enough to cover a school bus in {MONTH} {YEAR}?", + "Did someone attempt to break the world record for most dad jokes told in 1 hour?", + "Did an alpaca accidentally join a Zoom meeting for a tech startup on {MONTH} {DAY}, {YEAR}?", +]; + +const generateRandomPastDate = (now: Date): Date => { + const daysBack = Math.floor(Math.random() * 45) + 1; // 1 - 45 days + + const pastDate = new Date(now); + pastDate.setDate(pastDate.getDate() - daysBack); + + return pastDate; +}; + +const replaceDatePlaceholders = (question: string): string => { + const now = new Date(); + const past = generateRandomPastDate(now); + + return question + .replace(/\{DAY\}/g, past.getDate().toString()) + .replace(/\{MONTH\}/g, past.toLocaleDateString("en-US", { month: "long" })) + .replace(/\{YEAR\}/g, past.getFullYear().toString()); +}; + +export const getRandomQuestion = (): string => { + const randomIndex = Math.floor(Math.random() * QUESTIONS_FOR_OO.length); + const question = QUESTIONS_FOR_OO[randomIndex]; + return replaceDatePlaceholders(question); +}; diff --git a/extension/packages/hardhat/test/OptimisticOracle.ts b/extension/packages/hardhat/test/OptimisticOracle.ts new file mode 100644 index 00000000..704a5689 --- /dev/null +++ b/extension/packages/hardhat/test/OptimisticOracle.ts @@ -0,0 +1,642 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { OptimisticOracle, Decider } from "../typechain-types"; +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +describe("OptimisticOracle", function () { + before(async () => { + await ethers.provider.send("evm_setAutomine", [true]); + await ethers.provider.send("evm_setIntervalMining", [0]); + }); + + let optimisticOracle: OptimisticOracle; + let deciderContract: Decider; + let owner: HardhatEthersSigner; + let asserter: HardhatEthersSigner; + let proposer: HardhatEthersSigner; + let disputer: HardhatEthersSigner; + let otherUser: HardhatEthersSigner; + + const contractAddress = process.env.CONTRACT_ADDRESS; + + let contractArtifact: string; + if (contractAddress) { + // For the autograder + contractArtifact = `contracts/download-${contractAddress}.sol:OptimisticOracle`; + } else { + contractArtifact = "contracts/02_Optimistic/OptimisticOracle.sol:OptimisticOracle"; + } + + // Enum for state + const State = { + Invalid: 0n, + Asserted: 1n, + Proposed: 2n, + Disputed: 3n, + Settled: 4n, + Expired: 5n, + }; + + beforeEach(async function () { + [owner, asserter, proposer, disputer, otherUser] = await ethers.getSigners(); + + // Deploy OptimisticOracle with temporary decider (owner) + const OptimisticOracleFactory = await ethers.getContractFactory(contractArtifact); + optimisticOracle = (await OptimisticOracleFactory.deploy(owner.address)) as OptimisticOracle; + + // Deploy Decider + const DeciderFactory = await ethers.getContractFactory("Decider"); + deciderContract = await DeciderFactory.deploy(optimisticOracle.target); + + // Set the decider in the oracle + await optimisticOracle.setDecider(deciderContract.target); + }); + describe("Checkpoint4", function () { + describe("Deployment", function () { + it("Should deploy successfully", function () { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(optimisticOracle.target).to.not.be.undefined; + }); + + it("Should set the correct owner", async function () { + const contractOwner = await optimisticOracle.owner(); + expect(contractOwner).to.equal(owner.address); + }); + + it("Should have correct constants", async function () { + const minimumAssertionWindow = await optimisticOracle.MINIMUM_ASSERTION_WINDOW(); + const disputeWindow = await optimisticOracle.DISPUTE_WINDOW(); + + expect(minimumAssertionWindow).to.equal(180n); // 3 minutes + expect(disputeWindow).to.equal(180n); // 3 minutes + }); + + it("Should start with nextAssertionId at 1", async function () { + const nextAssertionId = await optimisticOracle.nextAssertionId(); + expect(nextAssertionId).to.equal(1n); + }); + + it("Should return correct assertionId for first assertion", async function () { + const description = "Will Bitcoin reach $1m by end of 2026?"; + const reward = ethers.parseEther("1"); + + const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward }); + const receipt = await tx.wait(); + // Get the assertionId from the event + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + const assertionId = parsedEvent!.args[0]; + + expect(assertionId).to.equal(1n); + }); + }); + + describe("Event Assertion", function () { + it("Should allow users to assert events with reward", async function () { + const description = "Will Bitcoin reach $1m by end of 2026?"; + const reward = ethers.parseEther("1"); + + const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward }); + const receipt = await tx.wait(); + + // Get the assertionId from the event + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + const assertionId = parsedEvent!.args[0]; + + expect(tx) + .to.emit(optimisticOracle, "EventAsserted") + .withArgs(assertionId, asserter.address, description, reward); + }); + + it("Should reject assertions with insufficient reward", async function () { + const description = "Will Bitcoin reach $1m by end of 2026?"; + const insufficientReward = ethers.parseEther("0.0"); + + await expect( + optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: insufficientReward }), + ).to.be.revertedWithCustomError(optimisticOracle, "InvalidValue"); + }); + }); + + describe("Outcome Proposal", function () { + let assertionId: bigint; + let description: string; + let reward: bigint; + + beforeEach(async function () { + description = "Will Bitcoin reach $1m by end of 2026?"; + reward = ethers.parseEther("1"); + const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward }); + const receipt = await tx.wait(); + // Get the assertionId from the event + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + assertionId = parsedEvent!.args[0]; + }); + + it("Should allow proposing outcomes with correct bond", async function () { + const bond = reward * 2n; + const outcome = true; + + const tx = await optimisticOracle.connect(proposer).proposeOutcome(assertionId, outcome, { value: bond }); + + expect(tx).to.emit(optimisticOracle, "OutcomeProposed").withArgs(assertionId, proposer.address, outcome); + }); + + it("Should reject proposals with incorrect bond", async function () { + const wrongBond = ethers.parseEther("0.05"); + const outcome = true; + + await expect( + optimisticOracle.connect(proposer).proposeOutcome(assertionId, outcome, { value: wrongBond }), + ).to.be.revertedWithCustomError(optimisticOracle, "InvalidValue"); + }); + + it("Should reject duplicate proposals", async function () { + const bond = reward * 2n; + const outcome = true; + + await optimisticOracle.connect(proposer).proposeOutcome(assertionId, outcome, { value: bond }); + + await expect( + optimisticOracle.connect(otherUser).proposeOutcome(assertionId, !outcome, { value: bond }), + ).to.be.revertedWithCustomError(optimisticOracle, "AssertionProposed"); + }); + }); + + describe("Outcome Dispute", function () { + let assertionId: bigint; + let description: string; + let reward: bigint; + + beforeEach(async function () { + description = "Will Bitcoin reach $1m by end of 2026?"; + reward = ethers.parseEther("1"); + const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward }); + const receipt = await tx.wait(); + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + assertionId = parsedEvent!.args[0]; + + const bond = reward * 2n; + await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }); + }); + + it("Should allow disputing outcomes with correct bond", async function () { + const bond = reward * 2n; + + const tx = await optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond }); + + expect(tx).to.emit(optimisticOracle, "OutcomeDisputed").withArgs(assertionId, disputer.address); + }); + + it("Should reject disputes with incorrect bond", async function () { + const wrongBond = ethers.parseEther("0.05"); + + await expect( + optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: wrongBond }), + ).to.be.revertedWithCustomError(optimisticOracle, "InvalidValue"); + }); + + it("Should reject disputes after deadline", async function () { + // Fast forward time past dispute window + await ethers.provider.send("evm_increaseTime", [181]); // 3 minutes + 1 second + await ethers.provider.send("evm_mine"); + + const bond = reward * 2n; + await expect( + optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond }), + ).to.be.revertedWithCustomError(optimisticOracle, "InvalidTime"); + }); + + it("Should reject duplicate disputes", async function () { + const bond = reward * 2n; + await optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond }); + + await expect( + optimisticOracle.connect(otherUser).disputeOutcome(assertionId, { value: bond }), + ).to.be.revertedWithCustomError(optimisticOracle, "ProposalDisputed"); + }); + }); + + describe("Start and End Time Logic", function () { + it("Should not allow proposal before startTime", async function () { + const reward = ethers.parseEther("1"); + const now = (await ethers.provider.getBlock("latest"))!.timestamp; + const start = now + 1000; + const end = start + 1000; + const tx = await optimisticOracle.connect(asserter).assertEvent("future event", start, end, { value: reward }); + const receipt = await tx.wait(); + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + if (!parsedEvent) throw new Error("Event not found"); + const assertionId = parsedEvent.args[0]; + + const bond = reward * 2n; + await expect( + optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }), + ).to.be.revertedWithCustomError(optimisticOracle, "InvalidTime"); + }); + + it("Should not allow proposal after endTime", async function () { + const reward = ethers.parseEther("1"); + const now = (await ethers.provider.getBlock("latest"))!.timestamp; + const start = now + 1; // Start time must be in the future + const end = now + 200; // 200 seconds, which is more than DISPUTE_WINDOW (180 seconds) + const tx = await optimisticOracle.connect(asserter).assertEvent("short event", start, end, { value: reward }); + const receipt = await tx.wait(); + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + if (!parsedEvent) throw new Error("Event not found"); + const assertionId = parsedEvent.args[0]; + + // Wait until after endTime + await ethers.provider.send("evm_increaseTime", [201]); + await ethers.provider.send("evm_mine"); + + const bond = reward * 2n; + await expect( + optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }), + ).to.be.revertedWithCustomError(optimisticOracle, "InvalidTime"); + }); + + it("Should allow proposal only within [startTime, endTime]", async function () { + const reward = ethers.parseEther("1"); + const now = (await ethers.provider.getBlock("latest"))!.timestamp; + const start = now + 10; // Start time in the future + const end = start + 200; // Ensure endTime is far enough in the future + const tx = await optimisticOracle.connect(asserter).assertEvent("window event", start, end, { value: reward }); + const receipt = await tx.wait(); + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + if (!parsedEvent) throw new Error("Event not found"); + const assertionId = parsedEvent.args[0]; + + const bond = reward * 2n; + + // Before startTime - should fail + await expect( + optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }), + ).to.be.revertedWithCustomError(optimisticOracle, "InvalidTime"); + + // Move to startTime + await ethers.provider.send("evm_increaseTime", [10]); + await ethers.provider.send("evm_mine"); + + // Now it should work + await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }); + }); + }); + }); + + describe("Checkpoint5", function () { + describe("Undisputed Reward Claiming", function () { + let assertionId: bigint; + let description: string; + let reward: bigint; + + beforeEach(async function () { + description = "Will Bitcoin reach $1m by end of 2026?"; + reward = ethers.parseEther("1"); + const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward }); + const receipt = await tx.wait(); + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + assertionId = parsedEvent!.args[0]; + + const bond = reward * 2n; + await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }); + }); + + it("Should allow claiming undisputed rewards after deadline", async function () { + // Fast forward time past dispute window + await ethers.provider.send("evm_increaseTime", [181]); + await ethers.provider.send("evm_mine"); + + const initialBalance = await ethers.provider.getBalance(proposer.address); + const tx = await optimisticOracle.connect(proposer).claimUndisputedReward(assertionId); + const receipt = await tx.wait(); + const finalBalance = await ethers.provider.getBalance(proposer.address); + + // Check that proposer received the reward (reward + bond - gas costs) + const expectedReward = reward + reward * 2n; + const gasCost = receipt!.gasUsed * receipt!.gasPrice!; + expect(finalBalance - initialBalance + gasCost).to.equal(expectedReward); + }); + + it("Should reject claiming before deadline", async function () { + await expect( + optimisticOracle.connect(proposer).claimUndisputedReward(assertionId), + ).to.be.revertedWithCustomError(optimisticOracle, "InvalidTime"); + }); + + it("Should reject claiming disputed assertions", async function () { + const bond = reward * 2n; + await optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond }); + + await expect( + optimisticOracle.connect(proposer).claimUndisputedReward(assertionId), + ).to.be.revertedWithCustomError(optimisticOracle, "ProposalDisputed"); + }); + + it("Should reject claiming already claimed rewards", async function () { + // Fast forward time and claim + await ethers.provider.send("evm_increaseTime", [181]); + await ethers.provider.send("evm_mine"); + await optimisticOracle.connect(proposer).claimUndisputedReward(assertionId); + + await expect( + optimisticOracle.connect(proposer).claimUndisputedReward(assertionId), + ).to.be.revertedWithCustomError(optimisticOracle, "AlreadyClaimed"); + }); + }); + + describe("Disputed Reward Claiming", function () { + let assertionId: bigint; + let description: string; + let reward: bigint; + + beforeEach(async function () { + description = "Will Bitcoin reach $1m by end of 2026?"; + reward = ethers.parseEther("1"); + const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward }); + const receipt = await tx.wait(); + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + assertionId = parsedEvent!.args[0]; + + const bond = reward * 2n; + await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }); + await optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond }); + }); + + it("Should allow winner to claim disputed rewards after settlement", async function () { + // Settle with proposer winning + await deciderContract.connect(owner).settleDispute(assertionId, true); + + const initialBalance = await ethers.provider.getBalance(proposer.address); + const tx = await optimisticOracle.connect(proposer).claimDisputedReward(assertionId); + const receipt = await tx.wait(); + const finalBalance = await ethers.provider.getBalance(proposer.address); + + // Check that proposer received the reward (reward + bond - gas costs) + const expectedReward = reward * 3n; + const gasCost = receipt!.gasUsed * receipt!.gasPrice!; + expect(finalBalance - initialBalance + gasCost).to.equal(expectedReward); + }); + + it("Should allow disputer to claim when they win", async function () { + // Settle with disputer winning + await deciderContract.connect(owner).settleDispute(assertionId, false); + + const initialBalance = await ethers.provider.getBalance(disputer.address); + const tx = await optimisticOracle.connect(disputer).claimDisputedReward(assertionId); + const receipt = await tx.wait(); + const finalBalance = await ethers.provider.getBalance(disputer.address); + + // Check that disputer received the reward + const expectedReward = reward * 3n; + const gasCost = receipt!.gasUsed * receipt!.gasPrice!; + expect(finalBalance - initialBalance + gasCost).to.equal(expectedReward); + }); + + it("Should reject claiming before settlement", async function () { + await expect(optimisticOracle.connect(proposer).claimDisputedReward(assertionId)).to.be.revertedWithCustomError( + optimisticOracle, + "AwaitingDecider", + ); + }); + + it("Should reject claiming already claimed rewards", async function () { + await deciderContract.connect(owner).settleDispute(assertionId, true); + await optimisticOracle.connect(proposer).claimDisputedReward(assertionId); + + await expect(optimisticOracle.connect(proposer).claimDisputedReward(assertionId)).to.be.revertedWithCustomError( + optimisticOracle, + "AlreadyClaimed", + ); + }); + }); + + describe("Refund Claiming", function () { + let assertionId: bigint; + let description: string; + let reward: bigint; + + beforeEach(async function () { + description = "Will Bitcoin reach $1m by end of 2026?"; + reward = ethers.parseEther("1"); + const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward }); + const receipt = await tx.wait(); + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + assertionId = parsedEvent!.args[0]; + }); + + it("Should allow asserter to claim refund for assertions without proposals", async function () { + // Fast forward time past dispute window + await ethers.provider.send("evm_increaseTime", [181]); + await ethers.provider.send("evm_mine"); + + const initialBalance = await ethers.provider.getBalance(asserter.address); + const tx = await optimisticOracle.connect(asserter).claimRefund(assertionId); + const receipt = await tx.wait(); + const finalBalance = await ethers.provider.getBalance(asserter.address); + + // Check that asserter received the refund (reward - gas costs) + const gasCost = receipt!.gasUsed * receipt!.gasPrice!; + expect(finalBalance - initialBalance + gasCost).to.equal(reward); + }); + + it("Should reject refund claiming for assertions with proposals", async function () { + const bond = reward * 2n; + await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }); + + await expect(optimisticOracle.connect(asserter).claimRefund(assertionId)).to.be.revertedWithCustomError( + optimisticOracle, + "AssertionProposed", + ); + }); + + it("Should reject claiming already claimed refunds", async function () { + // Fast forward time and claim + await ethers.provider.send("evm_increaseTime", [181]); + await ethers.provider.send("evm_mine"); + await optimisticOracle.connect(asserter).claimRefund(assertionId); + + await expect(optimisticOracle.connect(asserter).claimRefund(assertionId)).to.be.revertedWithCustomError( + optimisticOracle, + "AlreadyClaimed", + ); + }); + }); + }); + + describe("Checkpoint6", function () { + describe("Dispute Settlement", function () { + let assertionId: bigint; + let description: string; + let reward: bigint; + + beforeEach(async function () { + description = "Will Bitcoin reach $1m by end of 2026?"; + reward = ethers.parseEther("1"); + const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward }); + const receipt = await tx.wait(); + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + assertionId = parsedEvent!.args[0]; + + const bond = reward * 2n; + await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }); + await optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond }); + }); + + it("Should allow decider to settle disputed assertions", async function () { + const resolvedOutcome = true; + const tx = await deciderContract.connect(owner).settleDispute(assertionId, resolvedOutcome); + + expect(tx) + .to.emit(optimisticOracle, "AssertionSettled") + .withArgs(assertionId, resolvedOutcome, proposer.address); + + // Check that the assertion was settled correctly by checking the state + const state = await optimisticOracle.getState(assertionId); + expect(state).to.equal(State.Settled); // Settled state + }); + + it("Should reject settlement by non-decider", async function () { + const resolvedOutcome = true; + + await expect( + optimisticOracle.connect(otherUser).settleAssertion(assertionId, resolvedOutcome), + ).to.be.revertedWithCustomError(optimisticOracle, "OnlyDecider"); + }); + + it("Should reject settling undisputed assertions", async function () { + // Create a new undisputed assertion + const newDescription = "Will Ethereum reach $10k by end of 2024?"; + const newTx = await optimisticOracle.connect(asserter).assertEvent(newDescription, 0, 0, { value: reward }); + const newReceipt = await newTx.wait(); + const newEvent = newReceipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const newParsedEvent = optimisticOracle.interface.parseLog(newEvent as any); + const newAssertionId = newParsedEvent!.args[0]; + + const bond = reward * 2n; + await optimisticOracle.connect(proposer).proposeOutcome(newAssertionId, true, { value: bond }); + + const resolvedOutcome = true; + await expect( + deciderContract.connect(owner).settleDispute(newAssertionId, resolvedOutcome), + ).to.be.revertedWithCustomError(optimisticOracle, "NotDisputedAssertion"); + }); + }); + + describe("State Management", function () { + it("Should return correct states for different scenarios", async function () { + const description = "Will Bitcoin reach $1m by end of 2026?"; + const reward = ethers.parseEther("1"); + + // Invalid state for non-existent assertion + let state = await optimisticOracle.getState(999n); + expect(state).to.equal(State.Invalid); // Invalid + + // Asserted state + const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward }); + const receipt = await tx.wait(); + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + const assertionId = parsedEvent!.args[0]; + + state = await optimisticOracle.getState(assertionId); + expect(state).to.equal(State.Asserted); // Asserted + + // Proposed state + const bond = reward * 2n; + await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }); + state = await optimisticOracle.getState(assertionId); + expect(state).to.equal(State.Proposed); // Proposed + + // Disputed state + await optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond }); + state = await optimisticOracle.getState(assertionId); + expect(state).to.equal(State.Disputed); // Disputed + + // Settled state (after decider resolution) + await deciderContract.connect(owner).settleDispute(assertionId, true); + state = await optimisticOracle.getState(assertionId); + expect(state).to.equal(State.Settled); // Settled + }); + + it("Should show settled state for claimable uncontested assertions", async function () { + const description = "Will Ethereum reach $10k by end of 2024?"; + const reward = ethers.parseEther("1"); + + const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward }); + const receipt = await tx.wait(); + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + const assertionId = parsedEvent!.args[0]; + + const bond = reward * 2n; + await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }); + + // Fast forward time past dispute window + await ethers.provider.send("evm_increaseTime", [181]); + await ethers.provider.send("evm_mine"); + + const state = await optimisticOracle.getState(assertionId); + expect(state).to.equal(State.Settled); // Settled (can be claimed) + }); + + it("Should show expired state for assertions without proposals after deadline", async function () { + const description = "Will Ethereum reach $10k by end of 2024?"; + const reward = ethers.parseEther("1"); + + const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward }); + const receipt = await tx.wait(); + const event = receipt!.logs.find( + log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted", + ); + const parsedEvent = optimisticOracle.interface.parseLog(event as any); + const assertionId = parsedEvent!.args[0]; + + // Fast forward time past dispute window without any proposal + await ethers.provider.send("evm_increaseTime", [181]); + await ethers.provider.send("evm_mine"); + + const state = await optimisticOracle.getState(assertionId); + expect(state).to.equal(State.Expired); // Expired + }); + }); + }); +}); diff --git a/extension/packages/hardhat/test/StakingOracle.ts b/extension/packages/hardhat/test/StakingOracle.ts new file mode 100644 index 00000000..f7543c1f --- /dev/null +++ b/extension/packages/hardhat/test/StakingOracle.ts @@ -0,0 +1,425 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { mine } from "@nomicfoundation/hardhat-network-helpers"; +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import type { StakingOracle, ORA } from "../typechain-types"; + +describe("Checkpoint2", function () { + before(async () => { + await ethers.provider.send("evm_setAutomine", [true]); + await ethers.provider.send("evm_setIntervalMining", [0]); + }); + + let oracle: StakingOracle; + let oraToken: ORA; + let node1: HardhatEthersSigner; + let node2: HardhatEthersSigner; + let node3: HardhatEthersSigner; + let node4: HardhatEthersSigner; + let node5: HardhatEthersSigner; + let node6: HardhatEthersSigner; + let slasher: HardhatEthersSigner; + const MINIMUM_STAKE = ethers.parseEther("1"); + async function mineBuckets(count: number) { + const bucketWindow = Number(await oracle.BUCKET_WINDOW()); + await mine(bucketWindow * count); + } + beforeEach(async function () { + [node1, node2, node3, node4, node5, node6, slasher] = await ethers.getSigners(); + const StakingOracleFactory = await ethers.getContractFactory("StakingOracle"); + oracle = (await StakingOracleFactory.deploy()) as StakingOracle; + await oracle.waitForDeployment(); + const oraTokenAddress = await oracle.oracleToken(); + const ORAFactory = await ethers.getContractFactory("ORA"); + oraToken = ORAFactory.attach(oraTokenAddress) as ORA; + }); + describe("constructor", function () { + it("deploys ORA token", async function () { + const tokenAddress = await oracle.oracleToken(); + const code = await ethers.provider.getCode(tokenAddress); + expect(code).to.not.equal("0x"); + }); + }); + describe("getNodeAddresses", function () { + it("returns all registered nodes in order", async function () { + await (await oracle.connect(node1).registerNode(1500, { value: MINIMUM_STAKE })).wait(); + await (await oracle.connect(node2).registerNode(1501, { value: MINIMUM_STAKE })).wait(); + await (await oracle.connect(node3).registerNode(1502, { value: MINIMUM_STAKE })).wait(); + const nodeAddresses = await oracle.getNodeAddresses(); + expect(nodeAddresses.length).to.equal(3); + expect(nodeAddresses[0]).to.equal(node1.address); + expect(nodeAddresses[1]).to.equal(node2.address); + expect(nodeAddresses[2]).to.equal(node3.address); + }); + }); + describe("Node Registration", function () { + it("allows register with minimum stake and emits events", async function () { + const initialPrice = 1500; + await (await oracle.connect(node1).registerNode(initialPrice, { value: MINIMUM_STAKE })).wait(); + const info = await oracle.nodes(node1.address); + expect(info.stakedAmount).to.equal(MINIMUM_STAKE); + expect(info.active).to.equal(true); + expect(await oracle.getNodeAddresses()).to.deep.equal([node1.address]); + }); + it("rejects insufficient stake and duplicate registration", async function () { + await expect( + oracle.connect(node1).registerNode(1500, { value: ethers.parseEther("0.5") }), + ).to.be.revertedWithCustomError(oracle, "InsufficientStake"); + await oracle.connect(node1).registerNode(1500, { value: MINIMUM_STAKE }); + await expect(oracle.connect(node1).registerNode(1501, { value: MINIMUM_STAKE })).to.be.revertedWithCustomError( + oracle, + "NodeAlreadyRegistered", + ); + }); + }); + describe("Price Reporting", function () { + beforeEach(async function () { + await (await oracle.connect(node1).registerNode(1500, { value: MINIMUM_STAKE })).wait(); + }); + + it("emits PriceReported and prevents double report in same bucket", async function () { + await mineBuckets(1); + const currentBucket = await oracle.getCurrentBucketNumber(); + await (await oracle.connect(node1).reportPrice(1600)).wait(); + const [p] = await oracle.getAddressDataAtBucket(node1.address, currentBucket); + expect(p).to.equal(1600); + + await expect(oracle.connect(node1).reportPrice(1700)).to.be.revertedWithCustomError( + oracle, + "AlreadyReportedInCurrentBucket", + ); + }); + + it("rejects zero price and unregistered node", async function () { + await expect(oracle.connect(node1).reportPrice(0)).to.be.revertedWithCustomError(oracle, "InvalidPrice"); + await expect(oracle.connect(node2).reportPrice(1000)).to.be.revertedWithCustomError(oracle, "NodeNotRegistered"); + }); + + it("rejects when effective stake falls below minimum after missed buckets", async function () { + // Miss 2 full buckets without reporting -> 1 missed expected report => 0.01 ETH penalty + await mineBuckets(2); + // Now attempting to report should fail due to effectiveStake < 1 ETH + await expect(oracle.connect(node1).reportPrice(1600)).to.be.revertedWithCustomError(oracle, "InsufficientStake"); + }); + }); + + describe("Claim Reward", function () { + beforeEach(async function () { + await (await oracle.connect(node1).registerNode(1500, { value: MINIMUM_STAKE })).wait(); + }); + + it("mints 1 ORA per report and reverts with no rewards", async function () { + const beforeBal = await oraToken.balanceOf(node1.address); + await (await oracle.connect(node1).claimReward()).wait(); + const afterBal = await oraToken.balanceOf(node1.address); + expect(afterBal).to.be.gt(beforeBal); + await expect(oracle.connect(node1).claimReward()).to.be.revertedWithCustomError(oracle, "NoRewardsAvailable"); + }); + + it("accumulates rewards across buckets", async function () { + await (await oracle.connect(node1).claimReward()).wait(); + await mineBuckets(1); + await (await oracle.connect(node1).reportPrice(1600)).wait(); + await mineBuckets(1); + await (await oracle.connect(node1).reportPrice(1700)).wait(); + const beforeBal = await oraToken.balanceOf(node1.address); + await (await oracle.connect(node1).claimReward()).wait(); + const afterBal = await oraToken.balanceOf(node1.address); + expect(afterBal - beforeBal).to.equal(ethers.parseEther("2")); + }); + }); + describe("Prices by bucket", function () { + beforeEach(async function () { + await (await oracle.connect(node1).registerNode(1000, { value: MINIMUM_STAKE })).wait(); + await (await oracle.connect(node2).registerNode(1100, { value: MINIMUM_STAKE })).wait(); + }); + it("returns average for previous bucket via getLatestPrice", async function () { + await expect(oracle.getLatestPrice()).to.be.revertedWithCustomError(oracle, "NoValidPricesAvailable"); + await mineBuckets(1); + const latest = await oracle.getLatestPrice(); + expect(latest).to.equal(1050); + }); + it("getPastPrice returns stored average and getAddressDataAtBucket matches", async function () { + const bucketA = await oracle.getCurrentBucketNumber(); + await mineBuckets(1); + const pastAvg = await oracle.getPastPrice(bucketA); + expect(pastAvg).to.equal(1050); + const [p1] = await oracle.getAddressDataAtBucket(node1.address, bucketA); + const [p2] = await oracle.getAddressDataAtBucket(node2.address, bucketA); + expect(p1).to.equal(1000); + expect(p2).to.equal(1100); + }); + it("getPastPrice reverts for empty bucket", async function () { + await mineBuckets(1); + const futureBucket = await oracle.getCurrentBucketNumber(); + await expect(oracle.getPastPrice(futureBucket)).to.be.revertedWithCustomError(oracle, "NoValidPricesAvailable"); + }); + }); + describe("Effective stake and addStake", function () { + beforeEach(async function () { + await (await oracle.connect(node1).registerNode(1500, { value: MINIMUM_STAKE })).wait(); + }); + it("penalizes missed buckets and floors at zero; addStake increases", async function () { + await mineBuckets(2); + const eff1 = await oracle.getEffectiveStake(node1.address); + expect(eff1).to.equal(ethers.parseEther("0.99")); + await (await oracle.connect(node1).addStake({ value: ethers.parseEther("0.5") })).wait(); + const eff2 = await oracle.getEffectiveStake(node1.address); + expect(eff2).to.equal(ethers.parseEther("1.49")); + }); + it("rejects zero value stake addition", async function () { + await expect(oracle.connect(node1).addStake({ value: 0 })).to.be.revertedWithCustomError( + oracle, + "InsufficientStake", + ); + }); + }); + describe("Slashing - deviation in past bucket", function () { + async function indexOf(address: string) { + const arr = await oracle.getNodeAddresses(); + return arr.findIndex(a => a.toLowerCase() === address.toLowerCase()); + } + beforeEach(async function () { + // Move to beginning of bucket window before registering nodes so that we have 24 blocks to report in the same bucket. + const bucketWindow = Number(await oracle.BUCKET_WINDOW()); + const blockNum = await ethers.provider.getBlockNumber(); + const toNext = (bucketWindow - (blockNum % bucketWindow)) % bucketWindow; // 0..bucketWindow-1 + await mine(toNext + 1); + await (await oracle.connect(node1).registerNode(1000, { value: MINIMUM_STAKE })).wait(); + await (await oracle.connect(node2).registerNode(1000, { value: MINIMUM_STAKE })).wait(); + await (await oracle.connect(node3).registerNode(1200, { value: MINIMUM_STAKE })).wait(); + await mineBuckets(1); + }); + it("reverts for current bucket and for non-deviated prices", async function () { + const current = await oracle.getCurrentBucketNumber(); + const idx = await indexOf(node3.address); + await expect(oracle.connect(slasher).slashNode(node3.address, current, idx)).to.be.revertedWithCustomError( + oracle, + "OnlyPastBucketsAllowed", + ); + await (await oracle.connect(node1).reportPrice(1000)).wait(); + await (await oracle.connect(node2).reportPrice(1000)).wait(); + await (await oracle.connect(node3).reportPrice(1050)).wait(); + const bucketB = await oracle.getCurrentBucketNumber(); + await mineBuckets(1); + const idxB = await indexOf(node3.address); + await expect(oracle.connect(slasher).slashNode(node3.address, bucketB, idxB)).to.be.revertedWithCustomError( + oracle, + "NotDeviated", + ); + }); + it("slashes deviated node, rewards slasher, and cannot slash again", async function () { + await (await oracle.connect(node3).addStake({ value: ethers.parseEther("2") })).wait(); + const pastBucket = (await oracle.getCurrentBucketNumber()) - 1n; + const idx = await indexOf(node3.address); + const slasherBalBefore = await ethers.provider.getBalance(slasher.address); + const tx = await oracle.connect(slasher).slashNode(node3.address, pastBucket, idx); + const rcpt = await tx.wait(); + if (!rcpt) throw new Error("no receipt"); + const gasCost = BigInt(rcpt.gasUsed) * BigInt(rcpt.gasPrice); + const SLASHER_REWARD_PERCENTAGE = await oracle.SLASHER_REWARD_PERCENTAGE(); + const expectedReward = (ethers.parseEther("1") * SLASHER_REWARD_PERCENTAGE) / 100n; + const slasherBalAfter = await ethers.provider.getBalance(slasher.address); + expect(slasherBalAfter).to.equal(slasherBalBefore + expectedReward - gasCost); + await expect(oracle.connect(slasher).slashNode(node3.address, pastBucket, idx)).to.be.revertedWithCustomError( + oracle, + "NodeAlreadySlashed", + ); + }); + it("slashes deviated node and removes when stake hits zero", async function () { + const pastBucket = (await oracle.getCurrentBucketNumber()) - 1n; + const idx = await indexOf(node3.address); + const slasherBalBefore = await ethers.provider.getBalance(slasher.address); + const tx = await oracle.connect(slasher).slashNode(node3.address, pastBucket, idx); + const rcpt = await tx.wait(); + if (!rcpt) throw new Error("no receipt"); + const gasCost = BigInt(rcpt.gasUsed) * BigInt(rcpt.gasPrice); + const SLASHER_REWARD_PERCENTAGE = await oracle.SLASHER_REWARD_PERCENTAGE(); + const expectedReward = (ethers.parseEther("1") * SLASHER_REWARD_PERCENTAGE) / 100n; + const slasherBalAfter = await ethers.provider.getBalance(slasher.address); + expect(slasherBalAfter).to.equal(slasherBalBefore + expectedReward - gasCost); + const addresses = await oracle.getNodeAddresses(); + expect(addresses).to.not.include(node3.address); + }); + it("reverts NodeDidNotReport for registered node that did not report in that bucket", async function () { + await (await oracle.connect(node1).reportPrice(1000)).wait(); + await (await oracle.connect(node2).reportPrice(1000)).wait(); + // node3 is registered but did not report in this bucket + const bucketC = await oracle.getCurrentBucketNumber(); + await mineBuckets(1); + const idx3 = await indexOf(node3.address); + await expect(oracle.connect(slasher).slashNode(node3.address, bucketC, idx3)).to.be.revertedWithCustomError( + oracle, + "NodeDidNotReport", + ); + }); + it("verifies slashed flag is set correctly after slashing", async function () { + const pastBucket = (await oracle.getCurrentBucketNumber()) - 1n; + const idx = await indexOf(node3.address); + await (await oracle.connect(slasher).slashNode(node3.address, pastBucket, idx)).wait(); + const [price, slashed] = await oracle.getAddressDataAtBucket(node3.address, pastBucket); + expect(price).to.equal(1200); + expect(slashed).to.equal(true); + }); + it("reverts for exact 10% deviation threshold (should not slash)", async function () { + // Average is 1000, so 10% deviation means 1100 or 900 + // With MAX_DEVIATION_BPS = 1000 (10%), exactly 10% should NOT slash (strict >) + await (await oracle.connect(node1).reportPrice(1000)).wait(); + await (await oracle.connect(node2).reportPrice(1000)).wait(); + // node3 reports 1100, which is exactly 10% deviation from average of 1000 + await (await oracle.connect(node3).reportPrice(1100)).wait(); + const bucketD = await oracle.getCurrentBucketNumber(); + await mineBuckets(1); + const idx3 = await indexOf(node3.address); + // Should revert because exactly 10% is not > 10% + await expect(oracle.connect(slasher).slashNode(node3.address, bucketD, idx3)).to.be.revertedWithCustomError( + oracle, + "NotDeviated", + ); + }); + it("reverts IndexOutOfBounds when index is out of range", async function () { + const pastBucket = (await oracle.getCurrentBucketNumber()) - 1n; + const addresses = await oracle.getNodeAddresses(); + const invalidIndex = addresses.length; // Index out of bounds + await expect( + oracle.connect(slasher).slashNode(node3.address, pastBucket, invalidIndex), + ).to.be.revertedWithCustomError(oracle, "IndexOutOfBounds"); + }); + it("reverts NodeNotAtGivenIndex when index doesn't match address", async function () { + const pastBucket = (await oracle.getCurrentBucketNumber()) - 1n; + const idx1 = await indexOf(node1.address); + // Try to slash node3 but use node1's index + await expect(oracle.connect(slasher).slashNode(node3.address, pastBucket, idx1)).to.be.revertedWithCustomError( + oracle, + "NodeNotAtGivenIndex", + ); + }); + }); + describe("exitNode", function () { + async function indexOf(address: string) { + const arr = await oracle.getNodeAddresses(); + return arr.findIndex(a => a.toLowerCase() === address.toLowerCase()); + } + beforeEach(async function () { + await (await oracle.connect(node1).registerNode(1500, { value: MINIMUM_STAKE })).wait(); + await (await oracle.connect(node2).registerNode(1600, { value: MINIMUM_STAKE })).wait(); + }); + it("reverts before waiting period and exits with effective stake after", async function () { + const idx = await indexOf(node1.address); + await expect(oracle.connect(node1).exitNode(idx)).to.be.revertedWithCustomError(oracle, "WaitingPeriodNotOver"); + await mineBuckets(2); + const effectiveStake = await oracle.getEffectiveStake(node1.address); + const balBefore = await ethers.provider.getBalance(node1.address); + const tx = await oracle.connect(node1).exitNode(idx); + const rcpt = await tx.wait(); + if (!rcpt) throw new Error("no receipt"); + const gasCost = BigInt(rcpt.gasUsed) * BigInt(rcpt.gasPrice); + const balAfter = await ethers.provider.getBalance(node1.address); + // Verify the balance changed by approximately the effective stake minus gas + const balanceChange = balAfter - balBefore; + // The change should be approximately effectiveStake - gasCost + // Allow tolerance for gas estimation differences + expect(balanceChange + gasCost).to.be.closeTo(effectiveStake, ethers.parseEther("0.02")); + // Verify node is removed + const addresses = await oracle.getNodeAddresses(); + expect(addresses).to.not.include(node1.address); + // Verify node is deleted (effectiveStake should be 0 for inactive nodes) + expect(await oracle.getEffectiveStake(node1.address)).to.equal(0); + }); + it("reverts IndexOutOfBounds when index is out of range", async function () { + await mineBuckets(2); + const addresses = await oracle.getNodeAddresses(); + const invalidIndex = addresses.length; // Index out of bounds + await expect(oracle.connect(node1).exitNode(invalidIndex)).to.be.revertedWithCustomError( + oracle, + "IndexOutOfBounds", + ); + }); + it("reverts NodeNotAtGivenIndex when index doesn't match address", async function () { + await mineBuckets(2); + const idx2 = await indexOf(node2.address); + // Try to exit node1 but use node2's index + await expect(oracle.connect(node1).exitNode(idx2)).to.be.revertedWithCustomError(oracle, "NodeNotAtGivenIndex"); + }); + }); + describe("getOutlierNodes", function () { + beforeEach(async function () { + const bucketWindow = Number(await oracle.BUCKET_WINDOW()); + const blockNum = await ethers.provider.getBlockNumber(); + const toNext = (bucketWindow - (blockNum % bucketWindow)) % bucketWindow; + await mine(toNext + 1); + await (await oracle.connect(node1).registerNode(1000, { value: MINIMUM_STAKE })).wait(); + await (await oracle.connect(node2).registerNode(1000, { value: MINIMUM_STAKE })).wait(); + await (await oracle.connect(node3).registerNode(1000, { value: MINIMUM_STAKE })).wait(); + await (await oracle.connect(node4).registerNode(1000, { value: MINIMUM_STAKE })).wait(); + await (await oracle.connect(node5).registerNode(1000, { value: MINIMUM_STAKE })).wait(); + await (await oracle.connect(node6).registerNode(1000, { value: MINIMUM_STAKE })).wait(); + }); + it("returns empty array when no outliers exist", async function () { + // All nodes report the same price in a new bucket + await mineBuckets(1); + await (await oracle.connect(node1).reportPrice(1000)).wait(); + await (await oracle.connect(node2).reportPrice(1000)).wait(); + await (await oracle.connect(node3).reportPrice(1000)).wait(); + await (await oracle.connect(node4).reportPrice(1000)).wait(); + await (await oracle.connect(node5).reportPrice(1000)).wait(); + await (await oracle.connect(node6).reportPrice(1000)).wait(); + await mineBuckets(1); + const bucket = (await oracle.getCurrentBucketNumber()) - 1n; + const outliers = await oracle.getOutlierNodes(bucket); + expect(outliers.length).to.equal(0); + }); + it("returns deviated node addresses", async function () { + // node4 reports 1200 while others report 1000 (average = 1000) + // Deviation = (1200 - 1000) / 1000 = 20% > 10% threshold + await mineBuckets(1); + await (await oracle.connect(node1).reportPrice(1000)).wait(); + await (await oracle.connect(node2).reportPrice(1000)).wait(); + await (await oracle.connect(node3).reportPrice(1000)).wait(); + await (await oracle.connect(node4).reportPrice(1200)).wait(); + await mineBuckets(1); + const bucket = (await oracle.getCurrentBucketNumber()) - 1n; + const outliers = await oracle.getOutlierNodes(bucket); + expect(outliers.length).to.equal(1); + expect(outliers[0]).to.equal(node4.address); + }); + it("excludes nodes that did not report in the bucket", async function () { + // Only node1 and node2 report, node3 doesn't report + await mineBuckets(1); + await (await oracle.connect(node1).reportPrice(1000)).wait(); + await (await oracle.connect(node2).reportPrice(1000)).wait(); + await (await oracle.connect(node4).reportPrice(1200)).wait(); + await mineBuckets(1); + const bucket = (await oracle.getCurrentBucketNumber()) - 1n; + const outliers = await oracle.getOutlierNodes(bucket); + expect(outliers.length).to.equal(1); + expect(outliers[0]).to.equal(node4.address); + expect(outliers).to.not.include(node3.address); + }); + it("handles multiple outliers correctly", async function () { + await mineBuckets(1); + // Set up prices so that only node3 and node4 are outliers + // We need enough "normal" nodes to keep the average stable + await (await oracle.connect(node1).reportPrice(1000)).wait(); + await (await oracle.connect(node2).reportPrice(1000)).wait(); + await (await oracle.connect(node3).reportPrice(1000)).wait(); // Normal + await (await oracle.connect(node4).reportPrice(1500)).wait(); // Outlier (50% from avg of 1000) + await (await oracle.connect(node5).reportPrice(1000)).wait(); + await (await oracle.connect(node6).reportPrice(1000)).wait(); + await mineBuckets(1); + // Now in a new bucket, make node3 an outlier too + await (await oracle.connect(node1).reportPrice(1000)).wait(); + await (await oracle.connect(node2).reportPrice(1000)).wait(); + await (await oracle.connect(node3).reportPrice(1200)).wait(); // Outlier (avg without node3 = (1000+1000+1500)/3 โ‰ˆ 1166, deviation โ‰ˆ 20%) + await (await oracle.connect(node4).reportPrice(1200)).wait(); // Outlier (avg without node4 = (1000+1000+1400)/3 = 1133, deviation โ‰ˆ 32%) + await (await oracle.connect(node5).reportPrice(1000)).wait(); + await (await oracle.connect(node6).reportPrice(1000)).wait(); + await mineBuckets(1); + const bucket = (await oracle.getCurrentBucketNumber()) - 1n; + const outliers = await oracle.getOutlierNodes(bucket); + expect(outliers.length).to.equal(2); + expect(outliers).to.include(node3.address); + expect(outliers).to.include(node4.address); + }); + }); +}); diff --git a/extension/packages/hardhat/test/WhitelistOracle.ts b/extension/packages/hardhat/test/WhitelistOracle.ts new file mode 100644 index 00000000..60f8a244 --- /dev/null +++ b/extension/packages/hardhat/test/WhitelistOracle.ts @@ -0,0 +1,226 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import type { WhitelistOracle, SimpleOracle } from "../typechain-types"; + +describe("Checkpoint1", function () { + before(async () => { + await ethers.provider.send("evm_setAutomine", [true]); + await ethers.provider.send("evm_setIntervalMining", [0]); + }); + + let whitelistOracle: WhitelistOracle; + let owner: HardhatEthersSigner, + addr1: HardhatEthersSigner, + addr2: HardhatEthersSigner, + addr3: HardhatEthersSigner, + addr4: HardhatEthersSigner; + + beforeEach(async function () { + [owner, addr1, addr2, addr3, addr4] = await ethers.getSigners(); + const WhitelistOracleFactory = await ethers.getContractFactory("WhitelistOracle"); + whitelistOracle = await WhitelistOracleFactory.deploy(); + }); + + it("Should deploy and set owner", async function () { + expect(await whitelistOracle.owner()).to.equal(owner.address); + }); + + it("Should allow adding oracles and deploy SimpleOracle contracts", async function () { + await whitelistOracle.addOracle(addr1.address); + + const oracleAddress = await whitelistOracle.oracles(0); + expect(oracleAddress).to.not.equal(ethers.ZeroAddress); + + // Check that the oracle is a SimpleOracle contract + const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle"); + const oracle = SimpleOracleFactory.attach(oracleAddress) as SimpleOracle; + expect(await oracle.owner()).to.equal(addr1.address); + }); + + it("Should allow removing oracles by index", async function () { + await whitelistOracle.addOracle(addr1.address); + await whitelistOracle.addOracle(addr2.address); + + const oracle1Address = await whitelistOracle.oracles(0); + + await whitelistOracle.removeOracle(0); + + // After removal, the oracle at index 0 should be different (swapped from end) + const newOracle0Address = await whitelistOracle.oracles(0); + expect(newOracle0Address).to.not.equal(oracle1Address); + + // Should only have one oracle left + await expect(whitelistOracle.oracles(1)).to.be.reverted; + }); + + it("Should emit OracleAdded event when an oracle is added", async function () { + const tx = await whitelistOracle.addOracle(addr1.address); + await tx.wait(); + const oracleAddress = await whitelistOracle.oracles(0); + + expect(tx).to.emit(whitelistOracle, "OracleAdded").withArgs(oracleAddress, addr1.address); + }); + + it("Should emit OracleRemoved event when an oracle is removed", async function () { + await whitelistOracle.addOracle(addr1.address); + const oracleAddress = await whitelistOracle.oracles(0); + + await expect(whitelistOracle.removeOracle(0)).to.emit(whitelistOracle, "OracleRemoved").withArgs(oracleAddress); + }); + + it("Should revert with IndexOutOfBounds when trying to remove non-existent oracle", async function () { + await expect(whitelistOracle.removeOracle(0)).to.be.revertedWithCustomError(whitelistOracle, "IndexOutOfBounds"); + + await whitelistOracle.addOracle(addr1.address); + await expect(whitelistOracle.removeOracle(1)).to.be.revertedWithCustomError(whitelistOracle, "IndexOutOfBounds"); + + await whitelistOracle.removeOracle(0); + await expect(whitelistOracle.removeOracle(0)).to.be.revertedWithCustomError(whitelistOracle, "IndexOutOfBounds"); + }); + + it("Should revert with NoOraclesAvailable when getPrice is called with no oracles", async function () { + await expect(whitelistOracle.getPrice()).to.be.revertedWithCustomError(whitelistOracle, "NoOraclesAvailable"); + }); + + it("Should return correct price with one oracle", async function () { + await whitelistOracle.addOracle(addr1.address); + const oracleAddress = await whitelistOracle.oracles(0); + + const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle"); + const oracle = SimpleOracleFactory.attach(oracleAddress) as SimpleOracle; + + await oracle.setPrice(1000n); + + const price = await whitelistOracle.getPrice(); + expect(price).to.equal(1000n); + }); + + it("Should return correct median price with odd number of oracles", async function () { + await whitelistOracle.addOracle(addr1.address); + await whitelistOracle.addOracle(addr2.address); + await whitelistOracle.addOracle(addr3.address); + + const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle"); + const oracle1 = SimpleOracleFactory.attach(await whitelistOracle.oracles(0)) as SimpleOracle; + const oracle2 = SimpleOracleFactory.attach(await whitelistOracle.oracles(1)) as SimpleOracle; + const oracle3 = SimpleOracleFactory.attach(await whitelistOracle.oracles(2)) as SimpleOracle; + + await oracle1.setPrice(1000n); + await oracle2.setPrice(3000n); + await oracle3.setPrice(2000n); + + const medianPrice = await whitelistOracle.getPrice(); + expect(medianPrice).to.equal(2000n); + }); + + it("Should return correct median price with even number of oracles", async function () { + await whitelistOracle.addOracle(addr1.address); + await whitelistOracle.addOracle(addr2.address); + await whitelistOracle.addOracle(addr3.address); + await whitelistOracle.addOracle(addr4.address); + + const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle"); + const oracle1 = SimpleOracleFactory.attach(await whitelistOracle.oracles(0)) as SimpleOracle; + const oracle2 = SimpleOracleFactory.attach(await whitelistOracle.oracles(1)) as SimpleOracle; + const oracle3 = SimpleOracleFactory.attach(await whitelistOracle.oracles(2)) as SimpleOracle; + const oracle4 = SimpleOracleFactory.attach(await whitelistOracle.oracles(3)) as SimpleOracle; + + await oracle1.setPrice(1000n); + await oracle2.setPrice(3000n); + await oracle3.setPrice(2000n); + await oracle4.setPrice(4000n); + + const medianPrice = await whitelistOracle.getPrice(); + expect(medianPrice).to.equal(2500n); + }); + + it("Should exclude price reports older than 24 seconds from median calculation", async function () { + await whitelistOracle.addOracle(addr1.address); + await whitelistOracle.addOracle(addr2.address); + await whitelistOracle.addOracle(addr3.address); + + const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle"); + const oracle1 = SimpleOracleFactory.attach(await whitelistOracle.oracles(0)) as SimpleOracle; + const oracle2 = SimpleOracleFactory.attach(await whitelistOracle.oracles(1)) as SimpleOracle; + const oracle3 = SimpleOracleFactory.attach(await whitelistOracle.oracles(2)) as SimpleOracle; + + await oracle1.setPrice(1000n); + await oracle2.setPrice(2000n); + await oracle3.setPrice(3000n); + + let medianPrice = await whitelistOracle.getPrice(); + expect(medianPrice).to.equal(2000n); + + // Advance time by 25 seconds (more than STALE_DATA_WINDOW of 24 seconds) + await ethers.provider.send("evm_increaseTime", [25]); + await ethers.provider.send("evm_mine"); + + // Set new prices for only two oracles (the old prices should be stale) + await oracle1.setPrice(5000n); + await oracle2.setPrice(3000n); + + // Should only use the two fresh prices: median of [5000, 3000] = 4000 + medianPrice = await whitelistOracle.getPrice(); + expect(medianPrice).to.equal(4000n); + }); + + it("Should return empty array when no oracles are active", async function () { + const activeNodes = await whitelistOracle.getActiveOracleNodes(); + expect(activeNodes.length).to.equal(0); + }); + + it("Should return correct active oracle nodes", async function () { + await whitelistOracle.addOracle(addr1.address); + await whitelistOracle.addOracle(addr2.address); + + const oracle1Address = await whitelistOracle.oracles(0); + const oracle2Address = await whitelistOracle.oracles(1); + + const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle"); + const oracle1 = SimpleOracleFactory.attach(oracle1Address) as SimpleOracle; + const oracle2 = SimpleOracleFactory.attach(oracle2Address) as SimpleOracle; + + await oracle1.setPrice(1000n); + await oracle2.setPrice(2000n); + + let activeNodes = await whitelistOracle.getActiveOracleNodes(); + expect(activeNodes.length).to.equal(2); + expect(activeNodes).to.include(oracle1Address); + expect(activeNodes).to.include(oracle2Address); + + // Make oracle1's price stale + await ethers.provider.send("evm_increaseTime", [25]); + await ethers.provider.send("evm_mine"); + + // Update only oracle2 + await oracle2.setPrice(3000n); + + activeNodes = await whitelistOracle.getActiveOracleNodes(); + expect(activeNodes.length).to.equal(1); + expect(activeNodes[0]).to.equal(oracle2Address); + }); + + it("Should handle edge case when all prices are stale but array is not empty", async function () { + await whitelistOracle.addOracle(addr1.address); + await whitelistOracle.addOracle(addr2.address); + + const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle"); + const oracle1 = SimpleOracleFactory.attach(await whitelistOracle.oracles(0)) as SimpleOracle; + const oracle2 = SimpleOracleFactory.attach(await whitelistOracle.oracles(1)) as SimpleOracle; + + await oracle1.setPrice(1000n); + await oracle2.setPrice(2000n); + + // Verify median works initially + const medianPrice = await whitelistOracle.getPrice(); + expect(medianPrice).to.equal(1500n); + + // Make all prices stale + await ethers.provider.send("evm_increaseTime", [25]); + await ethers.provider.send("evm_mine"); + + const activeNodes = await whitelistOracle.getActiveOracleNodes(); + expect(activeNodes.length).to.equal(0); + }); +}); diff --git a/extension/packages/nextjs/app/api/config/price-variance/route.ts b/extension/packages/nextjs/app/api/config/price-variance/route.ts new file mode 100644 index 00000000..334704a3 --- /dev/null +++ b/extension/packages/nextjs/app/api/config/price-variance/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; + +const CONFIG_PATH = path.join(process.cwd(), "..", "hardhat", "scripts", "oracle-bot", "config.json"); + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { value, nodeAddress } = body; + + if (typeof value !== "number" || value < 0) { + return NextResponse.json({ error: "Value must be a non-negative number" }, { status: 400 }); + } + + // Read current config + const configContent = await fs.promises.readFile(CONFIG_PATH, "utf-8"); + const config = JSON.parse(configContent); + + // Update node-specific config + if (!config.NODE_CONFIGS[nodeAddress]) { + config.NODE_CONFIGS[nodeAddress] = { ...config.NODE_CONFIGS.default }; + } + config.NODE_CONFIGS[nodeAddress].PRICE_VARIANCE = value; + + // Write back to file + await fs.promises.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2)); + + return NextResponse.json({ success: true, value }); + } catch (error) { + console.error("Error updating price variance:", error); + return NextResponse.json({ error: "Failed to update configuration" }, { status: 500 }); + } +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const nodeAddress = searchParams.get("nodeAddress"); + + if (!nodeAddress) { + return NextResponse.json({ error: "nodeAddress parameter is required" }, { status: 400 }); + } + + const configContent = await fs.promises.readFile(CONFIG_PATH, "utf-8"); + const config = JSON.parse(configContent); + const nodeConfig = config.NODE_CONFIGS[nodeAddress] || config.NODE_CONFIGS.default; + + return NextResponse.json({ + value: nodeConfig.PRICE_VARIANCE, + }); + } catch (error) { + console.error("Error reading price variance:", error); + return NextResponse.json({ error: "Failed to read configuration" }, { status: 500 }); + } +} diff --git a/extension/packages/nextjs/app/api/config/skip-probability/route.ts b/extension/packages/nextjs/app/api/config/skip-probability/route.ts new file mode 100644 index 00000000..11cbe044 --- /dev/null +++ b/extension/packages/nextjs/app/api/config/skip-probability/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; + +const CONFIG_PATH = path.join(process.cwd(), "..", "hardhat", "scripts", "oracle-bot", "config.json"); + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { value, nodeAddress } = body; + + if (typeof value !== "number" || value < 0 || value > 1) { + return NextResponse.json({ error: "Value must be a number between 0 and 1" }, { status: 400 }); + } + + // Read current config + const configContent = await fs.promises.readFile(CONFIG_PATH, "utf-8"); + const config = JSON.parse(configContent); + + // Update node-specific config + if (!config.NODE_CONFIGS[nodeAddress]) { + config.NODE_CONFIGS[nodeAddress] = { ...config.NODE_CONFIGS.default }; + } + config.NODE_CONFIGS[nodeAddress].PROBABILITY_OF_SKIPPING_REPORT = value; + + // Write back to file + await fs.promises.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2)); + + return NextResponse.json({ success: true, value }); + } catch (error) { + console.error("Error updating skip probability:", error); + return NextResponse.json({ error: "Failed to update configuration" }, { status: 500 }); + } +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const nodeAddress = searchParams.get("nodeAddress"); + + if (!nodeAddress) { + return NextResponse.json({ error: "nodeAddress parameter is required" }, { status: 400 }); + } + + const configContent = await fs.promises.readFile(CONFIG_PATH, "utf-8"); + const config = JSON.parse(configContent); + const nodeConfig = config.NODE_CONFIGS[nodeAddress] || config.NODE_CONFIGS.default; + + return NextResponse.json({ + value: nodeConfig.PROBABILITY_OF_SKIPPING_REPORT, + }); + } catch (error) { + console.error("Error reading skip probability:", error); + return NextResponse.json({ error: "Failed to read configuration" }, { status: 500 }); + } +} diff --git a/extension/packages/nextjs/app/layout.tsx.args.mjs b/extension/packages/nextjs/app/layout.tsx.args.mjs index a4f42f0f..3a6f23fc 100644 --- a/extension/packages/nextjs/app/layout.tsx.args.mjs +++ b/extension/packages/nextjs/app/layout.tsx.args.mjs @@ -7,9 +7,8 @@ const spaceGrotesk = Space_Grotesk({ }); `; -// CHALLENGE-TODO: Update the metadataOverrides to reflect your challenge export const metadataOverrides = { - title: "YOUR CHALLENGE TITLE | SpeedRunEthereum", + title: "Oracles | SpeedRunEthereum", description: "Built with ๐Ÿ— Scaffold-ETH 2", }; diff --git a/extension/packages/nextjs/app/optimistic/page.tsx b/extension/packages/nextjs/app/optimistic/page.tsx new file mode 100644 index 00000000..fadb869b --- /dev/null +++ b/extension/packages/nextjs/app/optimistic/page.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { NextPage } from "next"; +import { useReadContracts } from "wagmi"; +import { AssertedTable } from "~~/components/oracle/optimistic/AssertedTable"; +import { AssertionModal } from "~~/components/oracle/optimistic/AssertionModal"; +import { DisputedTable } from "~~/components/oracle/optimistic/DisputedTable"; +import { ExpiredTable } from "~~/components/oracle/optimistic/ExpiredTable"; +import { ProposedTable } from "~~/components/oracle/optimistic/ProposedTable"; +import { SettledTable } from "~~/components/oracle/optimistic/SettledTable"; +import { SubmitAssertionButton } from "~~/components/oracle/optimistic/SubmitAssertionButton"; +import { useDeployedContractInfo, useScaffoldReadContract } from "~~/hooks/scaffold-eth"; +import { useChallengeState } from "~~/services/store/challengeStore"; + +// Loading spinner component +const LoadingSpinner = () => ( +
+
+
+); + +const Home: NextPage = () => { + const setRefetchAssertionStates = useChallengeState(state => state.setRefetchAssertionStates); + const [isInitialLoading, setIsInitialLoading] = useState(true); + + const { data: nextAssertionId, isLoading: isLoadingNextAssertionId } = useScaffoldReadContract({ + contractName: "OptimisticOracle", + functionName: "nextAssertionId", + query: { + placeholderData: (previousData: any) => previousData, + }, + }); + + // get deployed contract address + const { data: deployedContractAddress, isLoading: isLoadingDeployedContract } = useDeployedContractInfo({ + contractName: "OptimisticOracle", + }); + + // Create contracts array to get state for all assertions from 1 to nextAssertionId-1 + const assertionContracts = nextAssertionId + ? Array.from({ length: Number(nextAssertionId) - 1 }, (_, i) => ({ + address: deployedContractAddress?.address as `0x${string}`, + abi: deployedContractAddress?.abi, + functionName: "getState", + args: [BigInt(i + 1)], + })).filter(contract => contract.address && contract.abi) + : []; + + const { + data: assertionStates, + refetch: refetchAssertionStates, + isLoading: isLoadingAssertionStates, + } = useReadContracts({ + contracts: assertionContracts, + query: { + placeholderData: (previousData: any) => previousData, + }, + }); + + // Set the refetch function in the global store + useEffect(() => { + if (refetchAssertionStates) { + setRefetchAssertionStates(refetchAssertionStates); + } + }, [refetchAssertionStates, setRefetchAssertionStates]); + + // Map assertion IDs to their states and filter out expired ones (state 5) + const assertionStateMap = + nextAssertionId && assertionStates + ? Array.from({ length: Number(nextAssertionId) - 1 }, (_, i) => ({ + assertionId: i + 1, + state: (assertionStates[i]?.result as number) || 0, // Default to 0 (Invalid) if no result + })) + : []; + + // Track when initial loading is complete + const isFirstLoading = + isInitialLoading && (isLoadingNextAssertionId || isLoadingAssertionStates || isLoadingDeployedContract); + + // Mark as initially loaded when all data is available + useEffect(() => { + if (isInitialLoading && !isLoadingNextAssertionId && !isLoadingDeployedContract && !isLoadingAssertionStates) { + setIsInitialLoading(false); + } + }, [isInitialLoading, isLoadingNextAssertionId, isLoadingDeployedContract, isLoadingAssertionStates]); + + return ( +
+ {/* Show loading spinner only during initial load */} + {isFirstLoading ? ( + + ) : ( + <> + {/* Submit Assertion Button with Modal */} + + + {/* Tables */} +

Asserted

+ assertion.state === 1)} /> +

Proposed

+ assertion.state === 2)} /> +

Disputed

+ assertion.state === 3)} /> +

Settled

+ assertion.state === 4)} /> +

Expired

+ assertion.state === 5)} /> + + )} + + +
+ ); +}; + +export default Home; diff --git a/extension/packages/nextjs/app/page.tsx.args.mjs b/extension/packages/nextjs/app/page.tsx.args.mjs index 2389922f..c8c21042 100644 --- a/extension/packages/nextjs/app/page.tsx.args.mjs +++ b/extension/packages/nextjs/app/page.tsx.args.mjs @@ -1,21 +1,32 @@ -// CHALLENGE-TODO: imports and other pre-content -// export const preContent = ``; +export const preContent = `import Image from "next/image";`; -// CHALLENGE-TODO: Update the title, description, and deliverable to reflect your challenge export const description = `

- CHALLENGE TITLE + Oracles

+ challenge banner

- CHALLENGE DESCRIPTION + ๐Ÿ”ฎ Build your own decentralized oracle network! In this challenge, you'll explore different + oracle architectures and implementations. You'll dive deep into concepts like staking + mechanisms, consensus algorithms, slashing conditions, and dispute resolution โ€“ all crucial + components of a robust oracle system.

- ๐ŸŒŸ The final deliverable is an app that CHALLENGE DELIVERABLE. Deploy your contracts to a - testnet then build and upload your app to a public web server. Submit the url on{" "} + ๐ŸŒŸ The final deliverable is a comprehensive understanding of oracle architectures through hands-on + implementation. You'll explore two existing oracle systems (Whitelist and Staking) to + understand their mechanics, then implement the Optimistic Oracle from scratch. Deploy your optimistic + oracle to a testnet and demonstrate how it handles assertions, proposals, disputes, and settlements. + Then build and upload your app to a public web server. Submit the url on{" "} SpeedRunEthereum.com {" "} @@ -27,5 +38,4 @@ export const description = `

`; -// CHALLENGE-TODO: Update the externalExtensionName to reflect your challenge -export const externalExtensionName = "SpeedRunEthereum CHALLENGE TITLE"; +export const externalExtensionName = "SpeedRunEthereum Oracles"; diff --git a/extension/packages/nextjs/app/staking/page.tsx b/extension/packages/nextjs/app/staking/page.tsx new file mode 100644 index 00000000..667dd856 --- /dev/null +++ b/extension/packages/nextjs/app/staking/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import type { NextPage } from "next"; +import { useState } from "react"; +import { BucketCountdown } from "~~/components/oracle/BucketCountdown"; +import { NodesTable } from "~~/components/oracle/NodesTable"; +import { PriceWidget } from "~~/components/oracle/PriceWidget"; +import { TotalSlashedWidget } from "~~/components/oracle/TotalSlashedWidget"; + + +const Home: NextPage = () => { + const [selectedBucket, setSelectedBucket] = useState("current"); + + return ( + <> +
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+ + ); +}; + +export default Home; diff --git a/extension/packages/nextjs/app/whitelist/page.tsx b/extension/packages/nextjs/app/whitelist/page.tsx new file mode 100644 index 00000000..601d594c --- /dev/null +++ b/extension/packages/nextjs/app/whitelist/page.tsx @@ -0,0 +1,26 @@ +"use client"; + +import type { NextPage } from "next"; +import { PriceWidget } from "~~/components/oracle/PriceWidget"; +import { WhitelistTable } from "~~/components/oracle/whitelist/WhitelistTable"; + +const Home: NextPage = () => { + return ( + <> +
+
+
+
+ +
+
+ +
+
+
+
+ + ); +}; + +export default Home; diff --git a/extension/packages/nextjs/components/Header.tsx.args.mjs b/extension/packages/nextjs/components/Header.tsx.args.mjs index 494776c9..78479c8e 100644 --- a/extension/packages/nextjs/components/Header.tsx.args.mjs +++ b/extension/packages/nextjs/components/Header.tsx.args.mjs @@ -1,15 +1,18 @@ -// CHALLENGE-TODO: change for your imports -export const preContent = `import { BanknotesIcon } from "@heroicons/react/24/outline";`; - -// CHALLENGE-TODO: change for your extra menu links export const extraMenuLinksObjects = [ { - label: "Example", - href: "/example", - icon: '$$$$', + label: "Whitelist", + href: "/whitelist", + }, + { + label: "Staking", + href: "/staking", + }, + { + label: "Optimistic", + href: "/optimistic", }, ]; export const logoTitle = "SRE Challenges"; -// CHALLENGE-TODO: Update the logoSubtitle to reflect your challenge title -export const logoSubtitle = "YOUR CHALLENGE TITLE"; + +export const logoSubtitle = "Oracles"; diff --git a/extension/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx.args.mjs b/extension/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx.args.mjs new file mode 100644 index 00000000..b377c303 --- /dev/null +++ b/extension/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx.args.mjs @@ -0,0 +1,3 @@ +// Reference the example args file: https://github.com/scaffold-eth/create-eth-extensions/blob/example/extension/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx.args.mjs + +export const globalClassNames = "font-space-grotesk"; diff --git a/extension/packages/nextjs/components/TooltipInfo.tsx b/extension/packages/nextjs/components/TooltipInfo.tsx new file mode 100644 index 00000000..b947b19f --- /dev/null +++ b/extension/packages/nextjs/components/TooltipInfo.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; + +interface TooltipInfoProps { + top?: number; + right?: number; + infoText: string; + className?: string; +} + +// Note: The relative positioning is required for the tooltip to work. +const TooltipInfo: React.FC = ({ top, right, infoText, className = "" }) => { + const baseClasses = "tooltip tooltip-secondary font-normal [--radius-field:0.25rem]"; + const tooltipClasses = className ? `${baseClasses} ${className}` : `${baseClasses} tooltip-right`; + + if (top !== undefined && right !== undefined) { + return ( + +
+ +
+
+ ); + } + + return ( +
+ +
+ ); +}; + +export default TooltipInfo; diff --git a/extension/packages/nextjs/components/oracle/BucketCountdown.tsx b/extension/packages/nextjs/components/oracle/BucketCountdown.tsx new file mode 100644 index 00000000..319b44ac --- /dev/null +++ b/extension/packages/nextjs/components/oracle/BucketCountdown.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef, useState } from "react"; +import TooltipInfo from "../TooltipInfo"; +import { usePublicClient } from "wagmi"; +import { useScaffoldReadContract } from "~~/hooks/scaffold-eth"; + +export const BucketCountdown = () => { + const publicClient = usePublicClient(); + const { data: bucketWindow } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "BUCKET_WINDOW", + }) as { data: bigint | undefined }; + + const [remainingSec, setRemainingSec] = useState(null); + const [currentBucketNum, setCurrentBucketNum] = useState(null); + const lastBucketCheckTime = useRef(0); + + // Poll getCurrentBucketNumber every second for accuracy + const { data: contractBucketNum } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getCurrentBucketNumber", + watch: true, + }) as { data: bigint | undefined }; + + useEffect(() => { + if (contractBucketNum !== undefined) { + setCurrentBucketNum(contractBucketNum); + lastBucketCheckTime.current = Date.now(); + } + }, [contractBucketNum]); + + useEffect(() => { + if (!bucketWindow || !publicClient || !currentBucketNum) return; + let mounted = true; + const update = async () => { + try { + const block = await publicClient.getBlock(); + const blockNum = Number(block.number); + const w = Number(bucketWindow); + if (w <= 0) { + setRemainingSec(null); + return; + } + + // Calculate blocks remaining in current bucket + // Bucket number = (block.number / BUCKET_WINDOW) + 1 + // So current bucket started at: (currentBucketNum - 1) * BUCKET_WINDOW + const bucketStartBlock = (Number(currentBucketNum) - 1) * w; + const nextBucketBlock = bucketStartBlock + w; + const blocksRemaining = nextBucketBlock - blockNum; + + // Add 3 second offset since node is ahead of system time + const estimatedSecondsRemaining = Math.max(0, blocksRemaining + 3); + + if (mounted) setRemainingSec(estimatedSecondsRemaining); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + // ignore + } + }; + update(); + const id = setInterval(update, 1000); + return () => { + mounted = false; + clearInterval(id); + }; + }, [bucketWindow, publicClient, currentBucketNum]); + + return ( +
+

Bucket Countdown

+
+ +
+
Bucket #{currentBucketNum?.toString() ?? "..."}
+
{remainingSec !== null ? `${remainingSec}s` : "..."}
+
until next bucket
+
+
+
+ ); +}; diff --git a/extension/packages/nextjs/components/oracle/ConfigSlider.tsx b/extension/packages/nextjs/components/oracle/ConfigSlider.tsx new file mode 100644 index 00000000..7653fac9 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/ConfigSlider.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; + +interface ConfigSliderProps { + nodeAddress: string; + endpoint: string; + label: string; +} + +export const ConfigSlider = ({ nodeAddress, endpoint, label }: ConfigSliderProps) => { + const [value, setValue] = useState(0.0); + const [isLoading, setIsLoading] = useState(false); + const [localValue, setLocalValue] = useState(0.0); + + // Fetch initial value + useEffect(() => { + const fetchValue = async () => { + try { + const response = await fetch(`/api/config/${endpoint}?nodeAddress=${nodeAddress}`); + const data = await response.json(); + if (data.value !== undefined) { + setValue(data.value); + setLocalValue(data.value); + } + } catch (error) { + console.error(`Error fetching ${endpoint}:`, error); + } + }; + fetchValue(); + }, [nodeAddress, endpoint]); + + const handleChange = (newValue: number) => { + setLocalValue(newValue); + }; + + const handleFinalChange = async () => { + if (localValue === value) return; // Don't send request if value hasn't changed + + setIsLoading(true); + try { + const response = await fetch(`/api/config/${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ value: localValue, nodeAddress }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || `Failed to update ${endpoint}`); + } + setValue(localValue); // Update the committed value after successful API call + } catch (error) { + console.error(`Error updating ${endpoint}:`, error); + setLocalValue(value); // Reset to last known good value on error + } finally { + setIsLoading(false); + } + }; + + return ( + + handleChange(parseFloat(e.target.value))} + onMouseUp={handleFinalChange} + onTouchEnd={handleFinalChange} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700" + /> +
+ {(localValue * 100).toFixed(0)}% {label} +
+ {isLoading && ( +
+
+
+ )} + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/EditableCell.tsx b/extension/packages/nextjs/components/oracle/EditableCell.tsx new file mode 100644 index 00000000..1dde18ae --- /dev/null +++ b/extension/packages/nextjs/components/oracle/EditableCell.tsx @@ -0,0 +1,148 @@ +import { useEffect, useRef, useState } from "react"; +import { HighlightedCell } from "./HighlightedCell"; +import { parseEther } from "viem"; +import { useWriteContract } from "wagmi"; +import { ArrowPathIcon, PencilIcon } from "@heroicons/react/24/outline"; +import { SIMPLE_ORACLE_ABI } from "~~/utils/constants"; +import { notification } from "~~/utils/scaffold-eth"; + +type EditableCellProps = { + value: string | number; + address: string; + highlightColor?: string; +}; + +export const EditableCell = ({ value, address, highlightColor = "" }: EditableCellProps) => { + const [isEditing, setIsEditing] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [editValue, setEditValue] = useState(Number(value.toString()) || ""); + const inputRef = useRef(null); + + const { writeContractAsync } = useWriteContract(); + + // Update edit value when prop value changes + useEffect(() => { + if (!isEditing) { + setEditValue(Number(value.toString()) || ""); + } + }, [value, isEditing]); + + // Focus input when editing starts + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleSubmit = async () => { + const parsedValue = Number(editValue); + + if (isNaN(parsedValue)) { + notification.error("Invalid number"); + return; + } + + try { + await writeContractAsync({ + abi: SIMPLE_ORACLE_ABI, + address: address, + functionName: "setPrice", + args: [parseEther(parsedValue.toString())], + }); + setIsEditing(false); + } catch (error) { + console.error("Submit failed:", error); + } + }; + + // Resubmits the currently displayed value without entering edit mode + const handleRefresh = async () => { + const parsedValue = Number(value.toString()); + if (isNaN(parsedValue)) { + notification.error("Invalid number"); + return; + } + try { + await writeContractAsync({ + abi: SIMPLE_ORACLE_ABI, + address: address, + functionName: "setPrice", + args: [parseEther(parsedValue.toString())], + }); + } catch (error) { + console.error("Refresh failed:", error); + } + }; + + const handleCancel = () => { + setIsEditing(false); + }; + + const startEditing = () => { + setIsEditing(true); + }; + + return ( + +
+ {/* 70% width for value display/editing */} +
+ {isEditing ? ( +
+ setEditValue(e.target.value)} + className="w-full text-sm bg-secondary rounded-md" + /> +
+ ) : ( +
+ {value} +
+ + +
+
+ )} +
+ + {/* 30% width for action buttons */} +
+ {isEditing && ( +
+ + +
+ )} +
+
+
+ ); +}; diff --git a/extension/packages/nextjs/components/oracle/HighlightedCell.tsx b/extension/packages/nextjs/components/oracle/HighlightedCell.tsx new file mode 100644 index 00000000..08a5c3bf --- /dev/null +++ b/extension/packages/nextjs/components/oracle/HighlightedCell.tsx @@ -0,0 +1,41 @@ +import { useEffect, useRef, useState } from "react"; + +export const HighlightedCell = ({ + value, + highlightColor, + children, + className, + handleClick, +}: { + value: string | number; + highlightColor: string; + children: React.ReactNode; + className?: string; + handleClick?: () => void; +}) => { + const [isHighlighted, setIsHighlighted] = useState(false); + const prevValue = useRef(undefined); + + useEffect(() => { + if (value === undefined) return; + if (value === "Not reported") return; + if (value === "Loading...") return; + const hasPrev = typeof prevValue.current === "number" || typeof prevValue.current === "string"; + + if (hasPrev && value !== prevValue.current) { + setIsHighlighted(true); + const timer = setTimeout(() => setIsHighlighted(false), 1000); + return () => clearTimeout(timer); + } + prevValue.current = value; + }, [value]); + + return ( + + {children} + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/NodeRow.tsx b/extension/packages/nextjs/components/oracle/NodeRow.tsx new file mode 100644 index 00000000..30e048a9 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/NodeRow.tsx @@ -0,0 +1,206 @@ +import { useMemo } from "react"; +import { ConfigSlider } from "./ConfigSlider"; +import { NodeRowProps } from "./types"; +import { erc20Abi, formatEther } from "viem"; +import { useReadContract } from "wagmi"; +import { HighlightedCell } from "~~/components/oracle/HighlightedCell"; +import { Address } from "~~/components/scaffold-eth"; +import { useScaffoldReadContract } from "~~/hooks/scaffold-eth"; +import { getHighlightColorForPrice } from "~~/utils/helpers"; + +export interface NodeRowEditRequest { + address: string; + buttonRect: { top: number; left: number; bottom: number; right: number }; +} + +interface NodeRowWithEditProps extends NodeRowProps { + onEditRequest?: (req: NodeRowEditRequest) => void; + isEditing?: boolean; + showInlineSettings?: boolean; +} + +export const NodeRow = ({ address, bucketNumber, showInlineSettings }: NodeRowWithEditProps) => { + // Hooks and contract reads + const { data = [] } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "nodes", + args: [address], + watch: true, + }); + const { data: oracleTokenAddress } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "oracleToken", + }); + const { data: oraBalance } = useReadContract({ + address: oracleTokenAddress as `0x${string}`, + abi: erc20Abi, + functionName: "balanceOf", + args: [address], + query: { enabled: !!oracleTokenAddress, refetchInterval: 5000 }, + }); + const { data: minimumStake } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "MINIMUM_STAKE", + args: undefined, + }); + const { data: currentBucket } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getCurrentBucketNumber", + }) as { data: bigint | undefined }; + + const previousBucket = useMemo( + () => (currentBucket && currentBucket > 0n ? currentBucket - 1n : 0n), + [currentBucket], + ); + + const { data: prevBucketAverage } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getPastPrice", + args: [previousBucket] as any, + }) as { data: bigint | undefined }; + + // Get bucket stats for the selected past bucket (for deviation calculation) + // Temporarily using any to bypass TypeScript until contract is redeployed + const { data: selectedBucketStats } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "timeBuckets", + args: (bucketNumber !== null && bucketNumber !== undefined ? [bucketNumber] : [0n]) as any, + }) as { data?: [bigint, bigint] }; + + const { data: effectiveStake } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getEffectiveStake", + args: [address], + }) as { data: bigint | undefined }; + + // Get current bucket price + const { data: currentBucketPrice } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getAddressDataAtBucket", + args: [address, currentBucket ?? 0n] as const, + watch: true, + }) as { data?: [bigint, boolean] }; + + const reportedPriceInCurrentBucket = currentBucketPrice?.[0]; + + // Past bucket data (always call hook; gate via enabled) + const isCurrentView = bucketNumber === null || bucketNumber === undefined; + + const { data: addressDataAtBucket } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getAddressDataAtBucket", + args: [address, (bucketNumber ?? 0n) as any], + query: { enabled: !isCurrentView }, + }) as { data?: [bigint, boolean] }; + + const pastReportedPrice = !isCurrentView && addressDataAtBucket ? addressDataAtBucket[0] : undefined; + const pastSlashed = !isCurrentView && addressDataAtBucket ? addressDataAtBucket[1] : undefined; + + // Formatting + const stakedAmountFormatted = effectiveStake !== undefined ? Number(formatEther(effectiveStake)) : "Loading..."; + const lastReportedPriceFormatted = + reportedPriceInCurrentBucket !== undefined && reportedPriceInCurrentBucket !== 0n + ? `$${Number(parseFloat(formatEther(reportedPriceInCurrentBucket)).toFixed(2))}` + : "Not reported"; + const oraBalanceFormatted = oraBalance !== undefined ? Number(formatEther(oraBalance)) : "Loading..."; + const isInsufficientStake = + effectiveStake !== undefined && minimumStake !== undefined && effectiveStake < (minimumStake as bigint); + + // Calculate deviation for past buckets + const deviationText = useMemo(() => { + if (isCurrentView) return "โ€”"; + if (!pastReportedPrice || pastReportedPrice === 0n || !bucketNumber) return "โ€”"; + if (!selectedBucketStats || !selectedBucketStats[0] || !selectedBucketStats[1]) return "โ€”"; + + const [countReports, sumPrices] = selectedBucketStats; + + // Exclude this node's price from the average calculation + const adjustedCount = countReports - 1n; + const adjustedSum = sumPrices - pastReportedPrice; + + if (adjustedCount === 0n || adjustedSum === 0n) return "โ€”"; + + const averageWithoutNode = Number(adjustedSum) / Number(adjustedCount); + const price = Number(pastReportedPrice); + + if (averageWithoutNode === 0) return "โ€”"; + const pct = ((price - averageWithoutNode) / averageWithoutNode) * 100; + const sign = pct > 0 ? "+" : ""; + return `${sign}${pct.toFixed(2)}%`; + }, [selectedBucketStats, pastReportedPrice, bucketNumber, isCurrentView]); + + // Deviation for current bucket vs previous bucket average + const currentDeviationText = useMemo(() => { + if (!isCurrentView) return "โ€”"; + if (!reportedPriceInCurrentBucket || reportedPriceInCurrentBucket === 0n) return "โ€”"; + if (!prevBucketAverage || prevBucketAverage === 0n) return "โ€”"; + const avg = Number(prevBucketAverage); + const price = Number(reportedPriceInCurrentBucket); + if (avg === 0) return "โ€”"; + const pct = ((price - avg) / avg) * 100; + const sign = pct > 0 ? "+" : ""; + return `${sign}${pct.toFixed(2)}%`; + }, [isCurrentView, reportedPriceInCurrentBucket, prevBucketAverage]); + + return ( + <> + + +
+ + {showInlineSettings ? ( + // Inline settings mode: only show the settings sliders column + +
+
+ + +
+
+ + ) : isCurrentView ? ( + <> + + ฮž {stakedAmountFormatted} + + + {oraBalanceFormatted} + + 0 && typeof data[1] === "bigint" ? data[1] : 0n, + prevBucketAverage, + )} + className={""} + > + {lastReportedPriceFormatted} + + {currentDeviationText} + + ) : ( + <> + + {pastReportedPrice !== undefined && pastReportedPrice !== 0n + ? `$${Number(parseFloat(formatEther(pastReportedPrice)).toFixed(2))}` + : "Not reported"} + {pastSlashed && Slashed} + + {deviationText} + + )} + + {/* No inline editor row; editor is rendered by parent as floating panel */} + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/NodesTable.tsx b/extension/packages/nextjs/components/oracle/NodesTable.tsx new file mode 100644 index 00000000..6239e7af --- /dev/null +++ b/extension/packages/nextjs/components/oracle/NodesTable.tsx @@ -0,0 +1,482 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import TooltipInfo from "../TooltipInfo"; +import { ConfigSlider } from "./ConfigSlider"; +import { NodeRow, NodeRowEditRequest } from "./NodeRow"; +import { SelfNodeRow } from "./SelfNodeRow"; +import { parseEther } from "viem"; +import { useAccount, usePublicClient } from "wagmi"; +import { Cog6ToothIcon } from "@heroicons/react/24/outline"; +import { + useDeployedContractInfo, + useScaffoldEventHistory, + useScaffoldReadContract, + useScaffoldWriteContract, +} from "~~/hooks/scaffold-eth"; +import { useGlobalState } from "~~/services/store/store"; + +const LoadingRow = ({ colCount = 5 }: { colCount?: number }) => ( + + +
+ + +); +const NoNodesRow = ({ colSpan = 5 }: { colSpan?: number }) => ( + + + No nodes found + + +); + +const SlashAllButton = ({ selectedBucket }: { selectedBucket: bigint }) => { + const publicClient = usePublicClient(); + const { data: stakingDeployment } = useDeployedContractInfo({ contractName: "StakingOracle" }); + const { writeContractAsync: writeStakingOracle } = useScaffoldWriteContract({ contractName: "StakingOracle" }); + const { data: outliers } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getOutlierNodes", + args: [selectedBucket] as any, + watch: true, + }) as { data: string[] | undefined }; + const { data: nodeAddresses } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getNodeAddresses", + watch: true, + }) as { data: string[] | undefined }; + + const [unslashed, setUnslashed] = React.useState([]); + + React.useEffect(() => { + const check = async () => { + if (!outliers || !publicClient || !stakingDeployment) { + setUnslashed([]); + return; + } + const list: string[] = []; + for (const addr of outliers) { + try { + const [, isSlashed] = (await publicClient.readContract({ + address: stakingDeployment.address as `0x${string}`, + abi: stakingDeployment.abi as any, + functionName: "getAddressDataAtBucket", + args: [addr, selectedBucket], + })) as [bigint, boolean]; + if (!isSlashed) list.push(addr); + } catch { + // assume not slashed on read error + list.push(addr); + } + } + setUnslashed(list); + }; + check(); + const id = setInterval(check, 2000); + return () => clearInterval(id); + }, [outliers, selectedBucket, publicClient, stakingDeployment]); + + const handleSlashAll = async () => { + if (!unslashed.length || !nodeAddresses) return; + try { + for (const addr of unslashed) { + const idx = nodeAddresses.findIndex(a => a?.toLowerCase() === addr.toLowerCase()); + if (idx === -1) continue; + try { + await writeStakingOracle({ + functionName: "slashNode", + args: [addr as `0x${string}`, selectedBucket, BigInt(idx)], + }); + } catch { + // continue slashing the rest + } + } + } catch (e: any) { + console.error(e); + } + }; + + return ( + + ); +}; + +export const NodesTable = ({ + selectedBucket: externalSelectedBucket, + onBucketChange, +}: { + selectedBucket?: bigint | "current"; + onBucketChange?: (bucket: bigint | "current") => void; +} = {}) => { + const [editingNode, setEditingNode] = useState<{ address: string; pos: { top: number; left: number } } | null>(null); + const [showInlineSettings, setShowInlineSettings] = useState(false); + const handleEditRequest = (req: NodeRowEditRequest) => { + setEditingNode({ address: req.address, pos: { top: req.buttonRect.bottom + 8, left: req.buttonRect.left } }); + }; + const handleCloseEditor = () => setEditingNode(null); + const { address: connectedAddress } = useAccount(); + const { data: currentBucketData } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getCurrentBucketNumber", + }) as { data: bigint | undefined }; + const currentBucket = currentBucketData ?? undefined; + const [internalSelectedBucket, setInternalSelectedBucket] = useState("current"); + const selectedBucket = externalSelectedBucket ?? internalSelectedBucket; + const setSelectedBucket = (bucket: bigint | "current") => { + setInternalSelectedBucket(bucket); + onBucketChange?.(bucket); + }; + const [animateDir, setAnimateDir] = useState<"left" | "right" | null>(null); + const [animateKey, setAnimateKey] = useState(0); + const [entering, setEntering] = useState(true); + const lastCurrentBucketRef = useRef(null); + const { data: registeredEvents, isLoading: isLoadingRegistered } = useScaffoldEventHistory({ + contractName: "StakingOracle", + eventName: "NodeRegistered", + watch: true, + }); + const { data: exitedEvents, isLoading: isLoadingExited } = useScaffoldEventHistory({ + contractName: "StakingOracle", + eventName: "NodeExited", + watch: true, + }); + const eventDerivedNodeAddresses: string[] = (() => { + const set = new Set(); + (registeredEvents || []).forEach(ev => { + const addr = (ev?.args?.node as string | undefined)?.toLowerCase(); + if (addr) set.add(addr); + }); + (exitedEvents || []).forEach(ev => { + const addr = (ev?.args?.node as string | undefined)?.toLowerCase(); + if (addr) set.delete(addr); + }); + return Array.from(set.values()); + })(); + const hasEverRegisteredSelf = useMemo(() => { + if (!connectedAddress) return false; + const lower = connectedAddress.toLowerCase(); + return (registeredEvents || []).some(ev => { + const addr = (ev?.args?.node as string | undefined)?.toLowerCase(); + return addr === lower; + }); + }, [registeredEvents, connectedAddress]); + useEffect(() => { + if (currentBucket === undefined) return; + const last = lastCurrentBucketRef.current; + // In inline settings mode, keep the UI stable (no animation on bucket changes) + if (showInlineSettings) { + lastCurrentBucketRef.current = currentBucket; + return; + } + if (last !== null && currentBucket > last) { + if (selectedBucket === "current") { + setAnimateDir("left"); + setAnimateKey(k => k + 1); + setEntering(false); + setTimeout(() => setEntering(true), 20); + } + } + lastCurrentBucketRef.current = currentBucket; + }, [currentBucket, selectedBucket, showInlineSettings]); + const changeBucketWithAnimation = (newBucket: bigint | "current", dir: "left" | "right") => { + setAnimateDir(dir); + setAnimateKey(k => k + 1); + setEntering(false); + setSelectedBucket(newBucket); + setTimeout(() => setEntering(true), 20); + }; + const triggerSlide = (dir: "left" | "right") => { + setAnimateDir(dir); + setAnimateKey(k => k + 1); + setEntering(false); + setTimeout(() => setEntering(true), 20); + }; + const tooltipText = + "This table displays registered oracle nodes that provide price data to the system. Nodes are displayed as inactive if they don't have enough ETH staked. You can edit the skip probability and price variance of an oracle node with the slider."; + const { writeContractAsync: writeStakingOracle } = useScaffoldWriteContract({ contractName: "StakingOracle" }); + const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price); + const { data: nodeAddresses } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getNodeAddresses", + watch: true, + }); + const isSelfRegistered = Boolean( + (nodeAddresses as string[] | undefined)?.some( + addr => addr?.toLowerCase() === (connectedAddress || "").toLowerCase(), + ), + ); + const handleRegisterSelf = async () => { + if (!connectedAddress) return; + try { + const initialPrice = nativeCurrencyPrice > 0 ? parseEther(nativeCurrencyPrice.toString()) : 0n; + await writeStakingOracle({ functionName: "registerNode", args: [initialPrice], value: parseEther("1") }); + } catch (e: any) { + console.error(e); + } + }; + const handleClaimRewards = async () => { + if (!connectedAddress) return; + try { + await writeStakingOracle({ functionName: "claimReward" }); + } catch (e: any) { + console.error(e); + } + }; + const handleExitNode = async () => { + if (!connectedAddress) return; + if (!isSelfRegistered) return; + if (!nodeAddresses) return; + const list = nodeAddresses as string[]; + const idx = list.findIndex(addr => addr?.toLowerCase() === connectedAddress.toLowerCase()); + if (idx === -1) return; + try { + await writeStakingOracle({ functionName: "exitNode", args: [BigInt(idx)] }); + } catch (e: any) { + console.error(e); + } + }; + const filteredNodeAddresses = (eventDerivedNodeAddresses || []).filter( + (addr: string) => addr?.toLowerCase() !== (connectedAddress || "").toLowerCase(), + ); + return ( + <> +
+
+
+

Oracle Nodes

+ + + +
+
+
+ {/* Slash button near navigation (left of left arrow) */} + {selectedBucket !== "current" && } + {/* Previous (<) */} + + + {/* Current selected bucket label (non-clickable) */} + + {selectedBucket === "current" + ? currentBucket !== undefined + ? currentBucket.toString() + : "..." + : (selectedBucket as bigint).toString()} + + + {/* Next (>) */} + + + {/* Go to Current button */} + + + {/* Inline settings toggle */} + +
+ {connectedAddress && !isSelfRegistered ? ( + + ) : ( + <> + + + + )} +
+
+
+
+
+ + + + {showInlineSettings ? ( + <> + + + + ) : selectedBucket === "current" ? ( + <> + + + + + + + ) : ( + <> + + + + + )} + + + + {!showInlineSettings && ( + <> + {selectedBucket === "current" ? ( + isSelfRegistered || hasEverRegisteredSelf ? ( + + ) : null + ) : isSelfRegistered || hasEverRegisteredSelf ? ( + + ) : null} + {isSelfRegistered && ( + + + + )} + + )} + {isLoadingRegistered || isLoadingExited ? ( + + ) : filteredNodeAddresses.length === 0 ? ( + + ) : ( + filteredNodeAddresses.map((address: string, index: number) => ( + + )) + )} + +
Node AddressNode SettingsNode AddressStakeORAReported Price +
+ Deviation + +
+
Node AddressReported Price +
+ Deviation + +
+
+
Simulation Script Nodes
+
+
+
+
+
+ {editingNode && ( +
+
+ + +
+ +
+
+
+ )} + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/PriceWidget.tsx b/extension/packages/nextjs/components/oracle/PriceWidget.tsx new file mode 100644 index 00000000..38b7553f --- /dev/null +++ b/extension/packages/nextjs/components/oracle/PriceWidget.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState } from "react"; +import TooltipInfo from "../TooltipInfo"; +import { formatEther } from "viem"; +import { useScaffoldReadContract } from "~~/hooks/scaffold-eth"; + +const getHighlightColor = (oldPrice: bigint | undefined, newPrice: bigint | undefined): string => { + if (oldPrice === undefined || newPrice === undefined) return ""; + + const change = Math.abs(parseFloat(formatEther(newPrice)) - parseFloat(formatEther(oldPrice))); + + if (change < 50) return "bg-success"; + if (change < 100) return "bg-warning"; + return "bg-error"; +}; + +interface PriceWidgetProps { + contractName: "StakingOracle" | "WhitelistOracle"; +} + +export const PriceWidget = ({ contractName }: PriceWidgetProps) => { + const [highlight, setHighlight] = useState(false); + const [highlightColor, setHighlightColor] = useState(""); + const prevPrice = useRef(undefined); + const prevBucket = useRef(null); + const [showBucketLoading, setShowBucketLoading] = useState(false); + + // Poll getCurrentBucketNumber to detect bucket changes + const { data: contractBucketNum } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getCurrentBucketNumber", + watch: true, + }) as { data: bigint | undefined }; + + useEffect(() => { + if (contractBucketNum !== undefined) { + // Check if bucket changed + if (prevBucket.current !== null && contractBucketNum !== prevBucket.current) { + setShowBucketLoading(true); + setTimeout(() => setShowBucketLoading(false), 2000); // Show loading for 2 seconds after bucket change + } + prevBucket.current = contractBucketNum; + } + }, [contractBucketNum]); + + const isStaking = contractName === "StakingOracle"; + const { data: currentPrice, isError } = useScaffoldReadContract({ + contractName, + functionName: isStaking ? ("getLatestPrice" as any) : ("getPrice" as any), + watch: true, + }) as { data: bigint | undefined; isError: boolean; isLoading: boolean }; + + useEffect(() => { + if (currentPrice !== undefined && prevPrice.current !== undefined && currentPrice !== prevPrice.current) { + setHighlightColor(getHighlightColor(prevPrice.current, currentPrice)); + setHighlight(true); + setTimeout(() => { + setHighlight(false); + setHighlightColor(""); + }, 650); + } + prevPrice.current = currentPrice; + }, [currentPrice]); + + return ( +
+

Current Price

+
+ +
+
+ {showBucketLoading ? ( +
+
+
+ ) : isError || currentPrice === undefined || currentPrice === 0n ? ( +
No fresh price
+ ) : ( + {`$${parseFloat(formatEther(currentPrice)).toFixed(2)}`} + )} +
+
+
+
+ ); +}; diff --git a/extension/packages/nextjs/components/oracle/SelfNodeReporter.tsx b/extension/packages/nextjs/components/oracle/SelfNodeReporter.tsx new file mode 100644 index 00000000..d7ce42e9 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/SelfNodeReporter.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { formatEther, parseEther } from "viem"; +import { useAccount } from "wagmi"; +import TooltipInfo from "~~/components/TooltipInfo"; +import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth"; +import { notification } from "~~/utils/scaffold-eth"; + +export const SelfNodeReporter = () => { + const { address: connectedAddress } = useAccount(); + const [stakeAmount, setStakeAmount] = useState("1"); + const [newPrice, setNewPrice] = useState(""); + // Helper to get node index for connected address + const { data: nodeAddresses } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getNodeAddresses", + watch: true, + }); + const [initialPrice, setInitialPrice] = useState(""); + // Add exit node handler + const handleExitNode = async () => { + if (!isRegistered) { + return; + } + if (!nodeAddresses || !connectedAddress) { + return; + } + // Find index of connected address in nodeAddresses + const index = nodeAddresses.findIndex((addr: string) => addr.toLowerCase() === connectedAddress.toLowerCase()); + if (index === -1) { + return; + } + try { + await writeStaking({ functionName: "exitNode", args: [BigInt(index)] }); + } catch (e: any) { + console.error(e); + } + }; + + const { data: nodeData } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "nodes", + args: [connectedAddress ?? "0x0000000000000000000000000000000000000000"] as any, + watch: true, + }); + + // firstBucket is at index 4 of OracleNode struct + const firstBucket = (nodeData?.[4] as bigint | undefined) ?? undefined; + const lastReportedBucket = (nodeData?.[1] as bigint | undefined) ?? undefined; + const stakedAmountRaw = (nodeData?.[0] as bigint | undefined) ?? undefined; + + const { writeContractAsync: writeStaking } = useScaffoldWriteContract({ contractName: "StakingOracle" }); + + const isRegistered = useMemo(() => { + return Boolean(firstBucket && firstBucket > 0n); + }, [firstBucket]); + + // Fetch last reported price using helper view: getAddressDataAtBucket(address, bucket) + const { data: addressDataAtBucket } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getAddressDataAtBucket", + args: [connectedAddress ?? "0x0000000000000000000000000000000000000000", lastReportedBucket ?? 0n] as any, + watch: true, + }); + const lastReportedPrice = (addressDataAtBucket?.[0] as bigint | undefined) ?? undefined; + + const handleStake = async () => { + if (!connectedAddress) { + return; + } + const amount = Number(stakeAmount); + if (isNaN(amount) || amount <= 0) { + return; + } + const priceNum = Number(initialPrice); + if (isNaN(priceNum) || priceNum <= 0) { + notification.error("Enter a valid initial price (USD)"); + return; + } + try { + await writeStaking({ + functionName: "registerNode", + args: [parseEther(priceNum.toString())], + value: parseEther(amount.toString()), + }); + } catch (e: any) { + console.error(e); + } + }; + + const handleReport = async () => { + const price = Number(newPrice); + if (isNaN(price)) { + notification.error("Enter a valid price"); + return; + } + try { + await writeStaking({ functionName: "reportPrice", args: [parseEther(price.toString())] }); + setNewPrice(""); + } catch (e: any) { + console.error(e); + } + }; + + return ( +
+
+
+

My Node

+ +
+
+
+
+
Node Address
+
{connectedAddress ?? "Not connected"}
+
Staked ETH
+
+ {stakedAmountRaw !== undefined ? Number(formatEther(stakedAmountRaw)).toFixed(4) : "โ€”"} +
+
Last Reported Price (USD)
+
+ {lastReportedPrice !== undefined ? Number(formatEther(lastReportedPrice)).toFixed(2) : "โ€”"} +
+
ORA Balance
+
{/* Displayed in NodeRow via ERC20 read; keep simple here */}โ€”
+ {/* Claim rewards and Exit Node buttons (shown if registered) */} + {isRegistered && ( +
+ + {/* Placeholder for Claim Rewards button if/when implemented */} +
+ )} +
+
+ {!isRegistered ? ( +
+
+
Stake Amount (ETH)
+ setStakeAmount(e.target.value)} + /> +
+
+
Initial Price (USD)
+ setInitialPrice(e.target.value)} + /> +
+ +
+ ) : ( +
+
+
Report Price (USD)
+ setNewPrice(e.target.value)} + /> +
+ +
+ )} +
+
+
+ ); +}; diff --git a/extension/packages/nextjs/components/oracle/SelfNodeRow.tsx b/extension/packages/nextjs/components/oracle/SelfNodeRow.tsx new file mode 100644 index 00000000..b90f18a6 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/SelfNodeRow.tsx @@ -0,0 +1,228 @@ +import { useEffect, useMemo, useRef } from "react"; +import { erc20Abi, formatEther, parseEther } from "viem"; +import { useAccount, useReadContract } from "wagmi"; +import { PlusIcon } from "@heroicons/react/24/outline"; +import { HighlightedCell } from "~~/components/oracle/HighlightedCell"; +import { StakingEditableCell } from "~~/components/oracle/StakingEditableCell"; +import { Address } from "~~/components/scaffold-eth"; +import { useDeployedContractInfo, useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth"; +import { getHighlightColorForPrice } from "~~/utils/helpers"; + +type SelfNodeRowProps = { + isStale: boolean; + bucketNumber?: bigint | null; +}; + +export const SelfNodeRow = ({ isStale, bucketNumber }: SelfNodeRowProps) => { + const { address: connectedAddress } = useAccount(); + + const { data: nodeData } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "nodes", + args: [connectedAddress as any], + watch: true, + }); + // OracleNode struct layout: [0]=stakedAmount, [1]=lastReportedBucket, [2]=reportCount, [3]=claimedReportCount, [4]=firstBucket + const stakedAmount = nodeData?.[0] as bigint | undefined; + + const { data: currentBucket } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getCurrentBucketNumber", + }) as { data: bigint | undefined }; + + const previousBucket = currentBucket && currentBucket > 0n ? currentBucket - 1n : 0n; + + const { data: medianPrice } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getPastPrice", + args: [previousBucket] as any, + }) as { data: bigint | undefined }; + + const { data: oracleTokenAddress } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "oracleToken", + }); + + // Registered addresses array; authoritative for current membership + const { data: allNodeAddresses } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getNodeAddresses", + watch: true, + }) as { data: string[] | undefined }; + + const { data: oraBalance } = useReadContract({ + address: oracleTokenAddress as `0x${string}`, + abi: erc20Abi, + functionName: "balanceOf", + args: connectedAddress ? [connectedAddress] : undefined, + query: { enabled: !!oracleTokenAddress && !!connectedAddress, refetchInterval: 5000 }, + }); + + const { writeContractAsync: writeStaking } = useScaffoldWriteContract({ contractName: "StakingOracle" }); + const { data: stakingDeployment } = useDeployedContractInfo({ contractName: "StakingOracle" }); + + const isRegistered = useMemo(() => { + if (!connectedAddress) return false; + if (!allNodeAddresses) return false; + return allNodeAddresses.some(a => a?.toLowerCase() === connectedAddress.toLowerCase()); + }, [allNodeAddresses, connectedAddress]); + + // Use wagmi's useReadContract for enabled gating to avoid reverts when not registered + const { data: effectiveStake } = useReadContract({ + address: (stakingDeployment?.address as `0x${string}`) || undefined, + abi: (stakingDeployment?.abi as any) || undefined, + functionName: "getEffectiveStake", + args: connectedAddress ? [connectedAddress] : undefined, + query: { enabled: !!stakingDeployment?.address && !!connectedAddress && isRegistered, refetchInterval: 5000 }, + }) as { data: bigint | undefined }; + + const stakedAmountFormatted = effectiveStake !== undefined ? Number(formatEther(effectiveStake)) : "Loading..."; + // Current bucket reported price from contract (align with NodeRow) + const { data: currentBucketPrice } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getAddressDataAtBucket", + args: [connectedAddress || "0x0000000000000000000000000000000000000000", currentBucket ?? 0n] as const, + watch: true, + }) as { data?: [bigint, boolean] }; + const reportedPriceInCurrentBucket = currentBucketPrice?.[0]; + const hasReportedThisBucket = reportedPriceInCurrentBucket !== undefined && reportedPriceInCurrentBucket !== 0n; + const lastReportedPriceFormatted = + reportedPriceInCurrentBucket !== undefined && reportedPriceInCurrentBucket !== 0n + ? `$${Number(parseFloat(formatEther(reportedPriceInCurrentBucket)).toFixed(2))}` + : "Not reported"; + const oraBalanceFormatted = oraBalance !== undefined ? Number(formatEther(oraBalance as bigint)) : "Loading..."; + + // Track previous staked amount to determine up/down changes for highlight + const prevStakedAmountRef = useRef(undefined); + const prevStakedAmount = prevStakedAmountRef.current; + let stakeHighlightColor = ""; + if (prevStakedAmount !== undefined && stakedAmount !== undefined && stakedAmount !== prevStakedAmount) { + stakeHighlightColor = stakedAmount > prevStakedAmount ? "bg-success" : "bg-error"; + } + useEffect(() => { + prevStakedAmountRef.current = stakedAmount; + }, [stakedAmount]); + + // Deviation for current bucket vs previous bucket average + const currentDeviationText = useMemo(() => { + if (!reportedPriceInCurrentBucket || reportedPriceInCurrentBucket === 0n) return "โ€”"; + if (!medianPrice || medianPrice === 0n) return "โ€”"; + const avg = Number(medianPrice); + const price = Number(reportedPriceInCurrentBucket); + if (avg === 0) return "โ€”"; + const pct = ((price - avg) / avg) * 100; + const sign = pct > 0 ? "+" : ""; + return `${sign}${pct.toFixed(2)}%`; + }, [reportedPriceInCurrentBucket, medianPrice]); + + const isCurrentView = bucketNumber === null || bucketNumber === undefined; + + // For past buckets, fetch the reported price at that bucket + const { data: pastBucketPrice } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getAddressDataAtBucket", + args: [ + connectedAddress || "0x0000000000000000000000000000000000000000", + !isCurrentView && bucketNumber ? bucketNumber : 0n, + ] as const, + watch: true, + }) as { data?: [bigint, boolean] }; + + const pastReportedPrice = !isCurrentView && pastBucketPrice ? pastBucketPrice[0] : undefined; + const pastSlashed = !isCurrentView && pastBucketPrice ? pastBucketPrice[1] : undefined; + + // Calculate deviation for past bucket + const pastDeviationText = useMemo(() => { + if (isCurrentView) return "โ€”"; + if (!pastReportedPrice || pastReportedPrice === 0n || !bucketNumber) return "โ€”"; + if (!medianPrice || medianPrice === 0n) return "โ€”"; + const avg = Number(medianPrice); + const price = Number(pastReportedPrice); + if (avg === 0) return "โ€”"; + const pct = ((price - avg) / avg) * 100; + const sign = pct > 0 ? "+" : ""; + return `${sign}${pct.toFixed(2)}%`; + }, [isCurrentView, pastReportedPrice, medianPrice, bucketNumber]); + + const handleAddStake = async () => { + if (!connectedAddress) return; + try { + await writeStaking({ functionName: "addStake", value: parseEther("1") }); + } catch (e: any) { + console.error(e); + } + }; + + return ( + + + {connectedAddress ?
: "โ€”"} + + {isCurrentView ? ( + isRegistered ? ( + <> + +
+ ฮž {stakedAmountFormatted} + +
+
+ + {oraBalanceFormatted} + + + {currentDeviationText} + + ) : ( + <> + + ฮž โ€” + + + {oraBalanceFormatted} + + + โ€” + + ) + ) : ( + <> + + {pastReportedPrice !== undefined && pastReportedPrice !== 0n + ? `$${Number(parseFloat(formatEther(pastReportedPrice)).toFixed(2))}` + : "Not reported"} + {pastSlashed && Slashed} + + {pastDeviationText} + + )} + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/StakingEditableCell.tsx b/extension/packages/nextjs/components/oracle/StakingEditableCell.tsx new file mode 100644 index 00000000..b762e2ac --- /dev/null +++ b/extension/packages/nextjs/components/oracle/StakingEditableCell.tsx @@ -0,0 +1,174 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { HighlightedCell } from "./HighlightedCell"; +import { formatEther, parseEther } from "viem"; +import { ArrowPathIcon, PencilIcon } from "@heroicons/react/24/outline"; +import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth"; +import { notification } from "~~/utils/scaffold-eth"; + +type StakingEditableCellProps = { + value: string | number; + nodeAddress: string; + highlightColor?: string; + className?: string; + canEdit?: boolean; + disabled?: boolean; +}; + +export const StakingEditableCell = ({ + value, + nodeAddress, + highlightColor = "", + className = "", + canEdit = true, + disabled = false, +}: StakingEditableCellProps) => { + const [isEditing, setIsEditing] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const coerceToNumber = (val: string | number) => { + if (typeof val === "number") return val; + const numeric = Number(String(val).replace(/[^0-9.\-]/g, "")); + return Number.isFinite(numeric) ? numeric : NaN; + }; + const [editValue, setEditValue] = useState(coerceToNumber(value) || ""); + const inputRef = useRef(null); + + const { writeContractAsync: writeStakingOracle } = useScaffoldWriteContract({ contractName: "StakingOracle" }); + + // Read current bucket and previous bucket average for refresh + const { data: currentBucket } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getCurrentBucketNumber", + }) as { data: bigint | undefined }; + + const previousBucket = useMemo(() => (currentBucket && currentBucket > 0n ? currentBucket - 1n : 0n), [currentBucket]); + + const { data: prevBucketAverage } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getPastPrice", + args: [previousBucket] as any, + }) as { data: bigint | undefined }; + + const hasPrevAvg = typeof prevBucketAverage === "bigint" && prevBucketAverage > 0n; + + useEffect(() => { + if (!isEditing) { + setEditValue(coerceToNumber(value) || ""); + } + }, [value, isEditing]); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleSubmit = async () => { + const parsedValue = Number(editValue); + if (isNaN(parsedValue)) { + notification.error("Invalid number"); + return; + } + try { + await writeStakingOracle({ + functionName: "reportPrice", + args: [parseEther(parsedValue.toString())], + account: nodeAddress as `0x${string}`, + }); + setIsEditing(false); + } catch (error: any) { + console.error(error?.shortMessage || "Failed to update price"); + } + }; + + // Resubmits the average price from the previous bucket + const handleRefresh = async () => { + if (!prevBucketAverage || prevBucketAverage === 0n) { + notification.error("No previous bucket average available"); + return; + } + const avgPrice = Number(formatEther(prevBucketAverage)); + try { + await writeStakingOracle({ + functionName: "reportPrice", + args: [parseEther(avgPrice.toString())], + account: nodeAddress as `0x${string}`, + }); + } catch (error: any) { + console.error(error); + } + }; + + const handleCancel = () => setIsEditing(false); + const startEditing = () => { + if (!canEdit || disabled) return; + setIsEditing(true); + }; + + return ( + +
+
+ {isEditing ? ( +
+ setEditValue(e.target.value)} + className="w-full text-sm bg-secondary rounded-md" + /> +
+ ) : ( +
+ {value} + {canEdit && ( +
+ + +
+ )} +
+ )} +
+
+ {isEditing && ( +
+ + +
+ )} +
+
+
+ ); +}; diff --git a/extension/packages/nextjs/components/oracle/TimeAgo.tsx b/extension/packages/nextjs/components/oracle/TimeAgo.tsx new file mode 100644 index 00000000..069f8d84 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/TimeAgo.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useChallengeState } from "~~/services/store/challengeStore"; + +type TimeAgoProps = { + timestamp?: bigint; + staleWindow?: bigint; + className?: string; +}; + +const formatTimeAgo = (tsSec: number | undefined, nowSec: number): string => { + if (tsSec === undefined) return "โ€”"; + if (tsSec === 0) return "never"; + // Clamp to avoid negative display in rare race conditions + const diffSec = Math.max(0, nowSec - tsSec); + if (diffSec < 60) return `${diffSec}s ago`; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + return `${diffHr}h ago`; +}; + +export const TimeAgo = ({ timestamp, staleWindow, className = "" }: TimeAgoProps) => { + const { timestamp: networkTimestamp } = useChallengeState(); + const [currentTime, setCurrentTime] = useState(() => + networkTimestamp ? Number(networkTimestamp) : Math.floor(Date.now() / 1000), + ); + + useEffect(() => { + const interval = setInterval(() => { + setCurrentTime(prev => prev + 1); + }, 1000); + return () => clearInterval(interval); + }, []); + + const tsSec = typeof timestamp === "bigint" ? Number(timestamp) : timestamp; + const displayNow = currentTime; + const text = formatTimeAgo(tsSec, displayNow); + + // Determine staleness coloring + let colorClass = ""; + if (tsSec === undefined) { + colorClass = ""; + } else if (tsSec === 0) { + colorClass = "text-error"; + } else if (typeof staleWindow === "bigint") { + const isStale = tsSec === undefined ? false : displayNow - tsSec > Number(staleWindow); + colorClass = isStale ? "text-error" : "text-success"; + } + + return {text}; +}; + +export default TimeAgo; diff --git a/extension/packages/nextjs/components/oracle/TotalSlashedWidget.tsx b/extension/packages/nextjs/components/oracle/TotalSlashedWidget.tsx new file mode 100644 index 00000000..e56362bc --- /dev/null +++ b/extension/packages/nextjs/components/oracle/TotalSlashedWidget.tsx @@ -0,0 +1,40 @@ +import { useMemo } from "react"; +import TooltipInfo from "~~/components/TooltipInfo"; +import { useScaffoldEventHistory } from "~~/hooks/scaffold-eth"; +import { formatEther } from "viem"; + +export const TotalSlashedWidget = () => { + const { data: slashedEvents, isLoading } = useScaffoldEventHistory({ + contractName: "StakingOracle", + eventName: "NodeSlashed", + watch: true, + }); + + const totalSlashedWei = useMemo(() => { + if (!slashedEvents) return 0n; + return slashedEvents.reduce((acc: bigint, current) => { + const amount = (current?.args?.amount as bigint | undefined) ?? 0n; + return acc + amount; + }, 0n); + }, [slashedEvents]); + + const tooltipText = "Aggregated ETH slashed across all nodes. Sums the amount from every NodeSlashed event."; + + return ( +
+

Total Slashed

+
+ +
+ {isLoading ? ( +
+ ) : ( +
ฮž {Number(formatEther(totalSlashedWei)).toFixed(4)}
+ )} +
+
+
+ ); +}; + + diff --git a/extension/packages/nextjs/components/oracle/optimistic/AssertedRow.tsx b/extension/packages/nextjs/components/oracle/optimistic/AssertedRow.tsx new file mode 100644 index 00000000..e65adbdc --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/AssertedRow.tsx @@ -0,0 +1,50 @@ +import { TimeLeft } from "./TimeLeft"; +import { formatEther } from "viem"; +import { ChevronRightIcon } from "@heroicons/react/24/outline"; +import { useScaffoldReadContract } from "~~/hooks/scaffold-eth"; +import { useChallengeState } from "~~/services/store/challengeStore"; + +export const AssertedRow = ({ assertionId, state }: { assertionId: number; state: number }) => { + const { openAssertionModal } = useChallengeState(); + + const { data: assertionData } = useScaffoldReadContract({ + contractName: "OptimisticOracle", + functionName: "getAssertion", + args: [BigInt(assertionId)], + }); + + if (!assertionData) return null; + + return ( + { + openAssertionModal({ ...assertionData, assertionId, state }); + }} + className={`group border-b border-base-300 cursor-pointer`} + > + {/* Description Column */} + +
{assertionData.description}
+ + + {/* Bond Column */} + {formatEther(assertionData.bond)} ETH + + {/* Reward Column */} + {formatEther(assertionData.reward)} ETH + + {/* Time Left Column */} + + + + + {/* Chevron Column */} + +
+ +
+ + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/optimistic/AssertedTable.tsx b/extension/packages/nextjs/components/oracle/optimistic/AssertedTable.tsx new file mode 100644 index 00000000..c38e7f2f --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/AssertedTable.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { OOTableProps } from "../types"; +import { AssertedRow } from "./AssertedRow"; +import { EmptyRow } from "./EmptyRow"; + +export const AssertedTable = ({ assertions }: OOTableProps) => { + return ( +
+ + {/* Header */} + + + + + + + + + + + + {assertions.length > 0 ? ( + assertions.map(assertion => ( + + )) + ) : ( + + )} + +
DescriptionBondRewardTime Left{/* Icon column */}
+
+ ); +}; diff --git a/extension/packages/nextjs/components/oracle/optimistic/AssertionModal.tsx b/extension/packages/nextjs/components/oracle/optimistic/AssertionModal.tsx new file mode 100644 index 00000000..1bf0d4a6 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/AssertionModal.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useState } from "react"; +import { AssertionWithIdAndState } from "../types"; +import { formatEther } from "viem"; +import { Address } from "~~/components/scaffold-eth"; +import { useScaffoldWriteContract } from "~~/hooks/scaffold-eth"; +import { useChallengeState } from "~~/services/store/challengeStore"; +import { ZERO_ADDRESS } from "~~/utils/scaffold-eth/common"; + +const getStateName = (state: number) => { + switch (state) { + case 0: + return "Invalid"; + case 1: + return "Asserted"; + case 2: + return "Proposed"; + case 3: + return "Disputed"; + case 4: + return "Settled"; + case 5: + return "Expired"; + default: + return "Invalid"; + } +}; + +// Helper function to format timestamp to UTC +const formatTimestamp = (timestamp: bigint | string | number) => { + const timestampNumber = Number(timestamp); + const date = new Date(timestampNumber * 1000); // Convert from seconds to milliseconds + return date.toLocaleString(); +}; + +const Description = ({ assertion }: { assertion: AssertionWithIdAndState }) => { + return ( +
+
+ AssertionId: {assertion.assertionId} +
+ +
+ Description: {assertion.description} +
+ +
+ Bond: {formatEther(assertion.bond)} ETH +
+ +
+ Reward: {formatEther(assertion.reward)} ETH +
+ +
+ Start Time: + UTC: {formatTimestamp(assertion.startTime)} + Timestamp: {assertion.startTime} +
+ +
+ End Time: + UTC: {formatTimestamp(assertion.endTime)} + Timestamp: {assertion.endTime} +
+ + {assertion.proposer !== ZERO_ADDRESS && ( +
+ Proposed Outcome: {assertion.proposedOutcome ? "True" : "False"} +
+ )} + + {assertion.proposer !== ZERO_ADDRESS && ( +
+ Proposer:{" "} +
+
+ )} + + {assertion.disputer !== ZERO_ADDRESS && ( +
+ Disputer:{" "} +
+
+ )} +
+ ); +}; + +export const AssertionModal = () => { + const [isActionPending, setIsActionPending] = useState(false); + const { refetchAssertionStates, openAssertion, closeAssertionModal } = useChallengeState(); + + const isOpen = !!openAssertion; + + const { writeContractAsync: writeOOContractAsync } = useScaffoldWriteContract({ + contractName: "OptimisticOracle", + }); + + const { writeContractAsync: writeDeciderContractAsync } = useScaffoldWriteContract({ + contractName: "Decider", + }); + + const handleAction = async (args: any) => { + if (!openAssertion) return; + + try { + setIsActionPending(true); + if (args.functionName === "settleDispute") { + await writeDeciderContractAsync(args); + } else { + await writeOOContractAsync(args); + } + refetchAssertionStates(); + closeAssertionModal(); + } catch (error) { + console.log(error); + } finally { + setIsActionPending(false); + } + }; + + if (!openAssertion) return null; + + return ( + <> + + + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/optimistic/DisputedRow.tsx b/extension/packages/nextjs/components/oracle/optimistic/DisputedRow.tsx new file mode 100644 index 00000000..c0b22fb7 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/DisputedRow.tsx @@ -0,0 +1,48 @@ +import { ChevronRightIcon } from "@heroicons/react/24/outline"; +import { Address } from "~~/components/scaffold-eth"; +import { useScaffoldReadContract } from "~~/hooks/scaffold-eth"; +import { useChallengeState } from "~~/services/store/challengeStore"; + +export const DisputedRow = ({ assertionId, state }: { assertionId: number; state: number }) => { + const { openAssertionModal } = useChallengeState(); + + const { data: assertionData } = useScaffoldReadContract({ + contractName: "OptimisticOracle", + functionName: "getAssertion", + args: [BigInt(assertionId)], + }); + + if (!assertionData) return null; + + return ( + { + openAssertionModal({ ...assertionData, assertionId, state }); + }} + className={`group border-b border-base-300 cursor-pointer`} + > + {/* Description Column */} + +
{assertionData.description}
+ + + {/* Proposer Column */} + +
+ + + {/* Disputer Column */} + +
+ + + {/* Chevron Column */} + +
+ +
+ + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/optimistic/DisputedTable.tsx b/extension/packages/nextjs/components/oracle/optimistic/DisputedTable.tsx new file mode 100644 index 00000000..6cec3235 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/DisputedTable.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { OOTableProps } from "../types"; +import { DisputedRow } from "./DisputedRow"; +import { EmptyRow } from "./EmptyRow"; + +export const DisputedTable = ({ assertions }: OOTableProps) => { + return ( +
+ + {/* Header */} + + + + + + + + + + + {assertions.length > 0 ? ( + assertions.map(assertion => ( + + )) + ) : ( + + )} + +
DescriptionProposerDisputer{/* Icon column */}
+
+ ); +}; diff --git a/extension/packages/nextjs/components/oracle/optimistic/EmptyRow.tsx b/extension/packages/nextjs/components/oracle/optimistic/EmptyRow.tsx new file mode 100644 index 00000000..6fa91c0e --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/EmptyRow.tsx @@ -0,0 +1,15 @@ +export const EmptyRow = ({ + message = "No assertions match this state.", + colspan = 4, +}: { + message?: string; + colspan?: number; +}) => { + return ( + + + {message} + + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/optimistic/ExpiredRow.tsx b/extension/packages/nextjs/components/oracle/optimistic/ExpiredRow.tsx new file mode 100644 index 00000000..263e77b7 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/ExpiredRow.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { formatEther } from "viem"; +import { Address } from "~~/components/scaffold-eth"; +import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth"; + +export const ExpiredRow = ({ assertionId }: { assertionId: number }) => { + const [isClaiming, setIsClaiming] = useState(false); + + const { data: assertionData } = useScaffoldReadContract({ + contractName: "OptimisticOracle", + functionName: "getAssertion", + args: [BigInt(assertionId)], + }); + + const { writeContractAsync } = useScaffoldWriteContract({ + contractName: "OptimisticOracle", + }); + + const handleClaim = async () => { + setIsClaiming(true); + try { + await writeContractAsync({ + functionName: "claimRefund", + args: [BigInt(assertionId)], + }); + } catch (error) { + console.error(error); + } finally { + setIsClaiming(false); + } + }; + + if (!assertionData) return null; + + return ( + + {/* Description Column */} + {assertionData.description} + + {/* Asserter Column */} + +
+ + + {/* Reward Column */} + {formatEther(assertionData.reward)} ETH + + {/* Claimed Column */} + + {assertionData?.claimed ? ( + + ) : ( + + )} + + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/optimistic/ExpiredTable.tsx b/extension/packages/nextjs/components/oracle/optimistic/ExpiredTable.tsx new file mode 100644 index 00000000..821e27bc --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/ExpiredTable.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { OOTableProps } from "../types"; +import { EmptyRow } from "./EmptyRow"; +import { ExpiredRow } from "./ExpiredRow"; + +export const ExpiredTable = ({ assertions }: OOTableProps) => { + return ( +
+ + {/* Header */} + + + + + + + + + + + {assertions.length > 0 ? ( + assertions.map(assertion => ) + ) : ( + + )} + +
DescriptionAsserterRewardClaim Refund
+
+ ); +}; diff --git a/extension/packages/nextjs/components/oracle/optimistic/LoadingRow.tsx b/extension/packages/nextjs/components/oracle/optimistic/LoadingRow.tsx new file mode 100644 index 00000000..df83e9a2 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/LoadingRow.tsx @@ -0,0 +1,21 @@ +export const LoadingRow = () => { + return ( + + +
+ + + +
+ + + +
+ + + +
+ + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/optimistic/ProposedRow.tsx b/extension/packages/nextjs/components/oracle/optimistic/ProposedRow.tsx new file mode 100644 index 00000000..4cd27927 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/ProposedRow.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { OORowProps } from "../types"; +import { TimeLeft } from "./TimeLeft"; +import { formatEther } from "viem"; +import { ChevronRightIcon } from "@heroicons/react/24/outline"; +import { useScaffoldReadContract } from "~~/hooks/scaffold-eth"; +import { useChallengeState } from "~~/services/store/challengeStore"; + +export const ProposedRow = ({ assertionId, state }: OORowProps) => { + const { openAssertionModal } = useChallengeState(); + const { data: assertionData } = useScaffoldReadContract({ + contractName: "OptimisticOracle", + functionName: "getAssertion", + args: [BigInt(assertionId)], + }); + + if (!assertionData) return null; + + return ( + { + openAssertionModal({ ...assertionData, assertionId, state }); + }} + > + {/* Query Column */} + +
{assertionData?.description}
+ + + {/* Bond Column */} + {formatEther(assertionData?.bond)} ETH + + {/* Proposal Column */} + {assertionData?.proposedOutcome ? "True" : "False"} + + {/* Challenge Period Column */} + + + + + {/* Chevron Column */} + +
+ +
+ + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/optimistic/ProposedTable.tsx b/extension/packages/nextjs/components/oracle/optimistic/ProposedTable.tsx new file mode 100644 index 00000000..091bc860 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/ProposedTable.tsx @@ -0,0 +1,32 @@ +import { OOTableProps } from "../types"; +import { EmptyRow } from "./EmptyRow"; +import { ProposedRow } from "./ProposedRow"; + +export const ProposedTable = ({ assertions }: OOTableProps) => { + return ( +
+ + {/* Header */} + + + + + + + + + + + + {assertions.length > 0 ? ( + assertions.map(assertion => ( + + )) + ) : ( + + )} + +
DescriptionBondProposalTime Left{/* Icon column */}
+
+ ); +}; diff --git a/extension/packages/nextjs/components/oracle/optimistic/SettledRow.tsx b/extension/packages/nextjs/components/oracle/optimistic/SettledRow.tsx new file mode 100644 index 00000000..e696cd75 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/SettledRow.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState } from "react"; +import { SettledRowProps } from "../types"; +import { LoadingRow } from "./LoadingRow"; +import { formatEther } from "viem"; +import { Address } from "~~/components/scaffold-eth"; +import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth"; +import { ZERO_ADDRESS } from "~~/utils/scaffold-eth/common"; + +export const SettledRow = ({ assertionId }: SettledRowProps) => { + const [isClaiming, setIsClaiming] = useState(false); + const { data: assertionData, isLoading } = useScaffoldReadContract({ + contractName: "OptimisticOracle", + functionName: "getAssertion", + args: [BigInt(assertionId)], + }); + + const { writeContractAsync } = useScaffoldWriteContract({ + contractName: "OptimisticOracle", + }); + + if (isLoading) return ; + if (!assertionData) return null; + + const handleClaim = async () => { + try { + setIsClaiming(true); + const functionName = assertionData?.winner === ZERO_ADDRESS ? "claimUndisputedReward" : "claimDisputedReward"; + await writeContractAsync({ + functionName, + args: [BigInt(assertionId)], + }); + } catch (error) { + console.error(error); + } finally { + setIsClaiming(false); + } + }; + + const winner = assertionData?.winner === ZERO_ADDRESS ? assertionData?.proposer : assertionData?.winner; + const outcome = + assertionData?.winner === ZERO_ADDRESS ? assertionData?.proposedOutcome : assertionData?.resolvedOutcome; + + return ( + + {/* Query Column */} + {assertionData?.description} + + {/* Answer Column */} + {outcome ? "True" : "False"} + + {/* Winner Column */} + +
+ + + {/* Reward Column */} + {formatEther(assertionData?.reward)} ETH + + {/* Claimed Column */} + + {assertionData?.claimed ? ( + + ) : ( + + )} + + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/optimistic/SettledTable.tsx b/extension/packages/nextjs/components/oracle/optimistic/SettledTable.tsx new file mode 100644 index 00000000..bf368286 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/SettledTable.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { OOTableProps } from "../types"; +import { EmptyRow } from "./EmptyRow"; +import { SettledRow } from "./SettledRow"; + +export const SettledTable = ({ assertions }: OOTableProps) => { + return ( +
+ + {/* Header */} + + + + + + + + + + + + {assertions.length > 0 ? ( + assertions.map(assertion => ) + ) : ( + + )} + +
DescriptionResultWinnerRewardClaim
+
+ ); +}; diff --git a/extension/packages/nextjs/components/oracle/optimistic/SubmitAssertionButton.tsx b/extension/packages/nextjs/components/oracle/optimistic/SubmitAssertionButton.tsx new file mode 100644 index 00000000..7ace1cda --- /dev/null +++ b/extension/packages/nextjs/components/oracle/optimistic/SubmitAssertionButton.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useState } from "react"; +import { parseEther } from "viem"; +import { usePublicClient } from "wagmi"; +import TooltipInfo from "~~/components/TooltipInfo"; +import { IntegerInput } from "~~/components/scaffold-eth"; +import { useScaffoldWriteContract } from "~~/hooks/scaffold-eth"; +import { useChallengeState } from "~~/services/store/challengeStore"; +import { getRandomQuestion } from "~~/utils/helpers"; +import { notification } from "~~/utils/scaffold-eth"; + +const MINIMUM_ASSERTION_WINDOW = 3; + +const getStartTimestamp = (timestamp: bigint, startInMinutes: string) => { + if (startInMinutes.length === 0) return 0n; + if (Number(startInMinutes) === 0) return 0n; + return timestamp + BigInt(startInMinutes) * 60n; +}; + +const getEndTimestamp = (timestamp: bigint, startTimestamp: bigint, durationInMinutes: string) => { + if (durationInMinutes.length === 0) return 0n; + if (Number(durationInMinutes) === MINIMUM_ASSERTION_WINDOW) return 0n; + if (startTimestamp === 0n) return timestamp + BigInt(durationInMinutes) * 60n; + return startTimestamp + BigInt(durationInMinutes) * 60n; +}; + +interface SubmitAssertionModalProps { + isOpen: boolean; + onClose: () => void; +} + +const SubmitAssertionModal = ({ isOpen, onClose }: SubmitAssertionModalProps) => { + const { timestamp } = useChallengeState(); + const [isLoading, setIsLoading] = useState(false); + const publicClient = usePublicClient(); + + const [description, setDescription] = useState(""); + const [reward, setReward] = useState(""); + const [startInMinutes, setStartInMinutes] = useState(""); + const [durationInMinutes, setDurationInMinutes] = useState(""); + + const { writeContractAsync } = useScaffoldWriteContract({ contractName: "OptimisticOracle" }); + + const handleRandomQuestion = () => { + setDescription(getRandomQuestion()); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (durationInMinutes.length > 0 && Number(durationInMinutes) < MINIMUM_ASSERTION_WINDOW) { + notification.error( + `Duration must be at least ${MINIMUM_ASSERTION_WINDOW} minutes or leave blank to use default value`, + ); + return; + } + + if (Number(reward) === 0) { + notification.error(`Reward must be greater than 0 ETH`); + return; + } + + if (!publicClient) { + notification.error("Public client not found"); + return; + } + + try { + setIsLoading(true); + let recentTimestamp = timestamp; + if (!recentTimestamp) { + const block = await publicClient.getBlock(); + recentTimestamp = block.timestamp; + } + + const startTimestamp = getStartTimestamp(recentTimestamp, startInMinutes); + const endTimestamp = getEndTimestamp(recentTimestamp, startTimestamp, durationInMinutes); + + await writeContractAsync({ + functionName: "assertEvent", + args: [description.trim(), startTimestamp, endTimestamp], + value: parseEther(reward), + }); + // Reset form after successful submission + setDescription(""); + setReward(""); + setStartInMinutes(""); + setDurationInMinutes(""); + onClose(); + } catch (error) { + console.log("Error with submission", error); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + onClose(); + // Reset form when closing + setDescription(""); + setReward(""); + setStartInMinutes(""); + setDurationInMinutes(""); + }; + + if (!isOpen) return null; + const readyToSubmit = description.trim().length > 0 && reward.trim().length > 0; + + return ( + <> + +