From c404c1c9845e227f73737cf5c5a99af9039cea02 Mon Sep 17 00:00:00 2001 From: Elliott Alexander Date: Tue, 26 Aug 2025 15:28:41 -0400 Subject: [PATCH 01/34] initial commit --- README.md | 134 +++++++++++++++++++++++++++++++++++++++++++++++++-- funding.json | 5 ++ 2 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 funding.json diff --git a/README.md b/README.md index d8a2c9f8..560a520a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,132 @@ -# 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. +# ๐Ÿ— Scaffold-ETH 2 Challenges -You can use these files to help set up a new extension that matches the style and feel of [SpeedRunEthereum](https://speedrunethereum.com/). +**Learn how to use ๐Ÿ— Scaffold-ETH 2 to create decentralized applications on Ethereum. ๐Ÿš€** -There are `// CHALLENGE-TODO:` comments placed throughout that provide instructions on key changes to adapt this template for use with a new challenge. +--- + +## ๐Ÿšฉ Challenge 0: ๐ŸŽŸ Simple NFT Example + +๐ŸŽซ Create a simple NFT to learn the basics of ๐Ÿ— scaffold-eth. You'll use ๐Ÿ‘ทโ€โ™€๏ธ HardHat to compile and deploy smart contracts. Then, you'll use a template React app full of important Ethereum components and hooks. Finally, you'll deploy an NFT to a public network to share with friends! ๐Ÿš€ + +https://github.com/scaffold-eth/se-2-challenges/tree/challenge-0-simple-nft + +--- + +## ๐Ÿšฉ Challenge 1: ๐Ÿ” Decentralized Staking App + +๐Ÿฆธ A superpower of Ethereum is allowing you, the builder, to create a simple set of rules that an adversarial group of players can use to work together. In this challenge, you create a decentralized application where users can coordinate a group funding effort. If the users cooperate, the money is collected in a second smart contract. If they defect, the worst that can happen is everyone gets their money back. The users only have to trust the code. + +https://github.com/scaffold-eth/se-2-challenges/tree/challenge-1-decentralized-staking + +--- + +## ๐Ÿšฉ Challenge 2: ๐Ÿต Token Vendor + +๐Ÿค– Smart contracts are kind of like "always on" vending machines that anyone can access. Let's make a decentralized, digital currency. Then, let's build an unstoppable vending machine that will buy and sell the currency. We'll learn about the "approve" pattern for ERC20s and how contract to contract interactions work. + +https://github.com/scaffold-eth/se-2-challenges/tree/challenge-2-token-vendor + +--- + +## ๐Ÿšฉ Challenge 3: ๐ŸŽฒ Dice Game + +๐ŸŽฐ Randomness is tricky on a public deterministic blockchain. In this challenge you will explore creating random numbers using block hash and how that may be exploitable. Attack the dice game with your own contract by predicting the randomness ahead of time to always roll a winner! + +https://github.com/scaffold-eth/se-2-challenges/tree/challenge-3-dice-game + +--- + +## ๐Ÿšฉ Challenge 4: โš–๏ธ Build a DEX Challenge + +๐Ÿ’ต Build an exchange that swaps ETH to tokens and tokens to ETH. ๐Ÿ’ฐ This is possible because the smart contract holds reserves of both assets and has a price function based on the ratio of the reserves. Liquidity providers are issued a token that represents their share of the reserves and fees... + +DEX Telegram Channel: https://t.me/+_NeUIJ664Tc1MzIx + +https://github.com/scaffold-eth/se-2-challenges/tree/challenge-4-dex + +--- + +## ๐ŸŽ‰ Checkpoint: Eligible to join ๐Ÿฐ๏ธ BuidlGuidl + +The BuidlGuidl is a curated group of Ethereum builders creating products, prototypes, and tutorials to enrich the web3 ecosystem. A place to show off your builds and meet other builders. Start crafting your Web3 portfolio by submitting your DEX, Multisig or SVG NFT build. + +https://buidlguidl.com/ + +--- + +## ๐Ÿšฉ Challenge 5: ๐Ÿ“บ State Channel Application Challenge + +๐Ÿ›ฃ๏ธ The Ethereum blockchain has great decentralization & security properties but these properties come at a price: transaction throughput is low, and transactions can be expensive. This makes many traditional web applications infeasible on a blockchain... or does it? State channels look to solve these problems by allowing participants to securely transact off-chain while keeping interaction with Ethereum Mainnet at a minimum. + +State Channels Telegram Channel: https://t.me/+k0eUYngV2H0zYWUx + +https://github.com/scaffold-eth/se-2-challenges/tree/challenge-5-state-channels + +--- + +## ๐Ÿ’ก Contributing: Guide and Hints to create New Challenges + +### 1. Learn about SE-2 Extensions + +Go to [SE-2 Extensions Documentation](https://docs.scaffoldeth.io/extensions/createExtensions) and familiarize yourself with the way extensions work by watching the video and reading the overview. + +### 2. Follow the steps to create an extension +1. Clone the [create-eth repo](https://github.com/scaffold-eth/create-eth) and cd into it. +```bash + git clone https://github.com/scaffold-eth/create-eth + cd create-eth +``` + +#### Setting up things in externalExtensions: +2. cd into `externalExtensions` (if it's not present `mkdir externalExtensions && cd externalExtensions`) + +3. Clone the base-challenge-template with name of your extension inside `externalExtensions`: + +```bash + git clone -b base-challenge-template https://github.com/scaffold-eth/se-2-challenges.git +``` + +4. cd into `` dir and create a branch with your challenge name. +```bash + cd && git switch -c +``` + +5. Find all the file comments marked `// CHALLENGE-TODO:` and follow the instructions to prepare your challenge. + +6. Commit those changes as an initial commit: `git add . && git commit -m "fill template"` + +#### Commands to be run in create-eth repo: + +1. Build the create-eth cli + +```bash + yarn build:dev +``` + +2. Create an instance with same name as the challenge name directory which was created inside `externalExtensions`: + +```bash + yarn cli ../ -e --dev +``` + +3. This will create the full instance outside of create-eth repo with + +4. Tinker in that instance, adding any new files your challenge will use and then committing those changes + +5. Run this in create-eth to copy all the changes to you extension: + +```bash + yarn create-extension ../ +``` + +### 3. Testing your extension + +Now that you ran the `create-extension` command you should see in the terminal all files that were created and any missing template files. Add any missing template files and continue to follow the instructions in the [local testing](https://docs.scaffoldeth.io/extensions/createExtensions#local-testing) section! + +Don't forget to add a README.md to the top level of your extension. It should match what you put in the `extraContents` variable in `extension/README.md.args.mjs`. + +Iterate as necessary, repeating the steps, to get it just right. + +### 4. Submit a PR + +Once you have iterated your challenge to perfection, you can ask a maintainer to add a branch for your challenge and then submit a pull request to that branch. Expect to make a few passes of revisions as we test these challenges extensively. diff --git a/funding.json b/funding.json new file mode 100644 index 00000000..1493388d --- /dev/null +++ b/funding.json @@ -0,0 +1,5 @@ +{ + "opRetro": { + "projectId": "0x154a42e5ca88d7c2732fda74d6eb611057fc88dbe6f0ff3aae7b89c2cd1666ab" + } +} From 5eeca9fe87351a259020dca46943209c8b6e3dee Mon Sep 17 00:00:00 2001 From: Rinat Date: Fri, 1 Nov 2024 15:30:00 +0300 Subject: [PATCH 02/34] Initial commit of base challenge template for extensions --- README.md | 134 +----------------- .../packages/nextjs/app/page.tsx.args.mjs | 1 - .../ScaffoldEthAppWithProviders.tsx.args.mjs | 1 + .../packages/nextjs/next.config.js.args.mjs | 1 + .../nextjs/tailwind.config.js.args.mjs | 68 +++++++++ 5 files changed, 74 insertions(+), 131 deletions(-) create mode 100644 extension/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx.args.mjs create mode 100644 extension/packages/nextjs/next.config.js.args.mjs create mode 100644 extension/packages/nextjs/tailwind.config.js.args.mjs diff --git a/README.md b/README.md index 560a520a..d8a2c9f8 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,6 @@ -# ๐Ÿ— Scaffold-ETH 2 Challenges +# 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. -**Learn how to use ๐Ÿ— Scaffold-ETH 2 to create decentralized applications on Ethereum. ๐Ÿš€** +You can use these files to help set up a new extension that matches the style and feel of [SpeedRunEthereum](https://speedrunethereum.com/). ---- - -## ๐Ÿšฉ Challenge 0: ๐ŸŽŸ Simple NFT Example - -๐ŸŽซ Create a simple NFT to learn the basics of ๐Ÿ— scaffold-eth. You'll use ๐Ÿ‘ทโ€โ™€๏ธ HardHat to compile and deploy smart contracts. Then, you'll use a template React app full of important Ethereum components and hooks. Finally, you'll deploy an NFT to a public network to share with friends! ๐Ÿš€ - -https://github.com/scaffold-eth/se-2-challenges/tree/challenge-0-simple-nft - ---- - -## ๐Ÿšฉ Challenge 1: ๐Ÿ” Decentralized Staking App - -๐Ÿฆธ A superpower of Ethereum is allowing you, the builder, to create a simple set of rules that an adversarial group of players can use to work together. In this challenge, you create a decentralized application where users can coordinate a group funding effort. If the users cooperate, the money is collected in a second smart contract. If they defect, the worst that can happen is everyone gets their money back. The users only have to trust the code. - -https://github.com/scaffold-eth/se-2-challenges/tree/challenge-1-decentralized-staking - ---- - -## ๐Ÿšฉ Challenge 2: ๐Ÿต Token Vendor - -๐Ÿค– Smart contracts are kind of like "always on" vending machines that anyone can access. Let's make a decentralized, digital currency. Then, let's build an unstoppable vending machine that will buy and sell the currency. We'll learn about the "approve" pattern for ERC20s and how contract to contract interactions work. - -https://github.com/scaffold-eth/se-2-challenges/tree/challenge-2-token-vendor - ---- - -## ๐Ÿšฉ Challenge 3: ๐ŸŽฒ Dice Game - -๐ŸŽฐ Randomness is tricky on a public deterministic blockchain. In this challenge you will explore creating random numbers using block hash and how that may be exploitable. Attack the dice game with your own contract by predicting the randomness ahead of time to always roll a winner! - -https://github.com/scaffold-eth/se-2-challenges/tree/challenge-3-dice-game - ---- - -## ๐Ÿšฉ Challenge 4: โš–๏ธ Build a DEX Challenge - -๐Ÿ’ต Build an exchange that swaps ETH to tokens and tokens to ETH. ๐Ÿ’ฐ This is possible because the smart contract holds reserves of both assets and has a price function based on the ratio of the reserves. Liquidity providers are issued a token that represents their share of the reserves and fees... - -DEX Telegram Channel: https://t.me/+_NeUIJ664Tc1MzIx - -https://github.com/scaffold-eth/se-2-challenges/tree/challenge-4-dex - ---- - -## ๐ŸŽ‰ Checkpoint: Eligible to join ๐Ÿฐ๏ธ BuidlGuidl - -The BuidlGuidl is a curated group of Ethereum builders creating products, prototypes, and tutorials to enrich the web3 ecosystem. A place to show off your builds and meet other builders. Start crafting your Web3 portfolio by submitting your DEX, Multisig or SVG NFT build. - -https://buidlguidl.com/ - ---- - -## ๐Ÿšฉ Challenge 5: ๐Ÿ“บ State Channel Application Challenge - -๐Ÿ›ฃ๏ธ The Ethereum blockchain has great decentralization & security properties but these properties come at a price: transaction throughput is low, and transactions can be expensive. This makes many traditional web applications infeasible on a blockchain... or does it? State channels look to solve these problems by allowing participants to securely transact off-chain while keeping interaction with Ethereum Mainnet at a minimum. - -State Channels Telegram Channel: https://t.me/+k0eUYngV2H0zYWUx - -https://github.com/scaffold-eth/se-2-challenges/tree/challenge-5-state-channels - ---- - -## ๐Ÿ’ก Contributing: Guide and Hints to create New Challenges - -### 1. Learn about SE-2 Extensions - -Go to [SE-2 Extensions Documentation](https://docs.scaffoldeth.io/extensions/createExtensions) and familiarize yourself with the way extensions work by watching the video and reading the overview. - -### 2. Follow the steps to create an extension -1. Clone the [create-eth repo](https://github.com/scaffold-eth/create-eth) and cd into it. -```bash - git clone https://github.com/scaffold-eth/create-eth - cd create-eth -``` - -#### Setting up things in externalExtensions: -2. cd into `externalExtensions` (if it's not present `mkdir externalExtensions && cd externalExtensions`) - -3. Clone the base-challenge-template with name of your extension inside `externalExtensions`: - -```bash - git clone -b base-challenge-template https://github.com/scaffold-eth/se-2-challenges.git -``` - -4. cd into `` dir and create a branch with your challenge name. -```bash - cd && git switch -c -``` - -5. Find all the file comments marked `// CHALLENGE-TODO:` and follow the instructions to prepare your challenge. - -6. Commit those changes as an initial commit: `git add . && git commit -m "fill template"` - -#### Commands to be run in create-eth repo: - -1. Build the create-eth cli - -```bash - yarn build:dev -``` - -2. Create an instance with same name as the challenge name directory which was created inside `externalExtensions`: - -```bash - yarn cli ../ -e --dev -``` - -3. This will create the full instance outside of create-eth repo with - -4. Tinker in that instance, adding any new files your challenge will use and then committing those changes - -5. Run this in create-eth to copy all the changes to you extension: - -```bash - yarn create-extension ../ -``` - -### 3. Testing your extension - -Now that you ran the `create-extension` command you should see in the terminal all files that were created and any missing template files. Add any missing template files and continue to follow the instructions in the [local testing](https://docs.scaffoldeth.io/extensions/createExtensions#local-testing) section! - -Don't forget to add a README.md to the top level of your extension. It should match what you put in the `extraContents` variable in `extension/README.md.args.mjs`. - -Iterate as necessary, repeating the steps, to get it just right. - -### 4. Submit a PR - -Once you have iterated your challenge to perfection, you can ask a maintainer to add a branch for your challenge and then submit a pull request to that branch. Expect to make a few passes of revisions as we test these challenges extensively. +There are `// CHALLENGE-TODO:` comments placed throughout that provide instructions on key changes to adapt this template for use with a new challenge. diff --git a/extension/packages/nextjs/app/page.tsx.args.mjs b/extension/packages/nextjs/app/page.tsx.args.mjs index 2389922f..1d4ed8fc 100644 --- a/extension/packages/nextjs/app/page.tsx.args.mjs +++ b/extension/packages/nextjs/app/page.tsx.args.mjs @@ -26,6 +26,5 @@ export const description = ` `; - // CHALLENGE-TODO: Update the externalExtensionName to reflect your challenge export const externalExtensionName = "SpeedRunEthereum CHALLENGE TITLE"; 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..fca30389 --- /dev/null +++ b/extension/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx.args.mjs @@ -0,0 +1 @@ +export const globalClassNames = "font-space-grotesk"; diff --git a/extension/packages/nextjs/next.config.js.args.mjs b/extension/packages/nextjs/next.config.js.args.mjs new file mode 100644 index 00000000..13898486 --- /dev/null +++ b/extension/packages/nextjs/next.config.js.args.mjs @@ -0,0 +1 @@ +export const ignoreTsAndLintBuildErrors = 'true'; diff --git a/extension/packages/nextjs/tailwind.config.js.args.mjs b/extension/packages/nextjs/tailwind.config.js.args.mjs new file mode 100644 index 00000000..1ffa57b1 --- /dev/null +++ b/extension/packages/nextjs/tailwind.config.js.args.mjs @@ -0,0 +1,68 @@ +export const lightTheme = { + primary: "#C8F5FF", + "primary-content": "#026262", + secondary: "#89d7e9", + "secondary-content": "#088484", + accent: "#026262", + "accent-content": "#E9FBFF", + neutral: "#088484", + "neutral-content": "#F0FCFF", + "base-100": "#F0FCFF", + "base-200": "#E1FAFF", + "base-300": "#C8F5FF", + "base-content": "#088484", + info: "#026262", + success: "#34EEB6", + warning: "#FFCF72", + error: "#FF8863", + + "--rounded-btn": "9999rem", + + ".tooltip": { + "--tooltip-tail": "6px" + }, + ".link": { + textUnderlineOffset: "2px" + }, + ".link:hover": { + opacity: "80%" + } + }; + + export const darkTheme = { + primary: "#026262", + "primary-content": "#C8F5FF", + secondary: "#107575", + "secondary-content": "#E9FBFF", + accent: "#C8F5FF", + "accent-content": "#088484", + neutral: "#E9FBFF", + "neutral-content": "#11ACAC", + "base-100": "#11ACAC", + "base-200": "#088484", + "base-300": "#026262", + "base-content": "#E9FBFF", + info: "#C8F5FF", + success: "#34EEB6", + warning: "#FFCF72", + error: "#FF8863", + + "--rounded-btn": "9999rem", + + ".tooltip": { + "--tooltip-tail": "6px", + "--tooltip-color": "oklch(var(--p))" + }, + ".link": { + textUnderlineOffset: "2px" + }, + ".link:hover": { + opacity: "80%" + } + }; + + export const extendTheme = { + fontFamily: { + "space-grotesk": ["Space Grotesk", "sans-serif"] + } + }; From fb628b195bf9331973f620be98b5c42d4854c571 Mon Sep 17 00:00:00 2001 From: Elliott Alexander Date: Thu, 21 Aug 2025 15:44:26 -0400 Subject: [PATCH 03/34] fill template --- README.md | 1120 ++++++++++++++++- extension/README.md.args.mjs | 1076 +++++++++++++++- .../packages/nextjs/app/layout.tsx.args.mjs | 3 +- .../packages/nextjs/app/page.tsx.args.mjs | 29 +- .../nextjs/components/Header.tsx.args.mjs | 21 +- 5 files changed, 2199 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index d8a2c9f8..e80de368 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,1118 @@ -# 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 do we trust data that comes from outside the blockchain? + +
โ“ 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 exploration and 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. + +๐Ÿ” 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 ๐Ÿ“š + +๐Ÿ› ๏ธ Before you begin, make sure you have the following tools installed: + +- [Node (>=20.18.3)](https://nodejs.org/en/download/) +- Yarn ([v1](https://classic.yarnpkg.com/en/docs/install/) or [v2+](https://yarnpkg.com/getting-started/install)) +- [Git](https://git-scm.com/downloads) + +๐Ÿ“ฅ Then download the challenge to your computer and install dependencies by running: + +```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 + +cd challenge-oracles + +yarn deploy + +``` + +> ๐Ÿ“ฑ In a third terminal window, start your frontend: + +```sh + +cd challenge-oracles + +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. **`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 + +2. **`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 + +๐Ÿ” Open the `packages/hardhat/contracts/00_Whitelist/WhitelistOracle.sol` file to examine how multiple SimpleOracle contracts are aggregated. + +#### ๐Ÿ“– Understanding the Relationship: + +The `WhitelistOracle` contract **aggregates data from 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 (theoretically) data provider +- **Aggregation Level**: The WhitelistOracle collects and processes data from all whitelisted SimpleOracle contracts + +#### ๐Ÿ“– Understanding the Code: + +1. **`addOracle(address oracle)`** - Adds a SimpleOracle contract to the whitelist + + * โœ”๏ธ Validates the oracle address is not zero + + * ๐Ÿงช Checks for duplicates in the existing list + + * โž• Adds the SimpleOracle to the `oracles` array + + * ๐Ÿ“ฃ Emits the `OracleAdded` event + +2. **`removeOracle(uint256 index)`** - Removes a SimpleOracle from the whitelist + + * โœ”๏ธ Validates the index is within bounds + + * โž– Efficiently removes the oracle (swaps with last element) + + * ๐Ÿ“ฃ Emits the `OracleRemoved` event + +3. **`getPrice()`** - Aggregates prices from all whitelisted SimpleOracle contracts + + * ๐Ÿ” Loops through each SimpleOracle in the whitelist + + * ๐Ÿ“ก Calls `getPrice()` on each SimpleOracle to get `(price, timestamp)` + + * ๐Ÿงน Filters out stale prices (older than 10 seconds) + + * โ›”๏ธ Reverts if all prices are stale + + * ๐Ÿงฎ Calculates the median of valid prices + +#### ๐Ÿค” Key Insights: + +- **Composition Pattern**: WhitelistOracle is composed of multiple SimpleOracle contracts +- **Centralized Authority**: Only the owner can add/remove SimpleOracle contracts +- **Consensus Mechanism**: Uses median calculation to resist outliers from individual SimpleOracle contracts +- **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 authority and data providers + +### ๐Ÿ”„ 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] โ†’ 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! + +
+ +๐Ÿ‘Š **Manual Testing**: 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**: Deploy and add new SimpleOracle contracts to the whitelist + +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: + +- See how WhitelistOracle aggregates multiple nodes +- Observe how median calculation provides consensus from multiple sources +- 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 bad behavior. We will also issue rewards in the form of an ERC20 token called ORA to incentivise participation in the system. + +๐Ÿ” Open the `packages/hardhat/contracts/01_Staking/StakingOracle.sol` file to examine the staking oracle implementation. + +#### ๐Ÿ“– Understanding the Code: + +๐Ÿงฉ The `StakingOracle` contract implements a decentralized economic incentive model: + +1. **`registerNode(uint256 initialPrice)`** - Allows users to register as oracle nodes + + * โš ๏ธ Requires a minimum stake of 1 ETH + + * ๐Ÿงช Checks that the node is not already registered + + * ๐Ÿ—๏ธ Creates a new `OracleNode` struct with the provided data + + * โž• Adds the node to the `nodeAddresses` array + + * ๐Ÿ“ฃ Emits the `NodeRegistered` and `PriceReported` events + +2. **`reportPrice(uint256 price)`** - Allows registered nodes to report new prices + + * ๐Ÿงช Checks that the caller is a registered node + + * ๐Ÿ” Verifies the node has sufficient stake + + * ๐Ÿ”„ Updates the node's last reported price and timestamp + + * ๐Ÿ“ฃ Emits the `PriceReported` event + +3. **`separateStaleNodes(address[] memory nodesToSeparate)`** - Categorizes nodes into fresh and stale based on data recency + + * ๐Ÿ“ฆ Takes an array of node addresses to categorize + + * โฑ๏ธ Checks each node's last reported timestamp against `STALE_DATA_WINDOW` (5 seconds) + + * ๐Ÿ“Š Separates nodes into two arrays: fresh (recent data) and stale (old data) + + * ๐Ÿงน Returns trimmed arrays containing only the relevant addresses + + * ๐Ÿ” Used internally by other functions to filter active vs inactive nodes + +4. **`claimReward()`** - Allows registered nodes to claim their ORA token rewards + + * ๐Ÿงช Checks that the caller is a registered node + + * ๐Ÿ” Calculates reward amount based on time elapsed since last claim + + * ๐Ÿ’ฐ For active nodes (sufficient stake): rewards based on time since last claim + + * โš ๏ธ For slashed nodes (insufficient stake): limited rewards only up to when they were slashed + + * ๐ŸŽ Mints ORA tokens as rewards (time-based, scaled by 10^18) + + * ๐Ÿ“ฃ Emits the `NodeRewarded` event + +5. **`slashNodes()`** - Allows anyone to slash nodes that haven't reported recently + + * ๐Ÿ”Ž Identifies nodes with stale data (older than 5 seconds) + + * โœ‚๏ธ Slashes each stale node by 1 ETH + + * ๐Ÿ… Rewards the slasher with 10% of the slashed amount so we can guarantee bad nodes are always slashed + + * ๐Ÿ“ฃ Emits the `NodeSlashed` event for each slashed node + +6. **`getPrice()`** - Aggregates prices from all active nodes + + * ๐Ÿ“ฆ Collects prices from all active nodes + + * ๐Ÿงน Filters out nodes with stale data + + * ๐Ÿงฎ Calculates the median of all valid prices + + * โ›”๏ธ Reverts if no valid prices are available + +### ๐Ÿค” Key Insights: + +- **Economic Incentives**: Nodes stake ETH and can be slashed for bad behavior, where in contrast, good behavior rewards the nodes with ORA token +- **Decentralized**: Anyone can participate by staking, no central authority needed +- **Self-Correcting**: Slashing mechanism punishes inactive or malicious nodes +- **Freshness Enforcement**: Stale data is automatically filtered out +- **Use Cases**: Excellent for DeFi applications where economic alignment is crucial + +๐Ÿ”„ Run `yarn deploy --reset` then test the staking oracle. Try registering nodes, reporting prices, and slashing inactive nodes. + +๐Ÿงช **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 that simulate honest and malicious node behavior, frequent and stale reports, and demonstrate how slashing and median aggregation impact the reported price. You can update the price variance and skip probability from the front-end as well. + +### ๐Ÿฅ… Goals: + +- Understand how economic incentives drive honest behavior +- See how slashing mechanisms enforce data freshness +- Observe the decentralized nature of the system +- Recognize the trade-offs and risks associated with this type of oracle +- Oracles that require staking include [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 then the proposal is considered to be true, and the proposer may claim the reward and 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 - Dispute Resolution + +๐Ÿ‘ฉโ€๐Ÿ’ป Now it's (finally) time to build! Unlike the previous checkpoints where you explored existing implementations, 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 `NotEnoughValue` + +* โฑ๏ธ 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 not + +* ๐Ÿงญ 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! + +* ๐Ÿ“ฃ 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 NotEnoughValue(); + + // 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 `NotEnoughValue` + +* โœ๏ธ 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 NotEnoughValue(); + + 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 NotEnoughValue(); + + assertion.disputer = msg.sender; + + emit OutcomeDisputed(assertionId, msg.sender); + } +``` + +
+
+ +--- + +4. **Implement `claimUndisputedReward(uint256 assertionId)`** + +We need to allow the proposer to claim the reward when no dispute occurs before the deadline. + +* ๐Ÿงฉ 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); + } +``` + +
+
+ +--- + +5. **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); + } +``` + +
+
+ +--- + +6. **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); + } +``` + +
+
+ +--- + +7. **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); + } +``` + +
+
+ +8. **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; + } +``` + +
+
+ +--- + +9. **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; + } + } +``` + +
+
+ +--- + +โœ… Make sure you have implemented everything correctly by running tests with `yarn test`. You can dig into any errors by viewing the tests at `packages/hardhat/test/OptimisticOracle.ts`. + +๐Ÿ”„ Run `yarn deploy --reset` then test the optimistic oracle. Try creating assertions, proposing outcomes, and disputing them. + +๐Ÿงช **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: + +- Users can assert events with descriptions and time windows +- Users can propose outcomes for asserted events +- Users can dispute proposed outcomes +- Undisputed assertions can be claimed after the dispute window +- The system correctly handles timing constraints +- Bond amounts are properly validated +--- + +## Checkpoint 5: ๐Ÿ” 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) | Medium (economic incentives) | High (dispute resolution) | +| **Decentralization** | Low | High | Depends on Decider Implementation | +| **Cost** | Low | Medium | High | +| **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 simple, low-value use cases where speed is more important than decentralization +- **Staking Oracle**: Best for high-value DeFi applications where decentralization and security are crucial +- **Optimistic Oracle**: Best for complex, high-stakes applications where absolute truth is paramount + +--- + +## Checkpoint 6: ๐Ÿ’พ Deploy your contracts! ๐Ÿ›ฐ + +๐ŸŽ‰ 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 7: ๐Ÿšข 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 8: ๐Ÿ“œ 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 9: 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..d7605e57 100644 --- a/extension/README.md.args.mjs +++ b/extension/README.md.args.mjs @@ -1,43 +1,66 @@ 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 = `# ๐Ÿ”ฎ Oracles -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 do we trust data that comes from outside the blockchain? -๐Ÿ’ฌ 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 exploration and 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. + +๐Ÿ” 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: +> ๐Ÿ“ฑ In a third terminal window, start your frontend: \`\`\`sh yarn start @@ -57,23 +80,1024 @@ 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. **\`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 + +2. **\`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 + +๐Ÿ” Open the \`packages/hardhat/contracts/00_Whitelist/WhitelistOracle.sol\` file to examine how multiple SimpleOracle contracts are aggregated. + +#### ๐Ÿ“– Understanding the Relationship: + +The \`WhitelistOracle\` contract **aggregates data from 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 (theoretically) data provider +- **Aggregation Level**: The WhitelistOracle collects and processes data from all whitelisted SimpleOracle contracts + +#### ๐Ÿ“– Understanding the Code: + +1. **\`addOracle(address oracle)\`** - Adds a SimpleOracle contract to the whitelist + + * โœ”๏ธ Validates the oracle address is not zero + + * ๐Ÿงช Checks for duplicates in the existing list + + * โž• Adds the SimpleOracle to the \`oracles\` array + + * ๐Ÿ“ฃ Emits the \`OracleAdded\` event + +2. **\`removeOracle(uint256 index)\`** - Removes a SimpleOracle from the whitelist + + * โœ”๏ธ Validates the index is within bounds + + * โž– Efficiently removes the oracle (swaps with last element) + + * ๐Ÿ“ฃ Emits the \`OracleRemoved\` event + +3. **\`getPrice()\`** - Aggregates prices from all whitelisted SimpleOracle contracts + + * ๐Ÿ” Loops through each SimpleOracle in the whitelist + + * ๐Ÿ“ก Calls \`getPrice()\` on each SimpleOracle to get \`(price, timestamp)\` + + * ๐Ÿงน Filters out stale prices (older than 10 seconds) + + * โ›”๏ธ Reverts if all prices are stale + + * ๐Ÿงฎ Calculates the median of valid prices + +#### ๐Ÿค” Key Insights: + +- **Composition Pattern**: WhitelistOracle is composed of multiple SimpleOracle contracts +- **Centralized Authority**: Only the owner can add/remove SimpleOracle contracts +- **Consensus Mechanism**: Uses median calculation to resist outliers from individual SimpleOracle contracts +- **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 authority and data providers + +### ๐Ÿ”„ 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] โ†’ 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! + +
+ +๐Ÿ‘Š **Manual Testing**: 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**: Deploy and add new SimpleOracle contracts to the whitelist + +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: + +- See how WhitelistOracle aggregates multiple nodes +- Observe how median calculation provides consensus from multiple sources +- 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 bad behavior. We will also issue rewards in the form of an ERC20 token called ORA to incentivise participation in the system. + +๐Ÿ” Open the \`packages/hardhat/contracts/01_Staking/StakingOracle.sol\` file to examine the staking oracle implementation. + +#### ๐Ÿ“– Understanding the Code: + +๐Ÿงฉ The \`StakingOracle\` contract implements a decentralized economic incentive model: + +1. **\`registerNode(uint256 initialPrice)\`** - Allows users to register as oracle nodes + + * โš ๏ธ Requires a minimum stake of 1 ETH + + * ๐Ÿงช Checks that the node is not already registered + + * ๐Ÿ—๏ธ Creates a new \`OracleNode\` struct with the provided data + + * โž• Adds the node to the \`nodeAddresses\` array + + * ๐Ÿ“ฃ Emits the \`NodeRegistered\` and \`PriceReported\` events + +2. **\`reportPrice(uint256 price)\`** - Allows registered nodes to report new prices + + * ๐Ÿงช Checks that the caller is a registered node + + * ๐Ÿ” Verifies the node has sufficient stake + + * ๐Ÿ”„ Updates the node's last reported price and timestamp + + * ๐Ÿ“ฃ Emits the \`PriceReported\` event + +3. **\`separateStaleNodes(address[] memory nodesToSeparate)\`** - Categorizes nodes into fresh and stale based on data recency + + * ๐Ÿ“ฆ Takes an array of node addresses to categorize + + * โฑ๏ธ Checks each node's last reported timestamp against \`STALE_DATA_WINDOW\` (5 seconds) + + * ๐Ÿ“Š Separates nodes into two arrays: fresh (recent data) and stale (old data) + + * ๐Ÿงน Returns trimmed arrays containing only the relevant addresses + + * ๐Ÿ” Used internally by other functions to filter active vs inactive nodes + +4. **\`claimReward()\`** - Allows registered nodes to claim their ORA token rewards + + * ๐Ÿงช Checks that the caller is a registered node + + * ๐Ÿ” Calculates reward amount based on time elapsed since last claim + + * ๐Ÿ’ฐ For active nodes (sufficient stake): rewards based on time since last claim + + * โš ๏ธ For slashed nodes (insufficient stake): limited rewards only up to when they were slashed + + * ๐ŸŽ Mints ORA tokens as rewards (time-based, scaled by 10^18) + + * ๐Ÿ“ฃ Emits the \`NodeRewarded\` event + +5. **\`slashNodes()\`** - Allows anyone to slash nodes that haven't reported recently + + * ๐Ÿ”Ž Identifies nodes with stale data (older than 5 seconds) + + * โœ‚๏ธ Slashes each stale node by 1 ETH + + * ๐Ÿ… Rewards the slasher with 10% of the slashed amount so we can guarantee bad nodes are always slashed + + * ๐Ÿ“ฃ Emits the \`NodeSlashed\` event for each slashed node + +6. **\`getPrice()\`** - Aggregates prices from all active nodes + + * ๐Ÿ“ฆ Collects prices from all active nodes + + * ๐Ÿงน Filters out nodes with stale data + + * ๐Ÿงฎ Calculates the median of all valid prices + + * โ›”๏ธ Reverts if no valid prices are available + +### ๐Ÿค” Key Insights: + +- **Economic Incentives**: Nodes stake ETH and can be slashed for bad behavior, where in contrast, good behavior rewards the nodes with ORA token +- **Decentralized**: Anyone can participate by staking, no central authority needed +- **Self-Correcting**: Slashing mechanism punishes inactive or malicious nodes +- **Freshness Enforcement**: Stale data is automatically filtered out +- **Use Cases**: Excellent for DeFi applications where economic alignment is crucial + +๐Ÿ”„ Run \`yarn deploy --reset\` then test the staking oracle. Try registering nodes, reporting prices, and slashing inactive nodes. + +๐Ÿงช **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 that simulate honest and malicious node behavior, frequent and stale reports, and demonstrate how slashing and median aggregation impact the reported price. You can update the price variance and skip probability from the front-end as well. + +### ๐Ÿฅ… Goals: + +- Understand how economic incentives drive honest behavior +- See how slashing mechanisms enforce data freshness +- Observe the decentralized nature of the system +- Recognize the trade-offs and risks associated with this type of oracle +- Oracles that require staking include [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. -## Checkpoint {num}: ๐Ÿ’พ Deploy your contract! ๐Ÿ›ฐ +๐Ÿ’ก **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. -## Checkpoint {num}: ๐Ÿšข Ship your frontend! ๐Ÿš +๐Ÿ” **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?" -## Checkpoint {num}: ๐Ÿ“œ Contract Verification +๐Ÿงญ 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 then the proposal is considered to be true, and the proposer may claim the reward and 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 - Dispute Resolution + +๐Ÿ‘ฉโ€๐Ÿ’ป Now it's (finally) time to build! Unlike the previous checkpoints where you explored existing implementations, 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 \`NotEnoughValue\` + +* โฑ๏ธ 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 not + +* ๐Ÿงญ 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! + +* ๐Ÿ“ฃ 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 NotEnoughValue(); + + // 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; + } +\`\`\` + +
+
--- -_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 \`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 \`NotEnoughValue\` + +* โœ๏ธ 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]; -### โš”๏ธ Side Quests + 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 NotEnoughValue(); -_To finish your README, can add these links_ + 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 NotEnoughValue(); + + assertion.disputer = msg.sender; + + emit OutcomeDisputed(assertionId, msg.sender); + } +\`\`\` + +
+
+ +--- + +4. **Implement \`claimUndisputedReward(uint256 assertionId)\`** + +We need to allow the proposer to claim the reward when no dispute occurs before the deadline. + +* ๐Ÿงฉ 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); + } +\`\`\` + +
+
+ +--- + +5. **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); + } +\`\`\` + +
+
+ +--- + +6. **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); + } +\`\`\` + +
+
+ +--- + +7. **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); + } +\`\`\` + +
+
+ +8. **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; + } +\`\`\` + +
+
+ +--- + +9. **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; + } + } +\`\`\` + +
+
+ +--- + +โœ… Make sure you have implemented everything correctly by running tests with \`yarn test\`. You can dig into any errors by viewing the tests at \`packages/hardhat/test/OptimisticOracle.ts\`. + +๐Ÿ”„ Run \`yarn deploy --reset\` then test the optimistic oracle. Try creating assertions, proposing outcomes, and disputing them. + +๐Ÿงช **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: + +- Users can assert events with descriptions and time windows +- Users can propose outcomes for asserted events +- Users can dispute proposed outcomes +- Undisputed assertions can be claimed after the dispute window +- The system correctly handles timing constraints +- Bond amounts are properly validated +--- + +## Checkpoint 5: ๐Ÿ” 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) | Medium (economic incentives) | High (dispute resolution) | +| **Decentralization** | Low | High | Depends on Decider Implementation | +| **Cost** | Low | Medium | High | +| **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 simple, low-value use cases where speed is more important than decentralization +- **Staking Oracle**: Best for high-value DeFi applications where decentralization and security are crucial +- **Optimistic Oracle**: Best for complex, high-stakes applications where absolute truth is paramount + +--- + +## Checkpoint 6: ๐Ÿ’พ Deploy your contracts! ๐Ÿ›ฐ + +๐ŸŽ‰ 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 7: ๐Ÿšข 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 8: ๐Ÿ“œ 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 9: 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/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/page.tsx.args.mjs b/extension/packages/nextjs/app/page.tsx.args.mjs index 1d4ed8fc..f07d8121 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 exploration + and 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 {" "} @@ -26,5 +37,5 @@ 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/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"; From a46360e2cbe8a00c626f6f2865a0120b47623a84 Mon Sep 17 00:00:00 2001 From: Elliott Alexander Date: Thu, 21 Aug 2025 16:34:47 -0400 Subject: [PATCH 04/34] update social card image name --- .../packages/nextjs/utils/scaffold-eth/getMetadata.ts.args.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/packages/nextjs/utils/scaffold-eth/getMetadata.ts.args.mjs b/extension/packages/nextjs/utils/scaffold-eth/getMetadata.ts.args.mjs index b2095690..4f8110e5 100644 --- a/extension/packages/nextjs/utils/scaffold-eth/getMetadata.ts.args.mjs +++ b/extension/packages/nextjs/utils/scaffold-eth/getMetadata.ts.args.mjs @@ -1,2 +1,2 @@ export const titleTemplate = "%s | SpeedRunEthereum"; -export const thumbnailPath = "/thumbnail.png"; +export const thumbnailPath = "/social-card.png"; From c530602d66517a97b42314adfd0a8e2479427ee7 Mon Sep 17 00:00:00 2001 From: Elliott Alexander Date: Fri, 22 Aug 2025 14:03:44 -0400 Subject: [PATCH 05/34] second stage --- .../contracts/00_Whitelist/SimpleOracle.sol | 27 + .../00_Whitelist/WhitelistOracle.sol | 125 ++++ .../contracts/01_Staking/OracleToken.sol | 20 + .../contracts/01_Staking/StakingOracle.sol | 190 ++++++ .../contracts/02_Optimistic/Decider.sol | 40 ++ .../02_Optimistic/OptimisticOracle.sol | 110 +++ .../hardhat/deploy/00_deploy_whitelist.ts | 112 ++++ .../hardhat/deploy/01_deploy_staking.ts | 80 +++ .../hardhat/deploy/02_deploy_optimistic.ts | 48 ++ .../hardhat/scripts/fetchPriceFromUniswap.ts | 68 ++ .../hardhat/scripts/oracle-bot/balances.ts | 29 + .../hardhat/scripts/oracle-bot/config.json | 56 ++ .../hardhat/scripts/oracle-bot/price.ts | 16 + .../hardhat/scripts/oracle-bot/reporting.ts | 80 +++ .../hardhat/scripts/oracle-bot/types.ts | 19 + .../hardhat/scripts/oracle-bot/validation.ts | 79 +++ .../hardhat/scripts/runOptimisticBots.ts | 266 ++++++++ .../packages/hardhat/scripts/runOracleBots.ts | 64 ++ .../hardhat/scripts/runWhitelistOracleBots.ts | 120 ++++ extension/packages/hardhat/scripts/utils.ts | 114 ++++ .../packages/hardhat/test/OptimisticOracle.ts | 634 ++++++++++++++++++ .../app/api/config/price-variance/route.ts | 56 ++ .../app/api/config/skip-probability/route.ts | 56 ++ .../packages/nextjs/app/optimistic/page.tsx | 117 ++++ .../packages/nextjs/app/staking/page.tsx | 26 + .../packages/nextjs/app/whitelist/page.tsx | 26 + .../nextjs/components/MonitorAndTriggerTx.tsx | 65 ++ .../ScaffoldEthAppWithProviders.tsx.args.mjs | 8 + .../nextjs/components/TooltipInfo.tsx | 33 + .../nextjs/components/oracle/ConfigSlider.tsx | 84 +++ .../nextjs/components/oracle/EditableCell.tsx | 107 +++ .../components/oracle/HighlightedCell.tsx | 41 ++ .../nextjs/components/oracle/NodeRow.tsx | 101 +++ .../nextjs/components/oracle/NodesTable.tsx | 110 +++ .../nextjs/components/oracle/PriceWidget.tsx | 72 ++ .../oracle/optimistic/AssertedRow.tsx | 50 ++ .../oracle/optimistic/AssertedTable.tsx | 34 + .../oracle/optimistic/AssertionModal.tsx | 260 +++++++ .../oracle/optimistic/DisputedRow.tsx | 48 ++ .../oracle/optimistic/DisputedTable.tsx | 33 + .../components/oracle/optimistic/EmptyRow.tsx | 15 + .../oracle/optimistic/ExpiredRow.tsx | 62 ++ .../oracle/optimistic/ExpiredTable.tsx | 31 + .../oracle/optimistic/LoadingRow.tsx | 21 + .../oracle/optimistic/ProposedRow.tsx | 52 ++ .../oracle/optimistic/ProposedTable.tsx | 32 + .../oracle/optimistic/SettledRow.tsx | 75 +++ .../oracle/optimistic/SettledTable.tsx | 32 + .../optimistic/SubmitAssertionButton.tsx | 245 +++++++ .../components/oracle/optimistic/TimeLeft.tsx | 55 ++ .../nextjs/components/oracle/types.ts | 67 ++ .../oracle/whitelist/AddOracleButton.tsx | 72 ++ .../oracle/whitelist/WhitelistRow.tsx | 60 ++ .../oracle/whitelist/WhitelistTable.tsx | 112 ++++ extension/packages/nextjs/public/hero.png | Bin 0 -> 32545 bytes .../packages/nextjs/public/social-card.png | Bin 0 -> 24701 bytes .../nextjs/services/store/challengeStore.ts | 41 ++ .../packages/nextjs/utils/configUpdater.ts | 40 ++ extension/packages/nextjs/utils/constants.ts | 154 +++++ extension/packages/nextjs/utils/helpers.ts | 36 + 60 files changed, 4826 insertions(+) create mode 100644 extension/packages/hardhat/contracts/00_Whitelist/SimpleOracle.sol create mode 100644 extension/packages/hardhat/contracts/00_Whitelist/WhitelistOracle.sol create mode 100644 extension/packages/hardhat/contracts/01_Staking/OracleToken.sol create mode 100644 extension/packages/hardhat/contracts/01_Staking/StakingOracle.sol create mode 100644 extension/packages/hardhat/contracts/02_Optimistic/Decider.sol create mode 100644 extension/packages/hardhat/contracts/02_Optimistic/OptimisticOracle.sol create mode 100644 extension/packages/hardhat/deploy/00_deploy_whitelist.ts create mode 100644 extension/packages/hardhat/deploy/01_deploy_staking.ts create mode 100644 extension/packages/hardhat/deploy/02_deploy_optimistic.ts create mode 100644 extension/packages/hardhat/scripts/fetchPriceFromUniswap.ts create mode 100644 extension/packages/hardhat/scripts/oracle-bot/balances.ts create mode 100644 extension/packages/hardhat/scripts/oracle-bot/config.json create mode 100644 extension/packages/hardhat/scripts/oracle-bot/price.ts create mode 100644 extension/packages/hardhat/scripts/oracle-bot/reporting.ts create mode 100644 extension/packages/hardhat/scripts/oracle-bot/types.ts create mode 100644 extension/packages/hardhat/scripts/oracle-bot/validation.ts create mode 100644 extension/packages/hardhat/scripts/runOptimisticBots.ts create mode 100644 extension/packages/hardhat/scripts/runOracleBots.ts create mode 100644 extension/packages/hardhat/scripts/runWhitelistOracleBots.ts create mode 100644 extension/packages/hardhat/scripts/utils.ts create mode 100644 extension/packages/hardhat/test/OptimisticOracle.ts create mode 100644 extension/packages/nextjs/app/api/config/price-variance/route.ts create mode 100644 extension/packages/nextjs/app/api/config/skip-probability/route.ts create mode 100644 extension/packages/nextjs/app/optimistic/page.tsx create mode 100644 extension/packages/nextjs/app/staking/page.tsx create mode 100644 extension/packages/nextjs/app/whitelist/page.tsx create mode 100644 extension/packages/nextjs/components/MonitorAndTriggerTx.tsx create mode 100644 extension/packages/nextjs/components/TooltipInfo.tsx create mode 100644 extension/packages/nextjs/components/oracle/ConfigSlider.tsx create mode 100644 extension/packages/nextjs/components/oracle/EditableCell.tsx create mode 100644 extension/packages/nextjs/components/oracle/HighlightedCell.tsx create mode 100644 extension/packages/nextjs/components/oracle/NodeRow.tsx create mode 100644 extension/packages/nextjs/components/oracle/NodesTable.tsx create mode 100644 extension/packages/nextjs/components/oracle/PriceWidget.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/AssertedRow.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/AssertedTable.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/AssertionModal.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/DisputedRow.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/DisputedTable.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/EmptyRow.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/ExpiredRow.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/ExpiredTable.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/LoadingRow.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/ProposedRow.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/ProposedTable.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/SettledRow.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/SettledTable.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/SubmitAssertionButton.tsx create mode 100644 extension/packages/nextjs/components/oracle/optimistic/TimeLeft.tsx create mode 100644 extension/packages/nextjs/components/oracle/types.ts create mode 100644 extension/packages/nextjs/components/oracle/whitelist/AddOracleButton.tsx create mode 100644 extension/packages/nextjs/components/oracle/whitelist/WhitelistRow.tsx create mode 100644 extension/packages/nextjs/components/oracle/whitelist/WhitelistTable.tsx create mode 100644 extension/packages/nextjs/public/hero.png create mode 100644 extension/packages/nextjs/public/social-card.png create mode 100644 extension/packages/nextjs/services/store/challengeStore.ts create mode 100644 extension/packages/nextjs/utils/configUpdater.ts create mode 100644 extension/packages/nextjs/utils/constants.ts create mode 100644 extension/packages/nextjs/utils/helpers.ts 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..a80e92bc --- /dev/null +++ b/extension/packages/hardhat/contracts/00_Whitelist/SimpleOracle.sol @@ -0,0 +1,27 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.0 <0.9.0; + +contract SimpleOracle { + uint256 public price; + uint256 public timestamp; + + event PriceUpdated(uint256 newPrice); + + constructor() {} + + modifier onlyOwner() { + // Intentionally removing the owner requirement to make it easy for you to impersonate the owner + // require(msg.sender == owner, "Not the owner"); + _; + } + + function setPrice(uint256 _newPrice) public onlyOwner { + price = _newPrice; + timestamp = block.timestamp; + emit PriceUpdated(_newPrice); + } + + 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..0bc201e8 --- /dev/null +++ b/extension/packages/hardhat/contracts/00_Whitelist/WhitelistOracle.sol @@ -0,0 +1,125 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.0 <0.9.0; + +import "./SimpleOracle.sol"; + +contract WhitelistOracle { + address public owner; + SimpleOracle[] public oracles; + + event OracleAdded(address oracleAddress); + event OracleRemoved(address oracleAddress); + + constructor() { + owner = msg.sender; + } + + modifier onlyOwner() { + // Intentionally removing the owner requirement to make it easy for you to impersonate the owner + // require(msg.sender == owner, "Not the owner"); + _; + } + + function addOracle(address oracle) public onlyOwner { + require(oracle != address(0), "Invalid oracle address"); + for (uint256 i = 0; i < oracles.length; i++) { + require(address(oracles[i]) != oracle, "Oracle already exists"); + } + oracles.push(SimpleOracle(oracle)); + emit OracleAdded(oracle); + } + + function removeOracle(uint256 index) public onlyOwner { + require(index < oracles.length, "Index out of bounds"); + + address oracleAddress = address(oracles[index]); + + if (index != oracles.length - 1) { + oracles[index] = oracles[oracles.length - 1]; + } + + oracles.pop(); + + emit OracleRemoved(oracleAddress); + } + + function getPrice() public view returns (uint256) { + require(oracles.length > 0, "No oracles available"); + + // 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 10 seconds + if (currentTime - timestamp < 10) { + prices[validCount] = price; + validCount++; + } + } + + require(validCount > 0, "No valid prices available"); + + uint256[] memory validPrices = new uint256[](validCount); + for (uint256 i = 0; i < validCount; i++) { + validPrices[i] = prices[i]; + } + + // NOTE: It is not efficient to sort onchain, but since we only have 10 oracles + // and this is mimicking the early MakerDAO Medianizer exactly, it's fine + sort(validPrices); + + uint256 median; + if (validCount % 2 == 0) { + uint256 midIndex = validCount / 2; + median = (validPrices[midIndex - 1] + validPrices[midIndex]) / 2; + } else { + median = validPrices[validCount / 2]; + } + + return median; + } + + 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 - 10) { + 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; + } + + function transferOwnership(address newOwner) public onlyOwner { + require(newOwner != address(0), "New owner cannot be zero address"); + require(newOwner != owner, "New owner cannot be the same as current owner"); + owner = newOwner; + } + + 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]); + } + } + } +} 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..40f733ea --- /dev/null +++ b/extension/packages/hardhat/contracts/01_Staking/StakingOracle.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0 <0.9.0; + +import { Arrays } from "@openzeppelin/contracts/utils/Arrays.sol"; +import "./OracleToken.sol"; + +contract StakingOracle { + ORA public oracleToken; + + struct OracleNode { + address nodeAddress; + uint256 stakedAmount; + uint256 lastReportedPrice; + uint256 lastReportedTimestamp; + uint256 lastClaimedTimestamp; + uint256 lastSlashedTimestamp; + } + + mapping(address => OracleNode) public nodes; + address[] public nodeAddresses; + + uint256 public constant MINIMUM_STAKE = 1 ether; + uint256 public constant STALE_DATA_WINDOW = 5 seconds; + uint256 public constant SLASHER_REWARD_PERCENTAGE = 10; + + event NodeRegistered(address indexed node, uint256 stakedAmount); + event PriceReported(address indexed node, uint256 price); + + event NodeSlashed(address indexed node, uint256 amount); + event NodeRewarded(address indexed node, uint256 amount); + + event NodesValidated(); + + address public oracleTokenAddress; + + modifier onlyNode() { + require(nodes[msg.sender].nodeAddress != address(0), "Node not registered"); + _; + } + + constructor() { + oracleToken = new ORA(); + } + + /* ========== Oracle Node Operation Functions ========== */ + function registerNode(uint256 initialPrice) public payable { + require(msg.value >= MINIMUM_STAKE, "Insufficient stake"); + require(nodes[msg.sender].nodeAddress == address(0), "Node already registered"); + + nodes[msg.sender] = OracleNode({ + nodeAddress: msg.sender, + stakedAmount: msg.value, + lastReportedPrice: initialPrice, + lastReportedTimestamp: block.timestamp, + lastClaimedTimestamp: block.timestamp, + lastSlashedTimestamp: 0 + }); + + nodeAddresses.push(msg.sender); + + emit NodeRegistered(msg.sender, msg.value); + emit PriceReported(msg.sender, initialPrice); + } + + function reportPrice(uint256 price) public onlyNode { + OracleNode storage node = nodes[msg.sender]; + require(node.stakedAmount >= MINIMUM_STAKE, "Not enough stake"); + node.lastReportedPrice = price; + node.lastReportedTimestamp = block.timestamp; + + emit PriceReported(msg.sender, price); + } + + function rewardNode(address nodeAddress, uint256 reward) internal { + oracleToken.mint(nodeAddress, reward); + emit NodeRewarded(nodeAddress, reward); + } + + function slashNode(address nodeToSlash, uint256 penalty) internal returns (uint256) { + OracleNode storage node = nodes[nodeToSlash]; + uint256 actualPenalty = penalty > node.stakedAmount ? node.stakedAmount : penalty; + node.stakedAmount -= actualPenalty; + + uint256 reward = (actualPenalty * SLASHER_REWARD_PERCENTAGE) / 100; + + emit NodeSlashed(nodeToSlash, actualPenalty); + + return reward; + } + + function claimReward() public onlyNode { + OracleNode memory node = nodes[msg.sender]; + uint256 rewardAmount = 0; + + if (node.stakedAmount < MINIMUM_STAKE) { + if (node.lastClaimedTimestamp < node.lastSlashedTimestamp) { + rewardAmount = node.lastSlashedTimestamp - node.lastClaimedTimestamp; + } + } else { + rewardAmount = block.timestamp - node.lastClaimedTimestamp; + } + + require(rewardAmount > 0, "No rewards available"); + + nodes[msg.sender].lastClaimedTimestamp = block.timestamp; + rewardNode(msg.sender, rewardAmount * 10**18); + } + + function slashNodes() public { + (, address[] memory addressesToSlash) = separateStaleNodes(nodeAddresses); + uint256 slasherReward; + for (uint i = 0; i < addressesToSlash.length; i++) { + slasherReward += slashNode(addressesToSlash[i], 1 ether); + } + + (bool sent,) = msg.sender.call{value: slasherReward}(""); + require(sent, "Failed to send reward"); + } + + /* ========== Price Calculation Functions ========== */ + function getMedian(uint256[] memory arr) internal pure returns (uint256) { + uint256 length = arr.length; + if (length % 2 == 0) { + return (arr[length / 2 - 1] + arr[length / 2]) / 2; + } else { + return arr[length / 2]; + } + } + + function separateStaleNodes( + address[] memory nodesToSeparate + ) public view returns (address[] memory fresh, address[] memory stale) { + address[] memory freshNodeAddresses = new address[](nodesToSeparate.length); + address[] memory staleNodeAddresses = new address[](nodesToSeparate.length); + uint256 freshCount = 0; + uint256 staleCount = 0; + + for (uint i = 0; i < nodesToSeparate.length; i++) { + address nodeAddress = nodesToSeparate[i]; + OracleNode memory node = nodes[nodeAddress]; + uint256 timeElapsed = block.timestamp - node.lastReportedTimestamp; + bool dataIsStale = timeElapsed > STALE_DATA_WINDOW; + + if (dataIsStale) { + staleNodeAddresses[staleCount] = nodeAddress; + staleCount++; + } else { + freshNodeAddresses[freshCount] = nodeAddress; + freshCount++; + } + } + + address[] memory trimmedFreshNodes = new address[](freshCount); + address[] memory trimmedStaleNodes = new address[](staleCount); + + for (uint i = 0; i < freshCount; i++) { + trimmedFreshNodes[i] = freshNodeAddresses[i]; + } + for (uint i = 0; i < staleCount; i++) { + trimmedStaleNodes[i] = staleNodeAddresses[i]; + } + + return (trimmedFreshNodes, trimmedStaleNodes); + } + + function getPricesFromAddresses(address[] memory addresses) internal view returns (uint256[] memory) { + uint256[] memory prices = new uint256[](addresses.length); + + for (uint256 i = 0; i < addresses.length; i++) { + OracleNode memory node = nodes[addresses[i]]; + prices[i] = node.lastReportedPrice; + } + + return prices; + } + + function getPrice() public view returns (uint256) { + (address[] memory validAddresses, ) = separateStaleNodes(nodeAddresses); + uint256[] memory validPrices = getPricesFromAddresses(validAddresses); + require(validPrices.length > 0, "No valid prices available"); + Arrays.sort(validPrices); + return getMedian(validPrices); + } + + function getNodeAddresses() public view returns (address[] memory) { + return nodeAddresses; + } + + // Notably missing a way to unstake and exit your node but not needed for the challenge +} 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..26f5e6b6 --- /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..278210fa --- /dev/null +++ b/extension/packages/hardhat/contracts/02_Optimistic/OptimisticOracle.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0 <0.9.0; + +contract OptimisticOracle { + enum State { Invalid, Asserted, Proposed, Disputed, Settled, Expired } + + error AssertionNotFound(); + error AssertionProposed(); + error NotEnoughValue(); + error InvalidTime(); + error ProposalDisputed(); + error NotProposedAssertion(); + error AlreadyClaimed(); + error AlreadySettled(); + error AwaitingDecider(); + error NotDisputedAssertion(); + error OnlyDecider(); + error OnlyOwner(); + error TransferFailed(); + + 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 MINIMUM_DISPUTE_WINDOW = 3 minutes; + address public decider; + address public owner; + uint256 public nextAssertionId = 1; + mapping(uint256 => EventAssertion) public assertions; + + 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); + + modifier onlyDecider() { + if (msg.sender != decider) revert OnlyDecider(); + _; + } + + modifier onlyOwner() { + if (msg.sender != owner) revert OnlyOwner(); + _; + } + + constructor(address _decider) { + decider = _decider; + owner = msg.sender; + } + + function setDecider(address _decider) external onlyOwner { + address oldDecider = address(decider); + decider = _decider; + emit DeciderUpdated(oldDecider, _decider); + } + + function getAssertion(uint256 assertionId) external view returns (EventAssertion memory) { + return assertions[assertionId]; + } + + function assertEvent(string memory description, uint256 startTime, uint256 endTime) external payable returns (uint256) { + + } + + function proposeOutcome(uint256 assertionId, bool outcome) external payable { + + } + + function disputeOutcome(uint256 assertionId) external payable { + + } + + function claimUndisputedReward(uint256 assertionId) external { + + } + + function claimDisputedReward(uint256 assertionId) external { + + } + + function claimRefund(uint256 assertionId) external { + + } + + function settleAssertion(uint256 assertionId, bool resolvedOutcome) external onlyDecider { + + } + + function getState(uint256 assertionId) external view returns (State) { + + } + + function getResolution(uint256 assertionId) external view returns (bool) { + + } +} 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..3bf1431c --- /dev/null +++ b/extension/packages/hardhat/deploy/00_deploy_whitelist.ts @@ -0,0 +1,112 @@ +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { DeployFunction } from "hardhat-deploy/types"; +import { fetchPriceFromUniswap } from "../scripts/fetchPriceFromUniswap"; + +/** + * Deploys SimpleOracle instances and a WhitelistOracle contract using viem + * + * @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 contracts..."); + + // Get 10 wallet clients (accounts) + const accounts = await viem.getWalletClients(); + const nodeAccounts = accounts.slice(0, 10); + const simpleOracleAddresses: string[] = []; + + // Deploy 10 SimpleOracle contracts, each owned by a different account + for (let i = 0; i < nodeAccounts.length; i++) { + const account = nodeAccounts[i]; + console.log(`Deploying SimpleOracle ${i + 1}/10 from account: ${account.account.address}`); + const simpleOracle = await deploy(`SimpleOracle_${i + 1}`, { + contract: "SimpleOracle", + from: account.account.address, + args: [], + log: true, + autoMine: true, + }); + simpleOracleAddresses.push(simpleOracle.address); + } + + console.log("Deploying WhitelistOracle..."); + + const whitelistOracleDeployment = await deploy("WhitelistOracle", { + from: deployer, + args: [], + log: true, + autoMine: true, + }); + const whitelistOracleAddress = whitelistOracleDeployment.address as `0x${string}`; + const whitelistOracleAbi = whitelistOracleDeployment.abi; + + // Add all SimpleOracle addresses to WhitelistOracle + console.log("Adding SimpleOracle instances to WhitelistOracle..."); + const deployerAccount = accounts.find(a => a.account.address.toLowerCase() === deployer.toLowerCase()); + if (!deployerAccount) throw new Error("Deployer account not found in wallet clients"); + + try { + for (let i = 0; i < simpleOracleAddresses.length; i++) { + const oracleAddress = simpleOracleAddresses[i] as `0x${string}`; + console.log(`Adding SimpleOracle ${i + 1}/10: ${oracleAddress}`); + await deployerAccount.writeContract({ + address: whitelistOracleAddress, + abi: whitelistOracleAbi, + functionName: "addOracle", + args: [oracleAddress], + }); + } + } catch (error: any) { + if (error.message?.includes("Oracle already exists")) { + console.error("\nโŒ Deployment failed: Oracle contracts already exist!\n"); + console.error("๐Ÿ”ง Please retry with:"); + console.error("yarn deploy --reset\n"); + process.exit(1); + } else { + throw error; + } + } + + // Set initial prices for each SimpleOracle + console.log("Setting initial prices for each SimpleOracle..."); + const initialPrice = await fetchPriceFromUniswap(); + for (let i = 0; i < nodeAccounts.length; i++) { + const account = nodeAccounts[i]; + const simpleOracleDeployment = await hre.deployments.get(`SimpleOracle_${i + 1}`); + const simpleOracleAbi = simpleOracleDeployment.abi; + const simpleOracleAddress = simpleOracleDeployment.address as `0x${string}`; + await account.writeContract({ + address: simpleOracleAddress, + abi: simpleOracleAbi, + functionName: "setPrice", + args: [initialPrice], + }); + + await publicClient.transport.request({ + method: "evm_mine", + }); + + 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("All oracle contracts 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..f42780ef --- /dev/null +++ b/extension/packages/hardhat/deploy/01_deploy_staking.ts @@ -0,0 +1,80 @@ +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); + + 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("Node already registered")) { + 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; + } + } + + await publicClient.transport.request({ + method: "evm_mine", + }); + + await Promise.all( + nodeAccounts.map(account => { + return account.writeContract({ + address: stakingOracleAddress, + abi: deployment.abi, + functionName: "reportPrice", + args: [initialPrice], + }); + }), + ); + + await publicClient.transport.request({ + method: "evm_mine", + }); +}; + +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..dc756783 --- /dev/null +++ b/extension/packages/hardhat/deploy/02_deploy_optimistic.ts @@ -0,0 +1,48 @@ +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], // Use deployer as temporary decider + log: true, + autoMine: true, + }); + + // Deploy the Decider contract + const decider = await deploy("Decider", { + contract: "Decider", + from: deployer, + args: [optimisticOracle.address], + log: true, + autoMine: 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/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..0220acc3 --- /dev/null +++ b/extension/packages/hardhat/scripts/oracle-bot/config.json @@ -0,0 +1,56 @@ +{ + "PRICE": { + "CACHEDPRICE": 4211.966627258689, + "TIMESTAMP": 1754950566641 + }, + "INTERVALS": { + "PRICE_REPORT": 1750, + "VALIDATION": 1750 + }, + "NODE_CONFIGS": { + "default": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0 + }, + "0x70997970c51812dc3a010c7d01b50e0d17dc79c8": { + "PROBABILITY_OF_SKIPPING_REPORT": 0.5, + "PRICE_VARIANCE": 0.02 + }, + "0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc": { + "PROBABILITY_OF_SKIPPING_REPORT": 1, + "PRICE_VARIANCE": 0.04 + }, + "0x976ea74026e726554db657fa54763abd0c3a0aa9": { + "PROBABILITY_OF_SKIPPING_REPORT": 0.5, + "PRICE_VARIANCE": 0.03 + }, + "0x90f79bf6eb2c4f870365e785982e1f101e93b906": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0.01 + }, + "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0.035 + }, + "0x15d34aaf54267db7d7c367839aaf71a00a2c6a65": { + "PROBABILITY_OF_SKIPPING_REPORT": 0.59, + "PRICE_VARIANCE": 0.045 + }, + "0xbcd4042de499d14e55001ccbb24a551f3b954096": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0.73 + }, + "0x14dc79964da2c08b23698b3d3cc7ca32193d9955": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0.025 + }, + "0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f": { + "PROBABILITY_OF_SKIPPING_REPORT": 1, + "PRICE_VARIANCE": 0 + }, + "0xa0ee7a142d267c1f36714e4a8f75612f20a79720": { + "PROBABILITY_OF_SKIPPING_REPORT": 0, + "PRICE_VARIANCE": 0.05 + } + } +} \ No newline at end of file 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..6b142ead --- /dev/null +++ b/extension/packages/hardhat/scripts/runOptimisticBots.ts @@ -0,0 +1,266 @@ +import { deployments, ethers } from "hardhat"; +import hre from "hardhat"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { cleanup, 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(); + + await publicClient.transport.request({ method: "evm_setAutomine", params: [false] }); + + // 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; + await publicClient.transport.request({ method: "evm_mine" }); + await publicClient.transport.request({ method: "evm_setAutomine", params: [true] }); + } 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..."); + await cleanup(); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + console.log("\nReceived SIGTERM. Cleaning up..."); + await cleanup(); + process.exit(0); +}); + +// Handle uncaught exceptions +process.on("uncaughtException", async error => { + console.error("Uncaught Exception:", error); + await cleanup(); + process.exit(1); +}); + +// Handle unhandled promise rejections +process.on("unhandledRejection", async (reason, promise) => { + console.error("Unhandled Rejection at:", promise, "reason:", reason); + await cleanup(); + process.exit(1); +}); diff --git a/extension/packages/hardhat/scripts/runOracleBots.ts b/extension/packages/hardhat/scripts/runOracleBots.ts new file mode 100644 index 00000000..8be48b1b --- /dev/null +++ b/extension/packages/hardhat/scripts/runOracleBots.ts @@ -0,0 +1,64 @@ +import { reportPrices } from "./oracle-bot/reporting"; +import { claimRewards, validateNodes } from "./oracle-bot/validation"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import hre from "hardhat"; +import { cleanup, sleep } from "./utils"; + +const runCycle = async (hre: HardhatRuntimeEnvironment) => { + try { + const publicClient = await hre.viem.getPublicClient(); + const blockNumber = await publicClient.getBlockNumber(); + console.log(`\n[Block ${blockNumber}] Starting new oracle cycle...`); + + await publicClient.transport.request({ method: "evm_setAutomine", params: [false] }); + await reportPrices(hre); + await publicClient.transport.request({ method: "evm_mine" }); + + await validateNodes(hre); + await claimRewards(hre); + await publicClient.transport.request({ method: "evm_mine" }); + await publicClient.transport.request({ method: "evm_setAutomine", params: [true] }); + } catch (error) { + console.error("Error in oracle cycle:", error); + } +}; + +const run = async () => { + console.log("Starting oracle bot system..."); + while (true) { + await runCycle(hre); + await sleep(3000); + } +}; + +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..."); + await cleanup(); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + console.log("\nReceived SIGTERM. Cleaning up..."); + await cleanup(); + process.exit(0); +}); + +// Handle uncaught exceptions +process.on("uncaughtException", async error => { + console.error("Uncaught Exception:", error); + await cleanup(); + process.exit(1); +}); + +// Handle unhandled promise rejections +process.on("unhandledRejection", async (reason, promise) => { + console.error("Unhandled Rejection at:", promise, "reason:", reason); + await cleanup(); + 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..57df43b0 --- /dev/null +++ b/extension/packages/hardhat/scripts/runWhitelistOracleBots.ts @@ -0,0 +1,120 @@ +import { ethers } from "hardhat"; +import { WhitelistOracle } from "../typechain-types"; +import hre from "hardhat"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; +import { fetchPriceFromUniswap } from "./fetchPriceFromUniswap"; +import { cleanup, 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(); + + await publicClient.transport.request({ method: "evm_setAutomine", params: [false] }); + 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], + }); + } + + await publicClient.transport.request({ method: "evm_mine" }); + await publicClient.transport.request({ method: "evm_setAutomine", params: [true] }); + } 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..."); + await cleanup(); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + console.log("\nReceived SIGTERM. Cleaning up..."); + await cleanup(); + process.exit(0); +}); + +// Handle uncaught exceptions +process.on("uncaughtException", async error => { + console.error("Uncaught Exception:", error); + await cleanup(); + process.exit(1); +}); + +// Handle unhandled promise rejections +process.on("unhandledRejection", async (reason, promise) => { + console.error("Unhandled Rejection at:", promise, "reason:", reason); + await cleanup(); + 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..27b9a322 --- /dev/null +++ b/extension/packages/hardhat/scripts/utils.ts @@ -0,0 +1,114 @@ +import { Config } from "./oracle-bot/types"; +import fs from "fs"; +import path from "path"; +import hre from "hardhat"; + +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)); + +// Cleanup function to ensure automining is turned back on +export async function cleanup() { + try { + const publicClient = await hre.viem.getPublicClient(); + await publicClient.transport.request({ method: "evm_setAutomine", params: [true] }); + console.log("\nCleaning up: turning automining back on..."); + } catch (error) { + console.error("Error cleaning up:", error); + } +} + +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..88703b55 --- /dev/null +++ b/extension/packages/hardhat/test/OptimisticOracle.ts @@ -0,0 +1,634 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { OptimisticOracle, Decider } from "../typechain-types"; +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +describe("OptimisticOracle", function () { + let optimisticOracle: OptimisticOracle; + let deciderContract: Decider; + let owner: HardhatEthersSigner; + let asserter: HardhatEthersSigner; + let proposer: HardhatEthersSigner; + let disputer: HardhatEthersSigner; + let otherUser: HardhatEthersSigner; + + // 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("OptimisticOracle"); + optimisticOracle = await OptimisticOracleFactory.deploy(owner.address); + + // 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("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 minimumDisputeWindow = await optimisticOracle.MINIMUM_DISPUTE_WINDOW(); + + expect(minimumAssertionWindow).to.equal(180n); // 3 minutes + expect(minimumDisputeWindow).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); + + const state = await optimisticOracle.getState(assertionId); + expect(state).to.equal(State.Asserted); // Asserted state + }); + + 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, "NotEnoughValue"); + }); + }); + + 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); + + // Check that the proposal was recorded by checking the state + const state = await optimisticOracle.getState(assertionId); + expect(state).to.equal(State.Proposed); // Proposed state + }); + + 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, "NotEnoughValue"); + }); + + 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); + + // Check that the dispute was recorded by checking the state + const state = await optimisticOracle.getState(assertionId); + expect(state).to.equal(State.Disputed); // Disputed state + }); + + 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, "NotEnoughValue"); + }); + + 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("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("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 + }); + }); + + 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 MINIMUM_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 }); + }); + }); +}); 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/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/staking/page.tsx b/extension/packages/nextjs/app/staking/page.tsx new file mode 100644 index 00000000..aff773a2 --- /dev/null +++ b/extension/packages/nextjs/app/staking/page.tsx @@ -0,0 +1,26 @@ +"use client"; + +import type { NextPage } from "next"; +import { NodesTable } from "~~/components/oracle/NodesTable"; +import { PriceWidget } from "~~/components/oracle/PriceWidget"; + +const Home: NextPage = () => { + 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/MonitorAndTriggerTx.tsx b/extension/packages/nextjs/components/MonitorAndTriggerTx.tsx new file mode 100644 index 00000000..1e93f98e --- /dev/null +++ b/extension/packages/nextjs/components/MonitorAndTriggerTx.tsx @@ -0,0 +1,65 @@ +import { useEffect, useRef } from "react"; +import { parseEther } from "viem"; +import { hardhat } from "viem/chains"; +import { useAccount, usePublicClient, useWalletClient } from "wagmi"; +import { useTargetNetwork } from "~~/hooks/scaffold-eth"; +import { useChallengeState } from "~~/services/store/challengeStore"; + +// This component is used to monitor the block timestamp and trigger a transaction if the timestamp has not changed for 3 seconds +export const MonitorAndTriggerTx = () => { + const publicClient = usePublicClient(); + const { data: walletClient } = useWalletClient(); + const { targetNetwork } = useTargetNetwork(); + const isLocalNetwork = targetNetwork.id === hardhat.id; + const { connector } = useAccount(); + const isBurnerWallet = connector?.id === "burnerWallet"; + + const prevTimestampRef = useRef(null); + const currentTimestampRef = useRef(null); + + const { setTimestamp, refetchAssertionStates } = useChallengeState(); + + useEffect(() => { + if (!publicClient || !walletClient) return; + + const pollBlock = async () => { + try { + const block = await publicClient.getBlock(); + const newTimestamp = block.timestamp; + + const prev = prevTimestampRef.current; + const current = currentTimestampRef.current; + + if (prev && newTimestamp === prev) { + try { + if (isBurnerWallet && isLocalNetwork) { + await walletClient.sendTransaction({ + to: walletClient.account.address, + value: parseEther("0.0000001"), + }); + } + } catch (err) { + console.log("Failed to send tx"); + } + } + + // Update refs + prevTimestampRef.current = current; + currentTimestampRef.current = newTimestamp; + refetchAssertionStates(); + + // Also update current timestamp in Zustand store for global access + setTimestamp(newTimestamp); + } catch (err) { + console.log("Polling error"); + } + }; + + const interval = setInterval(pollBlock, 3000); + pollBlock(); // Initial call + + return () => clearInterval(interval); + }, [publicClient, walletClient, isLocalNetwork, isBurnerWallet, setTimestamp, refetchAssertionStates]); + + return null; +}; diff --git a/extension/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx.args.mjs b/extension/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx.args.mjs index fca30389..c30093df 100644 --- a/extension/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx.args.mjs +++ b/extension/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx.args.mjs @@ -1 +1,9 @@ +// Reference the example args file: https://github.com/scaffold-eth/create-eth-extensions/blob/example/extension/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx.args.mjs + +// Default args: +export const preContent = `import { MonitorAndTriggerTx } from "./MonitorAndTriggerTx";`; export const globalClassNames = "font-space-grotesk"; +export const extraProviders = { + "MonitorAndTriggerTx": {}, +}; +export const overrideProviders = {}; diff --git a/extension/packages/nextjs/components/TooltipInfo.tsx b/extension/packages/nextjs/components/TooltipInfo.tsx new file mode 100644 index 00000000..cf033bc4 --- /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"; + 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/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..05bb7292 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/EditableCell.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from "react"; +import { HighlightedCell } from "./HighlightedCell"; +import { parseEther } from "viem"; +import { useWriteContract } from "wagmi"; +import { 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 [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); + } + }; + + 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..4c1e5160 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/NodeRow.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef } from "react"; +import { ConfigSlider } from "./ConfigSlider"; +import { NodeRowProps } from "./types"; +import { erc20Abi, formatEther } from "viem"; +import { useReadContract, useWatchContractEvent } 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 const NodeRow = ({ address, isStale }: NodeRowProps) => { + const { data = [] } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "nodes", + args: [address], + }); + + const { data: oracleTokenAddress } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "oracleToken", + }); + + const { data: oraBalance, refetch: refetchOraBalance } = useReadContract({ + address: oracleTokenAddress as `0x${string}`, + abi: erc20Abi, + functionName: "balanceOf", + args: [address], + query: { + enabled: !!oracleTokenAddress, + }, + }); + + useWatchContractEvent({ + address: oracleTokenAddress as `0x${string}`, + abi: erc20Abi, + eventName: "Transfer", + onLogs: logs => { + const relevantTransfer = logs.find( + log => + log.args.to?.toLowerCase() === address.toLowerCase() || + log.args.from?.toLowerCase() === address.toLowerCase(), + ); + if (relevantTransfer) { + refetchOraBalance(); + } + }, + enabled: !!oracleTokenAddress, + }); + + const { data: minimumStake } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "MINIMUM_STAKE", + args: undefined, + }); + + const { data: medianPrice } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getPrice", + }) as { data: bigint | undefined }; + + const [, stakedAmount, lastReportedPrice] = data; + + const prevMedianPrice = useRef(undefined); + + useEffect(() => { + if (medianPrice !== undefined && medianPrice !== prevMedianPrice.current) { + prevMedianPrice.current = medianPrice; + } + }, [medianPrice]); + + const stakedAmountFormatted = stakedAmount !== undefined ? Number(formatEther(stakedAmount)) : "Loading..."; + const lastReportedPriceFormatted = + lastReportedPrice !== undefined ? Number(parseFloat(formatEther(lastReportedPrice)).toFixed(2)) : "Not reported"; + const oraBalanceFormatted = oraBalance !== undefined ? Number(formatEther(oraBalance)) : "Loading..."; + + // Check if staked amount is below minimum requirement + const isInsufficientStake = stakedAmount !== undefined && minimumStake !== undefined && stakedAmount < minimumStake; + + return ( + + +
+ + + {stakedAmountFormatted} + + + {lastReportedPriceFormatted} + + + {oraBalanceFormatted} + + + + + ); +}; diff --git a/extension/packages/nextjs/components/oracle/NodesTable.tsx b/extension/packages/nextjs/components/oracle/NodesTable.tsx new file mode 100644 index 00000000..a2fd3c73 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/NodesTable.tsx @@ -0,0 +1,110 @@ +import TooltipInfo from "../TooltipInfo"; +import { NodeRow } from "./NodeRow"; +import { useScaffoldReadContract } from "~~/hooks/scaffold-eth"; + +const LoadingRow = () => { + return ( + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + ); +}; + +const NoNodesRow = () => { + return ( + + + No nodes found + + + ); +}; + +export const NodesTable = () => { + const { data: nodeAddresses, isLoading } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "getNodeAddresses", + }); + + const { data: staleNodesData } = useScaffoldReadContract({ + contractName: "StakingOracle", + functionName: "separateStaleNodes", + args: [nodeAddresses || []], + }); + + 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."; + + // Extract stale node addresses from the returned data + const staleNodeAddresses = staleNodesData?.[1] || []; + + return ( +
+
+

Oracle Nodes

+ + + +
+
+
+ + + + + + + + + + + + + {isLoading ? ( + + ) : nodeAddresses?.length === 0 ? ( + + ) : ( + nodeAddresses?.map((address: string, index: number) => ( + + )) + )} + +
Node Address +
+ Staked Amount (ETH) + +
+
+
+ Last Reported Price (USD) + +
+
+
+ ORA Balance + +
+
Skip ProbabilityPrice Variance
+
+
+
+ ); +}; diff --git a/extension/packages/nextjs/components/oracle/PriceWidget.tsx b/extension/packages/nextjs/components/oracle/PriceWidget.tsx new file mode 100644 index 00000000..79c25513 --- /dev/null +++ b/extension/packages/nextjs/components/oracle/PriceWidget.tsx @@ -0,0 +1,72 @@ +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 { + data: currentPrice, + isLoading, + isError, + } = useScaffoldReadContract({ + contractName, + functionName: "getPrice", + 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

+
+ +
+
+ {isError ? ( +
No fresh price
+ ) : isLoading || currentPrice === undefined ? ( +
+
+
+ ) : ( + `$${parseFloat(formatEther(currentPrice)).toFixed(2)}` + )} +
+
+
+
+ ); +}; 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..0741b4bf --- /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 ( + <> + +