๐Ÿ”ฎ Oracle Challenge

readme-oracle

Skills you'll gain
  • Deeply understand how different oracle architectures work and their trade-offs

  • Design and build a whitelist oracle that allows only trusted addresses to submit data

  • Implement a staking-based oracle that rewards validators for providing accurate data

  • Create an optimistic oracle that uses challenge-response mechanisms to resolve disputes

Skill level
Intermediate
Time to complete
4 - 12 hours
Completed by
โ€”

๐Ÿ”— 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, Pyth Network, and UMA Protocol.

๐Ÿ’ฌ Meet other builders working on this challenge and get help in the Oracle Challenge Telegram


Checkpoint 0: ๐Ÿ“ฆ Environment ๐Ÿ“š

๐Ÿ’ป Start your local network (a blockchain emulator in your computer):


npx create-eth@2.0.4 -e challenge-oracles challenge-oracles

cd challenge-oracles

๐Ÿ’ป In the same terminal, start your local network (a blockchain emulator in your computer):


yarn chain

๐Ÿ›ฐ๏ธ In a second terminal window, deploy your contract (locally):


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:


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:


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
function addOracle(address _owner) public onlyOwner {
    SimpleOracle newOracle = new SimpleOracle(_owner);
    address oracleAddress = address(newOracle);

    oracles.push(newOracle);
    emit OracleAdded(oracleAddress, _owner);
}

  1. 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
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);
}

  1. 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
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();
}

  1. 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
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)

  1. Aggregation:

WhitelistOracle โ†’ getPrice() โ†’ [100, 102, 98] โ†’ sort โ†’ [98, 100, 102] โ†’ median(100) โ†’ 100

  1. 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 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.


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

๐Ÿ‘Š 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:


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, Whitelist Oracle

Checkpoint 2: ๐Ÿ’ฐ Staking Oracle - Economic Incentives

๐Ÿงญ Now let's explore a decentralized oracle that uses economic incentives to encourage honest reporting. In this design:

  • Nodes stake ORA, an ERC20 token, to participate.
  • Nodes report a price once per "bucket" (by default a bucket spans 24 blocks).
  • A bucket is only considered โ€œfinalizedโ€ after someone calls recordBucketMedian(bucket), which stores the median for that bucket (past buckets only).
  • Because the oracle data is useful, anyone who wants to digest the data will be incentivized to run the recordBucketMedian function.
  • Slashing decisions compare a node's report against the recorded median.

๐ŸŽฏ 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
function getCurrentBucketNumber() public view returns (uint256) {
    return (block.number / BUCKET_WINDOW) + 1;
}

  1. Implement getNodeAddresses()
  • ๐Ÿ“š This view function returns every registered node address in order (useful for the frontend and for index-based actions)
๐Ÿ’ก Hint: Just return the array
  • The array nodeAddresses is maintained by registerNode and _removeNode which we will implement later
  • Just return nodeAddresses
๐ŸŽฏ Solution
function getNodeAddresses() public view returns (address[] memory) {
    return nodeAddresses;
}

  1. Implement registerNode(uint256 amount)
  • ๐Ÿ—๏ธ This function allows anyone to register as an oracle node by staking ORA tokens (ERC20)

  • โš ๏ธ It should require a minimum stake of MINIMUM_STAKE, otherwise revert with InsufficientStake

  • ๐Ÿงช It should check that the node is not already registered, otherwise revert with NodeAlreadyRegistered

  • ๐Ÿ’ธ It should pull ORA from the user using transferFrom (so the user must approve first)

  • โš™๏ธ It should add the new OracleNode to the nodes mapping with the correct values

  • โž• It should add the node address to the nodeAddresses array

  • ๐Ÿ“ฃ It should emit the NodeRegistered event

๐Ÿ’ก Hint: ERC20 staking
  • Use oracleToken.transferFrom(msg.sender, address(this), amount)
  • If the transfer fails, revert with TransferFailed
  • For the OracleNode struct you should fill it as follows:
    • stakedAmount should be how many tokens they are using to register
    • lastReportedBucket should default to 0 since they haven't reported yet
    • reportCount and claimedReportCount should also start as 0
    • firstBucket should be the current bucket... Didn't we make a method for getting that earlier? ๐Ÿค”
    • active should be true since the node is now registering
๐ŸŽฏ Solution
function registerNode(uint256 amount) public {
    if (amount < MINIMUM_STAKE) revert InsufficientStake();
    if (nodes[msg.sender].active) revert NodeAlreadyRegistered();

    bool success = oracleToken.transferFrom(msg.sender, address(this), amount);
    if (!success) revert TransferFailed();

    nodes[msg.sender] = OracleNode({
        stakedAmount: amount,
        lastReportedBucket: 0,
        reportCount: 0,
        claimedReportCount: 0,
        firstBucket: getCurrentBucketNumber(),
        active: true
    });

    nodeAddresses.push(msg.sender);
    emit NodeRegistered(msg.sender, amount);
}

  1. Implement addStake(uint256 amount)
  • ๐Ÿ’ธ This function lets an active node increase its stake by depositing more ORA

  • โš ๏ธ It should revert with InsufficientStake if amount == 0

  • ๐Ÿ’ฐ It should pull ORA using transferFrom (so the user must approve before calling)

  • ๐Ÿ“ฃ It should emit the StakeAdded event

๐Ÿ’ก Hint: Similar pattern as `registerNode`
  • Validate amount > 0
  • transferFrom into the oracle
  • Increment nodes[msg.sender].stakedAmount
๐ŸŽฏ Solution
function addStake(uint256 amount) public onlyNode {
    if (amount == 0) revert InsufficientStake();

    bool success = oracleToken.transferFrom(msg.sender, address(this), amount);
    if (!success) revert TransferFailed();

    nodes[msg.sender].stakedAmount += amount;
    emit StakeAdded(msg.sender, amount);
}

  1. 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
  • Expected reports should count fully completed buckets since registration (exclude the current bucket)
  • Donโ€™t count a report in the current bucket as a โ€œcompletedโ€ report (itโ€™s still in-flight)
๐ŸŽฏ Solution
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;
    // Expected reports are only for fully completed buckets since registration (exclude current bucket)
    uint256 expectedReports = currentBucket - n.firstBucket;
    // Do not assume future reports; penalize only after a bucket has passed
    uint256 actualReportsCompleted = n.reportCount;
    // Exclude a report made in the current bucket from completed reports to avoid reducing past penalties
    if (n.lastReportedBucket == currentBucket && actualReportsCompleted > 0) {
        actualReportsCompleted -= 1;
    }
    if (actualReportsCompleted >= expectedReports) return n.stakedAmount; // no penalty if on target
    uint256 missed = expectedReports - actualReportsCompleted;
    uint256 penalty = missed * INACTIVITY_PENALTY;
    if (penalty > n.stakedAmount) return 0;
    return n.stakedAmount - penalty;
}

  1. 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 effective 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 report by appending to reporters[] and prices[] for the current bucket

  • ๐Ÿ”„ It should update the node's lastReportedBucket and increment reportCount

  • ๐Ÿ“ฃ It should emit the PriceReported event with the sender, price, and bucket number

๐Ÿ’ก Hint: Keep report indices stable
  • You must push msg.sender into bucket.reporters and price into bucket.prices at the same index
  • Later, slashing relies on that reportIndex
๐ŸŽฏ Solution
function reportPrice(uint256 price) public onlyNode {
    if (price == 0) revert InvalidPrice();
    if (getEffectiveStake(msg.sender) < MINIMUM_STAKE) revert InsufficientStake();

    OracleNode storage node = nodes[msg.sender];
    uint256 currentBucket = getCurrentBucketNumber();
    if (node.lastReportedBucket == currentBucket) revert AlreadyReportedInCurrentBucket();

    BlockBucket storage bucket = blockBuckets[currentBucket];
    bucket.reporters.push(msg.sender);
    bucket.prices.push(price);

    node.lastReportedBucket = currentBucket;
    node.reportCount++;

    emit PriceReported(msg.sender, price, currentBucket);
}

  1. Implement claimReward()
  • ๐Ÿช™ This function allows nodes (active or inactive) to claim accumulated ORA rewards for reports

  • ๐Ÿ” It should compute delta = reportCount - claimedReportCount

  • ๐Ÿ”’ It should revert with NoRewardsAvailable if delta == 0

  • โœ… It should update claimedReportCount before minting (reentrancy-safe ordering)

  • ๐Ÿ’ฐ It should mint delta * REWARD_PER_REPORT ORA tokens

  • ๐Ÿ“ฃ It should emit NodeRewarded(node, amount)

๐Ÿ’ก Hint: Claim only the difference
  • Use claimedReportCount to avoid double-claiming
๐ŸŽฏ Solution
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);
}

  1. Implement recordBucketMedian(uint256 bucketNumber)
  • ๐Ÿ“Œ This function finalizes a bucket by recording the median price for that bucket

  • ๐Ÿšซ It should revert with BucketMedianAlreadyRecorded if medianPrice is already set

  • โฐ It should only allow this function to be called with past buckets, otherwise revert with OnlyPastBucketsAllowed

  • ๐Ÿง  It should compute the median using StatisticsUtils on a memory copy of prices[] (donโ€™t reorder the arrays in storage as the slashing relies on the ordering!)

  • ๐Ÿ“ฃ It should emit BucketMedianRecorded(bucketNumber, medianPrice)

๐Ÿ’ก Hint: Median finalization
  • Median: sort a memory copy of bucket.prices then use the getMedian method on that sorted array
  • StatisticsUtils have already been imported and applied to uint256 arrays so you can access those methods from the array (arr.sort(), arr.getMedian())
๐ŸŽฏ Solution
function recordBucketMedian(uint256 bucketNumber) public {
    BlockBucket storage bucket = blockBuckets[bucketNumber];
    if (bucket.medianPrice != 0) revert BucketMedianAlreadyRecorded();
    if (bucketNumber >= getCurrentBucketNumber()) revert OnlyPastBucketsAllowed();

    uint256[] memory prices = bucket.prices;
    prices.sort();
    bucket.medianPrice = prices.getMedian();

    emit BucketMedianRecorded(bucketNumber, bucket.medianPrice);
}

  1. Implement getLatestPrice()
  • ๐Ÿ“ฆ This view function returns the finalized price (recorded median) for the most recent completed bucket

  • โ›”๏ธ It should revert with MedianNotRecorded if the bucket has not been finalized

๐Ÿ’ก Hint: Which bucket is โ€œlatestโ€
  • Latest price should read from getCurrentBucketNumber() - 1
๐ŸŽฏ Solution
function getLatestPrice() public view returns (uint256) {
    BlockBucket storage bucket = blockBuckets[getCurrentBucketNumber() - 1];
    if (bucket.medianPrice == 0) revert MedianNotRecorded();
    return bucket.medianPrice;
}

  1. Implement getPastPrice(uint256 bucketNumber)
  • ๐Ÿ•ฐ๏ธ This view function returns the finalized price (recorded median) for any historical bucket

  • โ›”๏ธ It should revert with MedianNotRecorded if that bucket has not been finalized

๐Ÿ’ก Hint: This is very similar to `getLatestPrice()`
  • Instead of getCurrentBucketNumber() - 1, use the provided bucketNumber
๐ŸŽฏ Solution
function getPastPrice(uint256 bucketNumber) public view returns (uint256) {
    BlockBucket storage bucket = blockBuckets[bucketNumber];
    if (bucket.medianPrice == 0) revert MedianNotRecorded();
    return bucket.medianPrice;
}

  1. Implement getSlashedStatus(address nodeAddress, uint256 bucketNumber)
  • ๐Ÿ”Ž This view function returns the price a node reported in a bucket and whether they were slashed there

  • ๐Ÿ—ก๏ธ This is just a convenience method to help onlookers check for slashable nodes

๐Ÿ’ก Hint: Find the node in `bucket.reporters[]`
  • Loop through blockBuckets[bucketNumber].reporters
  • When you find nodeAddress, return the matching prices[i] and slashedOffenses[nodeAddress]
๐ŸŽฏ Solution
function getSlashedStatus(address nodeAddress, uint256 bucketNumber) public view returns (uint256 price, bool slashed) {
    BlockBucket storage bucket = blockBuckets[bucketNumber];
    for (uint256 i = 0; i < bucket.reporters.length; i++) {
        if (bucket.reporters[i] == nodeAddress) {
            price = bucket.prices[i];
            slashed = bucket.slashedOffenses[nodeAddress];
        }
    }
}

  1. Implement _checkPriceDeviated(uint256 reportedPrice, uint256 medianPrice)
  • ๐Ÿงฎ This internal pure function determines whether a reported price deviates beyond the allowed threshold

  • ๐Ÿ“ It should return true only when deviation is strictly greater than MAX_DEVIATION_BPS

๐Ÿ’ก Hint: Convert to basis points
  • Compute absolute difference: abs(reportedPrice - medianPrice)
  • deviationBps = (deviation * 10_000) / medianPrice
  • Compare to MAX_DEVIATION_BPS
๐ŸŽฏ Solution
function _checkPriceDeviated(uint256 reportedPrice, uint256 medianPrice) internal pure returns (bool) {
    uint256 deviation = reportedPrice > medianPrice ? reportedPrice - medianPrice : medianPrice - reportedPrice;
    uint256 deviationBps = (deviation * 10_000) / medianPrice;
    return deviationBps > MAX_DEVIATION_BPS;
}

  1. Implement getOutlierNodes(uint256 bucketNumber)
  • ๐Ÿ“Š This view function identifies nodes whose report deviates beyond the maximum deviation for a given bucket

  • ๐Ÿ” Loops are fine since this is just a view method that will be called from outside the chain

  • โ›”๏ธ It should revert with MedianNotRecorded if the bucket hasnโ€™t been finalized

  • ๐Ÿšซ It should ignore nodes that are already marked slashed in that bucket

๐Ÿ’ก Hint: Outliers are โ€œdeviated from medianโ€
  • Use the stored medianPrice for that bucket
  • Loop bucket.reporters and check _checkPriceDeviated(bucket.prices[i], bucket.medianPrice)
  • Build a temp array at the maximum size, then trim to the actual size so you don't return empty array slots
๐ŸŽฏ Solution
function getOutlierNodes(uint256 bucketNumber) public view returns (address[] memory) {
    BlockBucket storage bucket = blockBuckets[bucketNumber];
    if (bucket.medianPrice == 0) revert MedianNotRecorded();

    address[] memory outliers = new address[](bucket.reporters.length);
    uint256 outlierCount = 0;

    for (uint256 i = 0; i < bucket.reporters.length; i++) {
        address reporter = bucket.reporters[i];
        if (bucket.slashedOffenses[reporter]) continue;
        uint256 reportedPrice = bucket.prices[i];
        if (reportedPrice == 0) continue;

        if (_checkPriceDeviated(reportedPrice, bucket.medianPrice)) {
            outliers[outlierCount] = reporter;
            outlierCount++;
        }
    }

    address[] memory trimmed = new address[](outlierCount);
    for (uint256 j = 0; j < outlierCount; j++) {
        trimmed[j] = outliers[j];
    }
    return trimmed;
}

  1. Implement _removeNode(address nodeAddress, uint256 index)
  • ๐Ÿ—‚๏ธ This internal function removes a node from the nodeAddresses array while keeping the array packed

  • ๐Ÿ” It should revert with IndexOutOfBounds if index is invalid

  • โœ… It should revert with NodeNotAtGivenIndex if the address at the index does not match nodeAddress

  • ๐Ÿ” It should use swap-and-pop, then mark nodes[nodeAddress].active = false

๐Ÿ’ก Hint: Swap-and-pop
  • Replace nodeAddresses[index] with nodeAddresses[last], then pop()
๐ŸŽฏ Solution
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;
}

  1. Implement slashNode(address nodeToSlash, uint256 bucketNumber, uint256 reportIndex, uint256 nodeAddressesIndex)
  • ๐Ÿ”Ž This function allows anyone to slash a node that deviated too far from the bucketโ€™s recorded median

  • โฐ It should only allow past buckets (not the current bucket), otherwise revert with OnlyPastBucketsAllowed

  • ๐Ÿง  It should require the bucket median is recorded, otherwise revert with MedianNotRecorded

  • ๐Ÿงท It should verify the provided indices (report index + node index), otherwise revert with NodeNotAtGivenIndex / IndexOutOfBounds

  • ๐Ÿšซ It should revert with NotDeviated if deviation is โ‰ค MAX_DEVIATION_BPS (strict >)

  • ๐Ÿ’ฐ It should slash up to MISREPORT_PENALTY and reward the slasher in ORA based on the SLASHER_REWARD_PERCENTAGE

๐Ÿ’ก Hint: Why indices matter
  • reportIndex must match blockBuckets[bucket].reporters[reportIndex] == nodeToSlash
  • nodeAddressesIndex must match nodeAddresses[nodeAddressesIndex] == nodeToSlash
  • This avoids needing to loop onchain (we do the lookup off chain)
๐ŸŽฏ Solution
function slashNode(address nodeToSlash, uint256 bucketNumber, uint256 reportIndex, uint256 nodeAddressesIndex) public {
    if (!nodes[nodeToSlash].active) revert NodeNotRegistered();
    if (getCurrentBucketNumber() == bucketNumber) revert OnlyPastBucketsAllowed();
    BlockBucket storage bucket = blockBuckets[bucketNumber];
    if (bucket.medianPrice == 0) revert MedianNotRecorded();
    if (bucket.slashedOffenses[nodeToSlash]) revert NodeAlreadySlashed();
    if (reportIndex >= bucket.reporters.length) revert IndexOutOfBounds();
    if (nodeToSlash != bucket.reporters[reportIndex]) revert NodeNotAtGivenIndex();
    uint256 reportedPrice = bucket.prices[reportIndex];
    if (reportedPrice == 0) revert NodeDidNotReport();
    if (!_checkPriceDeviated(reportedPrice, bucket.medianPrice)) revert NotDeviated();
    bucket.slashedOffenses[nodeToSlash] = true;
    OracleNode storage node = nodes[nodeToSlash];
    // Slash the node
    uint256 actualPenalty = MISREPORT_PENALTY > node.stakedAmount ? node.stakedAmount : MISREPORT_PENALTY;
    node.stakedAmount -= actualPenalty;

    if (node.stakedAmount == 0) {
        _removeNode(nodeToSlash, nodeAddressesIndex);
        emit NodeExited(nodeToSlash, 0);
    }

    uint256 reward = (actualPenalty * SLASHER_REWARD_PERCENTAGE) / 100;

    bool rewardSent = oracleToken.transfer(msg.sender, reward);
    if (!rewardSent) revert TransferFailed();

    emit NodeSlashed(nodeToSlash, actualPenalty);
}

  1. Implement exitNode(uint256 index)
  • ๐Ÿšช This function allows a node to exit and withdraw its stake after a waiting period

  • ๐Ÿค” By forcing a waiting period we make sure a node can't report a bad price and then exit without being slashed

  • โณ It should revert with WaitingPeriodNotOver if lastReportedBucket + WAITING_PERIOD > getCurrentBucketNumber()

  • ๐Ÿ’ฐ It should compute the withdrawable stake using getEffectiveStake before removing the node

  • ๐Ÿ—‘๏ธ It should remove the node using the index-verified swap-and-pop pattern, and mark inactive (you can use the _removeNode method for this)

  • ๐Ÿฅฉ Set the nodes stakedAmount to 0

  • ๐Ÿช™ Send the node its stake and revert if the TransferFailed

  • ๐Ÿ“ฃ It should emit NodeExited(node, amount)

๐Ÿ’ก Hint: Compute stake before removal
  • getEffectiveStake returns 0 for inactive nodes, so compute it before setting active = false
๐ŸŽฏ Solution
function exitNode(uint256 index) public onlyNode {
    OracleNode storage node = nodes[msg.sender];
    if (node.lastReportedBucket + WAITING_PERIOD > getCurrentBucketNumber()) revert WaitingPeriodNotOver();
    // Get effective stake before removing node (since getEffectiveStake returns 0 for inactive nodes)
    uint256 stake = getEffectiveStake(msg.sender);
    _removeNode(msg.sender, index);
    // Withdraw the stake
    nodes[msg.sender].stakedAmount = 0;
    bool success = oracleToken.transfer(msg.sender, stake);
    if (!success) revert TransferFailed();

    emit NodeExited(msg.sender, stake);
}

๐Ÿค” Key Insights:

  • Bucket-Based System: Prices are organized into discrete buckets, preventing double-reporting within the same bucket.
  • Token staking: Participation is gated by ORA staking via approve + transferFrom.
  • Median finalization: Buckets are finalized by recording a median, which is also what slashing compares against.
  • 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)
  • 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.

๐Ÿค” Real World Considerations

  • Singular Nodes: We built this where it is totally fine to just have one node mostly so that it is easy to test out but a better mechanism would heavily encourage multiple since one node doesn't have any accountability.
  • Schelling Point: You could do this by always allocating a certain amount of tokens to all reporting nodes (e.g. 100 tokens split between however many nodes reported) and this would encourage a large amount of participants (depending on the value of the token) and discourage too many participants which would make the recordBucketMedian function too expensive.
  • Tokenomics: Just note this; The ORA tokens are practically worthless in this system. The only demand source is people who want to run an oracle node through staking some of the token but we are constantly inflating the supply through rewards and we didn't design any other source of demand for the token. Ideally the consumer of the price would be used to create demand for the token and this would find some equilibrium but we did not design the system this way.
  • Locked Tokens: In this design, when a node is slashed, the portion that is not given as a reward simply stays locked in the contract. Also the ORA token contract accepts ETH for ORA but there is no way to withdraw it. Just use your imagination to fill in the gaps of our tokenomics issues. Perhaps the remaining tokens could be burned and the locked ETH could be swapped for it's value in ORA tokens. This a good start but the token still needs demand to be a long term viable system.

Testing your progress

๐Ÿ” Run the following command to check if you implemented the functions correctly.


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!

๐Ÿ”„ Run yarn deploy --reset then test the staking oracle. Go to the Staking page and try registering your own node and reporting prices.

๐Ÿ’ธ To register a node you need ORA to stake. Use the Buy ORA widget on the Staking page to swap 0.5 ETH โ†’ 100 ORA (the ORA token has a special buy function so it mints directly when you buy).

Staking Buttons Panel

๐Ÿ—บ๏ธ 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 bucket then just press the refresh icon next to the pencil.

SelfNodeRow

โ€ผ๏ธ "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. Buy more ORA (via the Buy ORA widget) and then add stake.

๐Ÿง  Before you can read a finalized price for a bucket or slash an outlier, someone must call Record Bucket Median for that bucket. By default there is a button that enables you to run the function for the last bucket but feel free to navigate backwards and trigger the median function by pressing the button on past buckets.

๐Ÿ˜ฎโ€๐Ÿ’จ 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:


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.

๐Ÿ’ฐ Note: When simulation nodes run out of stake (due to slashing or inactivity penalties), their stake will be automatically replenished with ~500 ORA to keep the simulation running.

๐Ÿฅฑ 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:

AUTO_SLASH=true yarn simulate:staking

๐Ÿฅ… Goals:

  • You can register as an oracle node by staking ORA (ERC20)
  • 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 finalizes buckets by recording a median, and reads come from recorded medians
  • 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 and PYTH

Checkpoint 3: ๐Ÿง  Optimistic Oracle Architecture

๐Ÿคฟ Now let's dive into 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

mermaidChart

๐Ÿงฉ 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 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 validate that startTime is not in the past (i.e., startTime >= block.timestamp), otherwise revert with InvalidTime

  • ๐Ÿงญ 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
    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;
    }

  1. 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 + 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 + DISPUTE_WINDOW
๐ŸŽฏ Solution
    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 + DISPUTE_WINDOW;

        emit OutcomeProposed(assertionId, msg.sender, outcome);
    }

  1. 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
    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.


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
    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);
    }

  1. 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
    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);
    }

  1. 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
    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);
    }

  1. 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
    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.


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
    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;
    }

  1. 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

  • ๐Ÿšซ It should revert with NotProposedAssertion if no proposal was ever made (important: expired assertions without proposals have no valid resolution!)

  • โณ 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
  • Check that a proposal exists first โ€” expired assertions without proposals have no valid resolution
  • 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
    function getResolution(uint256 assertionId) external view returns (bool) {
        EventAssertion storage a = assertions[assertionId];
        if (a.asserter == address(0)) revert AssertionNotFound();
        if (a.proposer == address(0)) revert NotProposedAssertion();

        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.


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

  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:


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:

AspectWhitelist OracleStaking OracleOptimistic Oracle
SpeedFastMediumSlow
SecurityLow (trusted authority)High (economic incentives)High (dispute resolution)
DecentralizationLowHighDepends on Decider Implementation
CostLowMedium (stake)High (rewards and bonds)
ComplexitySimpleMediumComplex

๐Ÿค” Key Trade-offs:

  1. Whitelist Oracle:
  • โœ… Simple and fast

  • โœ… Low gas costs

  • โŒ Requires trust in centralized authority

  • โŒ Single point of failure

  1. Staking Oracle:
  • โœ… Decentralized with economic incentives

  • โœ… Self-correcting through slashing

  • โŒ More complex to implement

  • โŒ Higher gas costs

  1. 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 in scenarios where higher latency is acceptable. 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 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 Speedrun Ethereum.

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.
  • ๐Ÿ“ƒETHERSCAN_API_KEY variable in packages/hardhat/.env with your generated API key. You can get your key here.

๐Ÿ’ฌ 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 to get the URL you submit to ๐Ÿƒโ€โ™€๏ธ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.

๐Ÿ’ฌ Problems, questions, comments on the stack? Post them to the ๐Ÿ— scaffold-eth developers chat

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!

๐Ÿš€ Ready to submit your challenge?

You're viewing this challenge as a guest. Want to start building your onchain portfolio?

Connect your wallet and register to unlock the full Speedrun Ethereum experience.