Initial commit with 🏗️ create-eth @ 2.0.4
This commit is contained in:
114
.cursor/rules/scaffold-eth.mdc
Normal file
114
.cursor/rules/scaffold-eth.mdc
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
This codebase contains Scaffold-ETH 2 (SE-2), everything you need to build dApps on Ethereum. Its tech stack is NextJS, RainbowKit, Wagmi and Typescript. Supports Hardhat and Foundry.
|
||||
|
||||
It's a yarn monorepo that contains following packages:
|
||||
|
||||
- Hardhat (`packages/hardhat`): The solidity framework to write, test and deploy EVM Smart Contracts.
|
||||
- NextJS (`packages/nextjs`): The UI framework extended with utilities to make interacting with Smart Contracts easy (using Next.js App Router, not Pages Router).
|
||||
|
||||
|
||||
The usual dev flow is:
|
||||
|
||||
- Start SE-2 locally:
|
||||
- `yarn chain`: Starts a local blockchain network
|
||||
- `yarn deploy`: Deploys SE-2 default contract
|
||||
- `yarn start`: Starts the frontend
|
||||
- Write a Smart Contract (modify the deployment script in `packages/hardhat/deploy` if needed)
|
||||
- Deploy it locally (`yarn deploy`)
|
||||
- Go to the `http://locahost:3000/debug` page to interact with your contract with a nice UI
|
||||
- Iterate until you get the functionality you want in your contract
|
||||
- Write tests for the contract in `packages/hardhat/test`
|
||||
- Create your custom UI using all the SE-2 components, hooks, and utilities.
|
||||
- Deploy your Smart Contrac to a live network
|
||||
- Deploy your UI (`yarn vercel` or `yarn ipfs`)
|
||||
- You can tweak which network the frontend is pointing (and some other configurations) in `scaffold.config.ts`
|
||||
|
||||
## Smart Contract UI interactions guidelines
|
||||
SE-2 provides a set of hooks that facilitates contract interactions from the UI. It reads the contract data from `deployedContracts.ts` and `externalContracts.ts`, located in `packages/nextjs/contracts`.
|
||||
|
||||
### Reading data from a contract
|
||||
Use the `useScaffoldReadContract` (`packages/nextjs/hooks/scaffold-eth/useScaffoldReadContract.ts`) hook.
|
||||
|
||||
Example:
|
||||
```typescript
|
||||
const { data: someData } = useScaffoldReadContract({
|
||||
contractName: "YourContract",
|
||||
functionName: "functionName",
|
||||
args: [arg1, arg2], // optional
|
||||
});
|
||||
```
|
||||
|
||||
### Writing data to a contract
|
||||
Use the `useScaffoldWriteContract` (`packages/nextjs/hooks/scaffold-eth/useScaffoldWriteContract.ts`) hook.
|
||||
1. Initilize the hook with just the contract name
|
||||
2. Call the `writeContractAsync` function.
|
||||
|
||||
Example:
|
||||
```typescript
|
||||
const { writeContractAsync: writeYourContractAsync } = useScaffoldWriteContract(
|
||||
{ contractName: "YourContract" }
|
||||
);
|
||||
// Usage (this will send a write transaction to the contract)
|
||||
await writeContractAsync({
|
||||
functionName: "functionName",
|
||||
args: [arg1, arg2], // optional
|
||||
value: parseEther("0.1"), // optional, for payable functions
|
||||
});
|
||||
```
|
||||
|
||||
Never use any other patterns for contract interaction. The hooks are:
|
||||
- useScaffoldReadContract (for reading)
|
||||
- useScaffoldWriteContract (for writing)
|
||||
|
||||
### Reading events from a contract
|
||||
|
||||
Use the `useScaffoldEventHistory` (`packages/nextjs/hooks/scaffold-eth/useScaffoldEventHistory.ts`) hook.
|
||||
|
||||
Example:
|
||||
|
||||
```typescript
|
||||
const {
|
||||
data: events,
|
||||
isLoading,
|
||||
error,
|
||||
} = useScaffoldEventHistory({
|
||||
contractName: "YourContract",
|
||||
eventName: "GreetingChange",
|
||||
watch: true, // optional, if true, the hook will watch for new events
|
||||
});
|
||||
```
|
||||
|
||||
The `data` property consists of an array of events and can be displayed as:
|
||||
|
||||
```jsx
|
||||
<div>
|
||||
{events?.map((event) => (
|
||||
<div key={event.logIndex}>
|
||||
<p>{event.args.greetingSetter}</p>
|
||||
<p>{event.args.newGreeting}</p>
|
||||
<p>{event.args.premium}</p>
|
||||
<p>{event.args.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Other Hooks
|
||||
SE-2 also provides other hooks to interact with blockchain data: `useScaffoldWatchContractEvent`, `useScaffoldEventHistory`, `useDeployedContractInfo`, `useScaffoldContract`, `useTransactor`. They live under `packages/nextjs/hooks/scaffold-eth`.
|
||||
|
||||
## Display Components guidelines
|
||||
With the `@scaffold-ui/components` library, SE-2 provides a set of pre-built React components for common Ethereum use cases:
|
||||
|
||||
- `Address`: Always use this when displaying an ETH address
|
||||
- `AddressInput`: Always use this when users need to input an ETH address
|
||||
- `Balance`: Display the ETH/USDC balance of a given address
|
||||
- `EtherInput`: An extended number input with ETH/USD conversion.
|
||||
|
||||
For fully customizable components, you can use the hooks from the `@scaffold-ui/hooks` library to get the data you need.
|
||||
|
||||
Find the relevant information from the documentation and the codebase. Think step by step before answering the question.
|
||||
1
.cursorignore
Normal file
1
.cursorignore
Normal file
@@ -0,0 +1 @@
|
||||
*
|
||||
43
.github/workflows/lint.yaml
vendored
Normal file
43
.github/workflows/lint.yaml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
node: [lts/*]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@master
|
||||
|
||||
- name: Setup node env
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Run hardhat node, deploy contracts (& generate contracts typescript output)
|
||||
run: yarn chain & yarn deploy
|
||||
|
||||
- name: Run hardhat lint
|
||||
run: yarn hardhat:lint --max-warnings=0
|
||||
|
||||
- name: Run nextjs lint
|
||||
run: yarn next:lint --max-warnings=0
|
||||
|
||||
- name: Check typings on nextjs
|
||||
run: yarn next:check-types
|
||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# yarn
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# eslint
|
||||
.eslintcache
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# cli
|
||||
dist
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
yarn lint-staged --verbose
|
||||
21
.lintstagedrc.js
Normal file
21
.lintstagedrc.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const path = require("path");
|
||||
|
||||
const buildNextEslintCommand = (filenames) =>
|
||||
`yarn next:lint --fix --file ${filenames
|
||||
.map((f) => path.relative(path.join("packages", "nextjs"), f))
|
||||
.join(" --file ")}`;
|
||||
|
||||
const checkTypesNextCommand = () => "yarn next:check-types";
|
||||
|
||||
const buildHardhatEslintCommand = (filenames) =>
|
||||
`yarn hardhat:lint-staged --fix ${filenames
|
||||
.map((f) => path.relative(path.join("packages", "hardhat"), f))
|
||||
.join(" ")}`;
|
||||
|
||||
module.exports = {
|
||||
"packages/nextjs/**/*.{ts,tsx}": [
|
||||
buildNextEslintCommand,
|
||||
checkTypesNextCommand,
|
||||
],
|
||||
"packages/hardhat/**/*.{ts,tsx}": [buildHardhatEslintCommand],
|
||||
};
|
||||
541
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
541
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
9
.yarn/plugins/@yarnpkg/plugin-typescript.cjs
vendored
Normal file
9
.yarn/plugins/@yarnpkg/plugin-typescript.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
783
.yarn/releases/yarn-3.2.3.cjs
vendored
Executable file
783
.yarn/releases/yarn-3.2.3.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
13
.yarnrc.yml
Normal file
13
.yarnrc.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
enableColors: true
|
||||
|
||||
nmHoistingLimits: workspaces
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
|
||||
spec: "@yarnpkg/plugin-typescript"
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.2.3.cjs
|
||||
86
CONTRIBUTING.md
Normal file
86
CONTRIBUTING.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Welcome to Scaffold-ETH 2 Contributing Guide
|
||||
|
||||
Thank you for investing your time in contributing to Scaffold-ETH 2!
|
||||
|
||||
This guide aims to provide an overview of the contribution workflow to help us make the contribution process effective for everyone involved.
|
||||
|
||||
## About the Project
|
||||
|
||||
Scaffold-ETH 2 is a minimal and forkable repo providing builders with a starter kit to build decentralized applications on Ethereum.
|
||||
|
||||
Read the [README](README.md) to get an overview of the project.
|
||||
|
||||
### Vision
|
||||
|
||||
The goal of Scaffold-ETH 2 is to provide the primary building blocks for a decentralized application.
|
||||
|
||||
The repo can be forked to include integrations and more features, but we want to keep the master branch simple and minimal.
|
||||
|
||||
### Project Status
|
||||
|
||||
The project is under active development.
|
||||
|
||||
You can view the open Issues, follow the development process and contribute to the project.
|
||||
|
||||
## Getting started
|
||||
|
||||
You can contribute to this repo in many ways:
|
||||
|
||||
- Solve open issues
|
||||
- Report bugs or feature requests
|
||||
- Improve the documentation
|
||||
|
||||
Contributions are made via Issues and Pull Requests (PRs). A few general guidelines for contributions:
|
||||
|
||||
- Search for existing Issues and PRs before creating your own.
|
||||
- Contributions should only fix/add the functionality in the issue OR address style issues, not both.
|
||||
- If you're running into an error, please give context. Explain what you're trying to do and how to reproduce the error.
|
||||
- Please use the same formatting in the code repository. You can configure your IDE to do it by using the prettier / linting config files included in each package.
|
||||
- If applicable, please edit the README.md file to reflect the changes.
|
||||
|
||||
### Issues
|
||||
|
||||
Issues should be used to report problems, request a new feature, or discuss potential changes before a PR is created.
|
||||
|
||||
#### Solve an issue
|
||||
|
||||
Scan through our [existing issues](https://github.com/scaffold-eth/scaffold-eth-2/issues) to find one that interests you.
|
||||
|
||||
If a contributor is working on the issue, they will be assigned to the individual. If you find an issue to work on, you are welcome to assign it to yourself and open a PR with a fix for it.
|
||||
|
||||
#### Create a new issue
|
||||
|
||||
If a related issue doesn't exist, you can open a new issue.
|
||||
|
||||
Some tips to follow when you are creating an issue:
|
||||
|
||||
- Provide as much context as possible. Over-communicate to give the most details to the reader.
|
||||
- Include the steps to reproduce the issue or the reason for adding the feature.
|
||||
- Screenshots, videos etc., are highly appreciated.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
#### Pull Request Process
|
||||
|
||||
We follow the ["fork-and-pull" Git workflow](https://github.com/susam/gitpr)
|
||||
|
||||
1. Fork the repo
|
||||
2. Clone the project
|
||||
3. Create a new branch with a descriptive name
|
||||
4. Commit your changes to the new branch
|
||||
5. Push changes to your fork
|
||||
6. Open a PR in our repository and tag one of the maintainers to review your PR
|
||||
|
||||
Here are some tips for a high-quality pull request:
|
||||
|
||||
- Create a title for the PR that accurately defines the work done.
|
||||
- Structure the description neatly to make it easy to consume by the readers. For example, you can include bullet points and screenshots instead of having one large paragraph.
|
||||
- Add the link to the issue if applicable.
|
||||
- Have a good commit message that summarises the work done.
|
||||
|
||||
Once you submit your PR:
|
||||
|
||||
- We may ask questions, request additional information or ask for changes to be made before a PR can be merged. Please note that these are to make the PR clear for everyone involved and aims to create a frictionless interaction process.
|
||||
- As you update your PR and apply changes, mark each conversation resolved.
|
||||
|
||||
Once the PR is approved, we'll "squash-and-merge" to keep the git commit history clean.
|
||||
21
LICENCE
Normal file
21
LICENCE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 BuidlGuidl
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
63
package.json
Normal file
63
package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "se-2",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"account": "yarn hardhat:account",
|
||||
"account:generate": "yarn workspace @se-2/hardhat account:generate",
|
||||
"account:import": "yarn workspace @se-2/hardhat account:import",
|
||||
"account:reveal-pk": "yarn workspace @se-2/hardhat account:reveal-pk",
|
||||
"chain": "yarn hardhat:chain",
|
||||
"compile": "yarn hardhat:compile",
|
||||
"deploy": "yarn hardhat:deploy",
|
||||
"fork": "yarn hardhat:fork",
|
||||
"format": "yarn next:format && yarn hardhat:format",
|
||||
"generate": "yarn account:generate",
|
||||
"hardhat:account": "yarn workspace @se-2/hardhat account",
|
||||
"hardhat:chain": "yarn workspace @se-2/hardhat chain",
|
||||
"hardhat:check-types": "yarn workspace @se-2/hardhat check-types",
|
||||
"hardhat:clean": "yarn workspace @se-2/hardhat clean",
|
||||
"hardhat:compile": "yarn workspace @se-2/hardhat compile",
|
||||
"hardhat:deploy": "yarn workspace @se-2/hardhat deploy",
|
||||
"hardhat:flatten": "yarn workspace @se-2/hardhat flatten",
|
||||
"hardhat:fork": "yarn workspace @se-2/hardhat fork",
|
||||
"hardhat:format": "yarn workspace @se-2/hardhat format",
|
||||
"hardhat:generate": "yarn workspace @se-2/hardhat generate",
|
||||
"hardhat:hardhat-verify": "yarn workspace @se-2/hardhat hardhat-verify",
|
||||
"hardhat:lint": "yarn workspace @se-2/hardhat lint",
|
||||
"hardhat:lint-staged": "yarn workspace @se-2/hardhat lint-staged",
|
||||
"hardhat:test": "yarn workspace @se-2/hardhat test",
|
||||
"hardhat:verify": "yarn workspace @se-2/hardhat verify",
|
||||
"postinstall": "husky install",
|
||||
"ipfs": "yarn workspace @se-2/nextjs ipfs",
|
||||
"lint": "yarn next:lint && yarn hardhat:lint",
|
||||
"next:build": "yarn workspace @se-2/nextjs build",
|
||||
"next:check-types": "yarn workspace @se-2/nextjs check-types",
|
||||
"next:format": "yarn workspace @se-2/nextjs format",
|
||||
"next:lint": "yarn workspace @se-2/nextjs lint",
|
||||
"next:serve": "yarn workspace @se-2/nextjs serve",
|
||||
"precommit": "lint-staged",
|
||||
"simulate:optimistic": "yarn workspace @se-2/hardhat simulate:optimistic",
|
||||
"simulate:staking": "yarn workspace @se-2/hardhat simulate:staking",
|
||||
"simulate:whitelist": "yarn workspace @se-2/hardhat simulate:whitelist",
|
||||
"start": "yarn workspace @se-2/nextjs dev",
|
||||
"test": "yarn hardhat:test",
|
||||
"vercel": "yarn workspace @se-2/nextjs vercel",
|
||||
"vercel:login": "yarn workspace @se-2/nextjs vercel:login",
|
||||
"vercel:yolo": "yarn workspace @se-2/nextjs vercel:yolo",
|
||||
"verify": "yarn hardhat:verify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "~9.1.6",
|
||||
"lint-staged": "~13.2.2"
|
||||
},
|
||||
"packageManager": "yarn@3.2.3",
|
||||
"engines": {
|
||||
"node": ">=20.18.3"
|
||||
}
|
||||
}
|
||||
13
packages/hardhat/.env.example
Normal file
13
packages/hardhat/.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
# Template for Hardhat environment variables.
|
||||
|
||||
# To use this template, copy this file, rename it .env, and fill in the values.
|
||||
|
||||
# If not set, we provide default values (check `hardhat.config.ts`) so developers can start prototyping out of the box,
|
||||
# but we recommend getting your own API Keys for Production Apps.
|
||||
|
||||
# To access the values stored in this .env file you can use: process.env.VARIABLENAME
|
||||
ALCHEMY_API_KEY=
|
||||
ETHERSCAN_V2_API_KEY=
|
||||
|
||||
# Don't fill this value manually, run yarn generate to generate a new account or yarn account:import to import an existing PK.
|
||||
DEPLOYER_PRIVATE_KEY_ENCRYPTED=
|
||||
30
packages/hardhat/.gitignore
vendored
Normal file
30
packages/hardhat/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# env files
|
||||
.env
|
||||
|
||||
# coverage
|
||||
coverage
|
||||
coverage.json
|
||||
|
||||
# typechain
|
||||
typechain
|
||||
typechain-types
|
||||
|
||||
# hardhat files
|
||||
cache
|
||||
artifacts
|
||||
|
||||
# zkSync files
|
||||
artifacts-zk
|
||||
cache-zk
|
||||
|
||||
# deployments
|
||||
deployments/localhost
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# other
|
||||
temp
|
||||
18
packages/hardhat/.prettierrc.json
Normal file
18
packages/hardhat/.prettierrc.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-solidity"],
|
||||
"arrowParens": "avoid",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.sol",
|
||||
"options": {
|
||||
"printWidth": 120,
|
||||
"tabWidth": 4,
|
||||
"singleQuote": false,
|
||||
"bracketSpacing": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
73
packages/hardhat/contracts/00_Whitelist/SimpleOracle.sol
Normal file
73
packages/hardhat/contracts/00_Whitelist/SimpleOracle.sol
Normal file
@@ -0,0 +1,73 @@
|
||||
//SPDX-License-Identifier: MIT
|
||||
pragma solidity >=0.8.0 <0.9.0;
|
||||
|
||||
contract SimpleOracle {
|
||||
/////////////////
|
||||
/// Errors //////
|
||||
/////////////////
|
||||
|
||||
error OnlyOwner();
|
||||
|
||||
//////////////////////
|
||||
/// State Variables //
|
||||
//////////////////////
|
||||
|
||||
uint256 public price;
|
||||
uint256 public timestamp;
|
||||
address public owner;
|
||||
|
||||
////////////////
|
||||
/// Events /////
|
||||
////////////////
|
||||
|
||||
event PriceUpdated(uint256 newPrice);
|
||||
|
||||
///////////////////
|
||||
/// Constructor ///
|
||||
///////////////////
|
||||
|
||||
constructor(address _owner) {
|
||||
owner = _owner;
|
||||
}
|
||||
|
||||
///////////////////
|
||||
/// Modifiers /////
|
||||
///////////////////
|
||||
|
||||
/**
|
||||
* @notice Modifier to restrict function access to the contract owner
|
||||
* @dev Currently disabled to make it easy for you to impersonate the owner
|
||||
*/
|
||||
modifier onlyOwner() {
|
||||
// Intentionally removing the owner requirement to make it easy for you to impersonate the owner
|
||||
// if (msg.sender != owner) revert OnlyOwner();
|
||||
_;
|
||||
}
|
||||
|
||||
///////////////////
|
||||
/// Functions /////
|
||||
///////////////////
|
||||
|
||||
/**
|
||||
* @notice Updates the oracle price with a new value (only contract owner)
|
||||
* @dev Sets the price and records the current block timestamp for freshness tracking.
|
||||
* Emits PriceUpdated event upon successful update.
|
||||
* @param _newPrice The new price value to set for this oracle
|
||||
*/
|
||||
function setPrice(uint256 _newPrice) public onlyOwner {
|
||||
price = _newPrice;
|
||||
timestamp = block.timestamp;
|
||||
emit PriceUpdated(_newPrice);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Returns the current price and its timestamp
|
||||
* @dev Provides both the stored price value and when it was last updated.
|
||||
* Used by aggregators to determine price freshness.
|
||||
* @return price The current price stored in this oracle
|
||||
* @return timestamp The block timestamp when the price was last updated
|
||||
*/
|
||||
function getPrice() public view returns (uint256, uint256) {
|
||||
return (price, timestamp);
|
||||
}
|
||||
}
|
||||
89
packages/hardhat/contracts/00_Whitelist/WhitelistOracle.sol
Normal file
89
packages/hardhat/contracts/00_Whitelist/WhitelistOracle.sol
Normal file
@@ -0,0 +1,89 @@
|
||||
//SPDX-License-Identifier: MIT
|
||||
pragma solidity >=0.8.0 <0.9.0;
|
||||
|
||||
import "./SimpleOracle.sol";
|
||||
import { StatisticsUtils } from "../utils/StatisticsUtils.sol";
|
||||
|
||||
contract WhitelistOracle {
|
||||
using StatisticsUtils for uint256[];
|
||||
|
||||
/////////////////
|
||||
/// Errors //////
|
||||
/////////////////
|
||||
|
||||
error OnlyOwner();
|
||||
error IndexOutOfBounds();
|
||||
error NoOraclesAvailable();
|
||||
|
||||
//////////////////////
|
||||
/// State Variables //
|
||||
//////////////////////
|
||||
|
||||
address public owner;
|
||||
SimpleOracle[] public oracles;
|
||||
uint256 public constant STALE_DATA_WINDOW = 24 seconds;
|
||||
|
||||
////////////////
|
||||
/// Events /////
|
||||
////////////////
|
||||
|
||||
event OracleAdded(address oracleAddress, address oracleOwner);
|
||||
event OracleRemoved(address oracleAddress);
|
||||
|
||||
///////////////////
|
||||
/// Modifiers /////
|
||||
///////////////////
|
||||
|
||||
/**
|
||||
* @notice Modifier to restrict function access to the contract owner
|
||||
* @dev Currently disabled to make it easy for you to impersonate the owner
|
||||
*/
|
||||
modifier onlyOwner() {
|
||||
// if (msg.sender != owner) revert OnlyOwner();
|
||||
_;
|
||||
}
|
||||
|
||||
///////////////////
|
||||
/// Constructor ///
|
||||
///////////////////
|
||||
|
||||
constructor() {
|
||||
owner = msg.sender;
|
||||
}
|
||||
|
||||
///////////////////
|
||||
/// Functions /////
|
||||
///////////////////
|
||||
|
||||
/**
|
||||
* @notice Adds a new oracle to the whitelist by deploying a SimpleOracle contract (only contract owner)
|
||||
* @dev Creates a new SimpleOracle instance and adds it to the oracles array.
|
||||
* @param _owner The address that will own the newly created oracle and can update its price
|
||||
*/
|
||||
function addOracle(address _owner) public onlyOwner {}
|
||||
|
||||
/**
|
||||
* @notice Removes an oracle from the whitelist by its array index (only contract owner)
|
||||
* @dev Uses swap-and-pop pattern for gas-efficient removal. Order is not preserved.
|
||||
* Reverts with IndexOutOfBounds, if the provided index is >= oracles.length.
|
||||
* @param index The index of the oracle to remove from the oracles array
|
||||
*/
|
||||
function removeOracle(uint256 index) public onlyOwner {}
|
||||
|
||||
/**
|
||||
* @notice Returns the aggregated price from all active oracles using median calculation
|
||||
* @dev Filters oracles with timestamps older than STALE_DATA_WINDOW, then calculates median
|
||||
* of remaining valid prices. Uses StatisticsUtils for sorting and median calculation.
|
||||
* @return The median price from all active oracles
|
||||
*/
|
||||
function getPrice() public view returns (uint256) {}
|
||||
|
||||
/**
|
||||
* @notice Returns the addresses of all oracles that have updated their price within the last STALE_DATA_WINDOW
|
||||
* @dev Iterates through all oracles and filters those with recent timestamps (within STALE_DATA_WINDOW).
|
||||
* Uses a temporary array to collect active nodes, then creates a right-sized return array
|
||||
* for gas optimization.
|
||||
* @return An array of addresses representing the currently active oracle contracts
|
||||
*/
|
||||
function getActiveOracleNodes() public view returns (address[] memory) {}
|
||||
}
|
||||
65
packages/hardhat/contracts/01_Staking/OracleToken.sol
Normal file
65
packages/hardhat/contracts/01_Staking/OracleToken.sol
Normal file
@@ -0,0 +1,65 @@
|
||||
// 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 {
|
||||
//////////////////
|
||||
/// Constants ////
|
||||
//////////////////
|
||||
|
||||
// 0.5 ETH = 100 ORA => 1 ETH = 200 ORA (18 decimals)
|
||||
uint256 public constant ORA_PER_ETH = 200;
|
||||
|
||||
//////////////////////
|
||||
/// State Variables //
|
||||
//////////////////////
|
||||
|
||||
////////////////
|
||||
/// Events /////
|
||||
////////////////
|
||||
|
||||
event OraPurchased(address indexed buyer, uint256 ethIn, uint256 oraOut);
|
||||
event EthWithdrawn(address indexed to, uint256 amount);
|
||||
|
||||
/////////////////
|
||||
/// Errors //////
|
||||
/////////////////
|
||||
|
||||
error EthTransferFailed();
|
||||
|
||||
constructor() ERC20("Oracle Token", "ORA") Ownable(msg.sender) {
|
||||
// Mint initial supply to the contract deployer
|
||||
_mint(msg.sender, 1000000000000 ether);
|
||||
}
|
||||
|
||||
function mint(address to, uint256 amount) public onlyOwner {
|
||||
_mint(to, amount);
|
||||
}
|
||||
|
||||
function burn(uint256 amount) public {
|
||||
_burn(msg.sender, amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Buy ORA at a fixed rate by sending ETH. Mints directly to the buyer.
|
||||
*/
|
||||
receive() external payable {
|
||||
_buy(msg.sender);
|
||||
}
|
||||
|
||||
function buy() external payable {
|
||||
_buy(msg.sender);
|
||||
}
|
||||
|
||||
function quoteOra(uint256 ethAmountWei) public pure returns (uint256) {
|
||||
return ethAmountWei * ORA_PER_ETH;
|
||||
}
|
||||
|
||||
function _buy(address buyer) internal {
|
||||
uint256 oraOut = quoteOra(msg.value);
|
||||
_mint(buyer, oraOut);
|
||||
emit OraPurchased(buyer, msg.value, oraOut);
|
||||
}
|
||||
}
|
||||
236
packages/hardhat/contracts/01_Staking/StakingOracle.sol
Normal file
236
packages/hardhat/contracts/01_Staking/StakingOracle.sol
Normal file
@@ -0,0 +1,236 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity >=0.8.0 <0.9.0;
|
||||
|
||||
import { ORA } from "./OracleToken.sol";
|
||||
import { StatisticsUtils } from "../utils/StatisticsUtils.sol";
|
||||
|
||||
contract StakingOracle {
|
||||
using StatisticsUtils for uint256[];
|
||||
|
||||
/////////////////
|
||||
/// Errors //////
|
||||
/////////////////
|
||||
|
||||
error NodeNotRegistered();
|
||||
error InsufficientStake();
|
||||
error NodeAlreadyRegistered();
|
||||
error NoRewardsAvailable();
|
||||
error OnlyPastBucketsAllowed();
|
||||
error NodeAlreadySlashed();
|
||||
error AlreadyReportedInCurrentBucket();
|
||||
error NotDeviated();
|
||||
error WaitingPeriodNotOver();
|
||||
error InvalidPrice();
|
||||
error IndexOutOfBounds();
|
||||
error NodeNotAtGivenIndex();
|
||||
error TransferFailed();
|
||||
error MedianNotRecorded();
|
||||
error BucketMedianAlreadyRecorded();
|
||||
error NodeDidNotReport();
|
||||
|
||||
//////////////////////
|
||||
/// State Variables //
|
||||
//////////////////////
|
||||
|
||||
ORA public oracleToken;
|
||||
|
||||
struct OracleNode {
|
||||
uint256 stakedAmount;
|
||||
uint256 lastReportedBucket;
|
||||
uint256 reportCount;
|
||||
uint256 claimedReportCount;
|
||||
uint256 firstBucket; // block when node registered
|
||||
bool active;
|
||||
}
|
||||
|
||||
struct BlockBucket {
|
||||
mapping(address => bool) slashedOffenses;
|
||||
address[] reporters;
|
||||
uint256[] prices;
|
||||
uint256 medianPrice;
|
||||
}
|
||||
|
||||
mapping(address => OracleNode) public nodes;
|
||||
mapping(uint256 => BlockBucket) public blockBuckets; // one bucket per 24 blocks
|
||||
address[] public nodeAddresses;
|
||||
|
||||
uint256 public constant MINIMUM_STAKE = 100 ether;
|
||||
uint256 public constant BUCKET_WINDOW = 24; // 24 blocks
|
||||
uint256 public constant SLASHER_REWARD_PERCENTAGE = 10;
|
||||
uint256 public constant REWARD_PER_REPORT = 1 ether; // ORA Token reward per report
|
||||
uint256 public constant INACTIVITY_PENALTY = 1 ether;
|
||||
uint256 public constant MISREPORT_PENALTY = 100 ether;
|
||||
uint256 public constant MAX_DEVIATION_BPS = 1000; // 10% default threshold
|
||||
uint256 public constant WAITING_PERIOD = 2; // 2 buckets after last report before exit allowed
|
||||
|
||||
////////////////
|
||||
/// Events /////
|
||||
////////////////
|
||||
|
||||
event NodeRegistered(address indexed node, uint256 stakedAmount);
|
||||
event PriceReported(address indexed node, uint256 price, uint256 bucketNumber);
|
||||
event BucketMedianRecorded(uint256 indexed bucketNumber, uint256 medianPrice);
|
||||
event NodeSlashed(address indexed node, uint256 amount);
|
||||
event NodeRewarded(address indexed node, uint256 amount);
|
||||
event StakeAdded(address indexed node, uint256 amount);
|
||||
event NodeExited(address indexed node, uint256 amount);
|
||||
|
||||
///////////////////
|
||||
/// Modifiers /////
|
||||
///////////////////
|
||||
|
||||
/**
|
||||
* @notice Modifier to restrict function access to registered oracle nodes
|
||||
* @dev Checks if the sender has a registered node in the mapping
|
||||
*/
|
||||
modifier onlyNode() {
|
||||
if (nodes[msg.sender].active == false) revert NodeNotRegistered();
|
||||
_;
|
||||
}
|
||||
|
||||
///////////////////
|
||||
/// Constructor ///
|
||||
///////////////////
|
||||
|
||||
constructor(address oraTokenAddress) {
|
||||
oracleToken = ORA(payable(oraTokenAddress));
|
||||
}
|
||||
|
||||
///////////////////
|
||||
/// Functions /////
|
||||
///////////////////
|
||||
|
||||
/**
|
||||
* @notice Registers a new oracle node with initial ORA token stake
|
||||
* @dev Creates a new OracleNode struct and adds the sender to the nodeAddresses array.
|
||||
* Requires minimum stake amount and prevents duplicate registrations.
|
||||
*/
|
||||
function registerNode(uint256 amount) public {}
|
||||
|
||||
/**
|
||||
* @notice Updates the price reported by an oracle node (only registered nodes)
|
||||
* @dev Updates the node's lastReportedBucket and price in that bucket. Requires sufficient stake.
|
||||
* Enforces that previous report's bucket must have its median recorded before allowing new report.
|
||||
* This creates a chain of finalized buckets, ensuring all past reports are accountable.
|
||||
* @param price The new price value to report
|
||||
*/
|
||||
function reportPrice(uint256 price) public onlyNode {}
|
||||
|
||||
/**
|
||||
* @notice Allows active and inactive nodes to claim accumulated ORA token rewards
|
||||
* @dev Calculates rewards based on time elapsed since last claim.
|
||||
*/
|
||||
function claimReward() public {}
|
||||
|
||||
/**
|
||||
* @notice Allows a registered node to increase its ORA token stake
|
||||
*/
|
||||
function addStake(uint256 amount) public onlyNode {}
|
||||
|
||||
/**
|
||||
* @notice Records the median price for a bucket once sufficient reports are available
|
||||
* @dev Anyone who uses the oracle's price feed can call this function to record the median price for a bucket.
|
||||
* @param bucketNumber The bucket number to finalize
|
||||
*/
|
||||
function recordBucketMedian(uint256 bucketNumber) public {}
|
||||
|
||||
/**
|
||||
* @notice Slashes a node for giving a price that is deviated too far from the average
|
||||
* @param nodeToSlash The address of the node to slash
|
||||
* @param bucketNumber The bucket number to slash the node from
|
||||
* @param reportIndex The index of node in the prices and reporters arrays
|
||||
* @param nodeAddressesIndex The index of the node to slash in the nodeAddresses array
|
||||
*/
|
||||
function slashNode(
|
||||
address nodeToSlash,
|
||||
uint256 bucketNumber,
|
||||
uint256 reportIndex,
|
||||
uint256 nodeAddressesIndex
|
||||
) public {}
|
||||
|
||||
/**
|
||||
* @notice Allows a registered node to exit the system and withdraw their stake
|
||||
* @dev Removes the node from the system and sends the stake to the node.
|
||||
* Requires that the the initial waiting period has passed to ensure the
|
||||
* node has been slashed if it reported a bad price before allowing it to exit.
|
||||
* @param index The index of the node to remove in nodeAddresses
|
||||
*/
|
||||
function exitNode(uint256 index) public onlyNode {}
|
||||
|
||||
////////////////////////
|
||||
/// View Functions /////
|
||||
////////////////////////
|
||||
|
||||
/**
|
||||
* @notice Returns the current bucket number
|
||||
* @dev Returns the current bucket number based on the block number
|
||||
* @return The current bucket number
|
||||
*/
|
||||
function getCurrentBucketNumber() public view returns (uint256) {
|
||||
return (block.number / BUCKET_WINDOW) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Returns the list of registered oracle node addresses
|
||||
* @return Array of registered oracle node addresses
|
||||
*/
|
||||
function getNodeAddresses() public view returns (address[] memory) {}
|
||||
|
||||
/**
|
||||
* @notice Returns the stored median price from the most recently completed bucket
|
||||
* @dev Requires that the median for the bucket be recorded via recordBucketMedian
|
||||
* @return The median price for the last finalized bucket
|
||||
*/
|
||||
function getLatestPrice() public view returns (uint256) {}
|
||||
|
||||
/**
|
||||
* @notice Returns the stored median price from a specified bucket
|
||||
* @param bucketNumber The bucket number to read the median price from
|
||||
* @return The median price stored for the bucket
|
||||
*/
|
||||
function getPastPrice(uint256 bucketNumber) public view returns (uint256) {}
|
||||
|
||||
/**
|
||||
* @notice Returns the price and slashed status of a node at a given bucket
|
||||
* @param nodeAddress The address of the node to get the data for
|
||||
* @param bucketNumber The bucket number to get the data from
|
||||
* @return price The price of the node at the specified bucket
|
||||
* @return slashed The slashed status of the node at the specified bucket
|
||||
*/
|
||||
function getSlashedStatus(
|
||||
address nodeAddress,
|
||||
uint256 bucketNumber
|
||||
) public view returns (uint256 price, bool slashed) {}
|
||||
|
||||
/**
|
||||
* @notice Returns the effective stake accounting for inactivity penalties via missed buckets
|
||||
* @dev Effective stake = stakedAmount - (missedBuckets * INACTIVITY_PENALTY), floored at 0
|
||||
*/
|
||||
function getEffectiveStake(address nodeAddress) public view returns (uint256) {}
|
||||
|
||||
/**
|
||||
* @notice Returns the addresses of nodes in a bucket whose reported price deviates beyond the threshold
|
||||
* @param bucketNumber The bucket number to get the outliers from
|
||||
* @return Array of node addresses considered outliers
|
||||
*/
|
||||
function getOutlierNodes(uint256 bucketNumber) public view returns (address[] memory) {}
|
||||
|
||||
//////////////////////////
|
||||
/// Internal Functions ///
|
||||
//////////////////////////
|
||||
|
||||
/**
|
||||
* @notice Removes a node from the nodeAddresses array
|
||||
* @param nodeAddress The address of the node to remove
|
||||
* @param index The index of the node to remove
|
||||
*/
|
||||
function _removeNode(address nodeAddress, uint256 index) internal {}
|
||||
|
||||
/**
|
||||
* @notice Checks if the price deviation is greater than the threshold
|
||||
* @param reportedPrice The price reported by the node
|
||||
* @param medianPrice The average price of the bucket
|
||||
* @return True if the price deviation is greater than the threshold, false otherwise
|
||||
*/
|
||||
function _checkPriceDeviated(uint256 reportedPrice, uint256 medianPrice) internal pure returns (bool) {}
|
||||
}
|
||||
42
packages/hardhat/contracts/02_Optimistic/Decider.sol
Normal file
42
packages/hardhat/contracts/02_Optimistic/Decider.sol
Normal file
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity >=0.8.0 <0.9.0;
|
||||
|
||||
contract Decider {
|
||||
address public owner;
|
||||
IOptimisticOracle public oracle;
|
||||
|
||||
event DisputeSettled(uint256 indexed assertionId, bool resolvedValue);
|
||||
|
||||
constructor(address _oracle) {
|
||||
owner = msg.sender;
|
||||
oracle = IOptimisticOracle(_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 = IOptimisticOracle(newOracle);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Allow the contract to receive ETH
|
||||
*/
|
||||
receive() external payable {}
|
||||
}
|
||||
|
||||
interface IOptimisticOracle {
|
||||
function settleAssertion(uint256, bool) external;
|
||||
}
|
||||
211
packages/hardhat/contracts/02_Optimistic/OptimisticOracle.sol
Normal file
211
packages/hardhat/contracts/02_Optimistic/OptimisticOracle.sol
Normal file
@@ -0,0 +1,211 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity >=0.8.0 <0.9.0;
|
||||
|
||||
contract OptimisticOracle {
|
||||
////////////////
|
||||
/// Enums //////
|
||||
////////////////
|
||||
|
||||
enum State {
|
||||
Invalid,
|
||||
Asserted,
|
||||
Proposed,
|
||||
Disputed,
|
||||
Settled,
|
||||
Expired
|
||||
}
|
||||
|
||||
/////////////////
|
||||
/// Errors //////
|
||||
/////////////////
|
||||
|
||||
error AssertionNotFound();
|
||||
error AssertionProposed();
|
||||
error InvalidValue();
|
||||
error InvalidTime();
|
||||
error ProposalDisputed();
|
||||
error NotProposedAssertion();
|
||||
error AlreadyClaimed();
|
||||
error AlreadySettled();
|
||||
error AwaitingDecider();
|
||||
error NotDisputedAssertion();
|
||||
error OnlyDecider();
|
||||
error OnlyOwner();
|
||||
error TransferFailed();
|
||||
|
||||
//////////////////////
|
||||
/// State Variables //
|
||||
//////////////////////
|
||||
|
||||
struct EventAssertion {
|
||||
address asserter;
|
||||
address proposer;
|
||||
address disputer;
|
||||
bool proposedOutcome;
|
||||
bool resolvedOutcome;
|
||||
uint256 reward;
|
||||
uint256 bond;
|
||||
uint256 startTime;
|
||||
uint256 endTime;
|
||||
bool claimed;
|
||||
address winner;
|
||||
string description;
|
||||
}
|
||||
|
||||
uint256 public constant MINIMUM_ASSERTION_WINDOW = 3 minutes;
|
||||
uint256 public constant DISPUTE_WINDOW = 3 minutes;
|
||||
address public decider;
|
||||
address public owner;
|
||||
uint256 public nextAssertionId = 1;
|
||||
mapping(uint256 => EventAssertion) public assertions;
|
||||
|
||||
////////////////
|
||||
/// Events /////
|
||||
////////////////
|
||||
|
||||
event EventAsserted(uint256 assertionId, address asserter, string description, uint256 reward);
|
||||
event OutcomeProposed(uint256 assertionId, address proposer, bool outcome);
|
||||
event OutcomeDisputed(uint256 assertionId, address disputer);
|
||||
event AssertionSettled(uint256 assertionId, bool outcome, address winner);
|
||||
event DeciderUpdated(address oldDecider, address newDecider);
|
||||
event RewardClaimed(uint256 assertionId, address winner, uint256 amount);
|
||||
event RefundClaimed(uint256 assertionId, address asserter, uint256 amount);
|
||||
|
||||
///////////////////
|
||||
/// Modifiers /////
|
||||
///////////////////
|
||||
|
||||
/**
|
||||
* @notice Modifier to restrict function access to the designated decider
|
||||
* @dev Ensures only the decider can settle disputed assertions
|
||||
*/
|
||||
modifier onlyDecider() {
|
||||
if (msg.sender != decider) revert OnlyDecider();
|
||||
_;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Modifier to restrict function access to the contract owner
|
||||
* @dev Ensures only the owner can update critical contract parameters
|
||||
*/
|
||||
modifier onlyOwner() {
|
||||
if (msg.sender != owner) revert OnlyOwner();
|
||||
_;
|
||||
}
|
||||
|
||||
///////////////////
|
||||
/// Constructor ///
|
||||
///////////////////
|
||||
|
||||
constructor(address _decider) {
|
||||
decider = _decider;
|
||||
owner = msg.sender;
|
||||
}
|
||||
|
||||
///////////////////
|
||||
/// Functions /////
|
||||
///////////////////
|
||||
|
||||
/**
|
||||
* @notice Updates the decider address (only contract owner)
|
||||
* @dev Changes the address authorized to settle disputed assertions.
|
||||
* Emits DeciderUpdated event with old and new addresses.
|
||||
* @param _decider The new address that will act as decider for disputed assertions
|
||||
*/
|
||||
function setDecider(address _decider) external onlyOwner {
|
||||
address oldDecider = address(decider);
|
||||
decider = _decider;
|
||||
emit DeciderUpdated(oldDecider, _decider);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Returns the complete assertion details for a given assertion ID
|
||||
* @dev Provides access to all fields of the EventAssertion struct
|
||||
* @param assertionId The unique identifier of the assertion to retrieve
|
||||
* @return The complete EventAssertion struct containing all assertion data
|
||||
*/
|
||||
function getAssertion(uint256 assertionId) external view returns (EventAssertion memory) {
|
||||
return assertions[assertionId];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Creates a new assertion about an event with a true/false outcome
|
||||
* @dev Requires ETH payment as reward for correct proposers. Bond requirement is 2x the reward.
|
||||
* Sets default timestamps if not provided. Validates timing requirements.
|
||||
* @param description Human-readable description of the event (e.g. "Did X happen by time Y?")
|
||||
* @param startTime When proposals can begin (0 for current block timestamp)
|
||||
* @param endTime When the assertion expires (0 for startTime + minimum window)
|
||||
* @return The unique assertion ID for the newly created assertion
|
||||
*/
|
||||
function assertEvent(
|
||||
string memory description,
|
||||
uint256 startTime,
|
||||
uint256 endTime
|
||||
) external payable returns (uint256) {}
|
||||
|
||||
/**
|
||||
* @notice Proposes the outcome (true or false) for an asserted event
|
||||
* @dev Requires bonding ETH equal to 2x the original reward. Sets dispute window deadline.
|
||||
* Can only be called once per assertion and within the assertion time window.
|
||||
* @param assertionId The unique identifier of the assertion to propose an outcome for
|
||||
* @param outcome The proposed boolean outcome (true or false) for the event
|
||||
*/
|
||||
function proposeOutcome(uint256 assertionId, bool outcome) external payable {}
|
||||
|
||||
/**
|
||||
* @notice Disputes a proposed outcome by bonding ETH
|
||||
* @dev Requires bonding ETH equal to the bond amount. Can only dispute once per assertion
|
||||
* and must be within the dispute window after proposal.
|
||||
* @param assertionId The unique identifier of the assertion to dispute
|
||||
*/
|
||||
function disputeOutcome(uint256 assertionId) external payable {}
|
||||
|
||||
/**
|
||||
* @notice Claims reward for undisputed assertions after dispute window expires
|
||||
* @dev Anyone can trigger this function. Transfers reward + bond to the proposer.
|
||||
* Can only be called after dispute window has passed without disputes.
|
||||
* @param assertionId The unique identifier of the assertion to claim rewards for
|
||||
*/
|
||||
function claimUndisputedReward(uint256 assertionId) external {}
|
||||
|
||||
/**
|
||||
* @notice Claims reward for disputed assertions after decider settlement
|
||||
* @dev Anyone can trigger this function. Pays decider fee and transfers remaining rewards to winner.
|
||||
* Can only be called after decider has settled the dispute.
|
||||
* @param assertionId The unique identifier of the disputed assertion to claim rewards for
|
||||
*/
|
||||
function claimDisputedReward(uint256 assertionId) external {}
|
||||
|
||||
/**
|
||||
* @notice Claims refund for assertions that receive no proposals before deadline
|
||||
* @dev Anyone can trigger this function. Returns the original reward to the asserter.
|
||||
* Can only be called after assertion deadline has passed without any proposals.
|
||||
* @param assertionId The unique identifier of the expired assertion to claim refund for
|
||||
*/
|
||||
function claimRefund(uint256 assertionId) external {}
|
||||
|
||||
/**
|
||||
* @notice Resolves disputed assertions by determining the correct outcome (only decider)
|
||||
* @dev Sets the resolved outcome and determines winner based on proposal accuracy.
|
||||
* @param assertionId The unique identifier of the disputed assertion to settle
|
||||
* @param resolvedOutcome The decider's determination of the true outcome
|
||||
*/
|
||||
function settleAssertion(uint256 assertionId, bool resolvedOutcome) external onlyDecider {}
|
||||
|
||||
/**
|
||||
* @notice Returns the current state of an assertion based on its lifecycle stage
|
||||
* @dev Evaluates assertion progress through states: Invalid, Asserted, Proposed, Disputed, Settled, Expired
|
||||
* @param assertionId The unique identifier of the assertion to check state for
|
||||
* @return The current State enum value representing the assertion's status
|
||||
*/
|
||||
function getState(uint256 assertionId) external view returns (State) {}
|
||||
|
||||
/**
|
||||
* @notice Returns the final resolved outcome of a settled assertion
|
||||
* @dev For undisputed assertions, returns the proposed outcome after dispute window.
|
||||
* For disputed assertions, returns the decider's resolved outcome.
|
||||
* @param assertionId The unique identifier of the assertion to get resolution for
|
||||
* @return The final boolean outcome of the assertion
|
||||
*/
|
||||
function getResolution(uint256 assertionId) external view returns (bool) {}
|
||||
}
|
||||
54
packages/hardhat/contracts/utils/StatisticsUtils.sol
Normal file
54
packages/hardhat/contracts/utils/StatisticsUtils.sol
Normal file
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity >=0.8.0 <0.9.0;
|
||||
|
||||
library StatisticsUtils {
|
||||
/////////////////
|
||||
/// Errors //////
|
||||
/////////////////
|
||||
|
||||
error EmptyArray();
|
||||
|
||||
///////////////////
|
||||
/// Functions /////
|
||||
///////////////////
|
||||
|
||||
/**
|
||||
* @notice Sorts an array of uint256 values in ascending order using selection sort
|
||||
* @dev Uses selection sort algorithm which is not gas-efficient but acceptable for small arrays.
|
||||
* This implementation mimics the early MakerDAO Medianizer exactly.
|
||||
* Modifies the input array in-place.
|
||||
* @param arr The array of uint256 values to sort in ascending order
|
||||
*/
|
||||
function sort(uint256[] memory arr) internal pure {
|
||||
uint256 n = arr.length;
|
||||
for (uint256 i = 0; i < n; i++) {
|
||||
uint256 minIndex = i;
|
||||
for (uint256 j = i + 1; j < n; j++) {
|
||||
if (arr[j] < arr[minIndex]) {
|
||||
minIndex = j;
|
||||
}
|
||||
}
|
||||
if (minIndex != i) {
|
||||
(arr[i], arr[minIndex]) = (arr[minIndex], arr[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notice Calculates the median value from a sorted array of uint256 values
|
||||
* @dev For arrays with even length, returns the average of the two middle elements.
|
||||
* For arrays with odd length, returns the middle element.
|
||||
* Assumes the input array is already sorted in ascending order.
|
||||
* @param arr The sorted array of uint256 values to calculate median from
|
||||
* @return The median value as a uint256
|
||||
*/
|
||||
function getMedian(uint256[] memory arr) internal pure returns (uint256) {
|
||||
uint256 length = arr.length;
|
||||
if (length == 0) revert EmptyArray();
|
||||
if (length % 2 == 0) {
|
||||
return (arr[length / 2 - 1] + arr[length / 2]) / 2;
|
||||
} else {
|
||||
return arr[length / 2];
|
||||
}
|
||||
}
|
||||
}
|
||||
121
packages/hardhat/deploy/00_deploy_whitelist.ts
Normal file
121
packages/hardhat/deploy/00_deploy_whitelist.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { HardhatRuntimeEnvironment } from "hardhat/types";
|
||||
import { DeployFunction } from "hardhat-deploy/types";
|
||||
import { decodeEventLog } from "viem";
|
||||
import { fetchPriceFromUniswap } from "../scripts/fetchPriceFromUniswap";
|
||||
|
||||
/**
|
||||
* Deploys a WhitelistOracle contract and creates SimpleOracle instances through it
|
||||
*
|
||||
* @param hre HardhatRuntimeEnvironment object.
|
||||
*/
|
||||
const deployWhitelistOracleContracts: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
|
||||
const { deployer } = await hre.getNamedAccounts();
|
||||
const { deploy } = hre.deployments;
|
||||
const { viem } = hre;
|
||||
|
||||
const publicClient = await viem.getPublicClient();
|
||||
|
||||
console.log("Deploying WhitelistOracle contract...");
|
||||
const whitelistOracleDeployment = await deploy("WhitelistOracle", {
|
||||
from: deployer,
|
||||
args: [],
|
||||
log: true,
|
||||
autoMine: false,
|
||||
});
|
||||
const whitelistOracleAddress = whitelistOracleDeployment.address as `0x${string}`;
|
||||
const whitelistOracleAbi = whitelistOracleDeployment.abi;
|
||||
|
||||
// Skip the rest of the setup if we are on a live network
|
||||
if (hre.network.name === "localhost") {
|
||||
// Get 10 wallet clients (accounts) to be oracle owners
|
||||
const accounts = await viem.getWalletClients();
|
||||
const nodeAccounts = accounts.slice(0, 10);
|
||||
|
||||
console.log("Creating SimpleOracle instances through WhitelistOracle...");
|
||||
const deployerAccount = accounts.find(a => a.account.address.toLowerCase() === deployer.toLowerCase());
|
||||
if (!deployerAccount) throw new Error("Deployer account not found in wallet clients");
|
||||
|
||||
// Create SimpleOracle instances through WhitelistOracle.addOracle() sequentially
|
||||
// (parallel nonce assignment doesn't work reliably with automining)
|
||||
const addOracleReceipts = [];
|
||||
for (let i = 0; i < nodeAccounts.length; i++) {
|
||||
const ownerAddress = nodeAccounts[i].account.address;
|
||||
console.log(`Creating SimpleOracle ${i + 1}/10 with owner: ${ownerAddress}`);
|
||||
const txHash = await deployerAccount.writeContract({
|
||||
address: whitelistOracleAddress,
|
||||
abi: whitelistOracleAbi,
|
||||
functionName: "addOracle",
|
||||
args: [ownerAddress],
|
||||
});
|
||||
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
|
||||
addOracleReceipts.push(receipt);
|
||||
}
|
||||
|
||||
// Map owner => created oracle address from events
|
||||
const ownerToOracleAddress = new Map<string, string>();
|
||||
for (const receipt of addOracleReceipts) {
|
||||
const oracleAddedEvent = receipt.logs.find(log => {
|
||||
try {
|
||||
const decoded = decodeEventLog({
|
||||
abi: whitelistOracleAbi,
|
||||
data: log.data,
|
||||
topics: log.topics,
|
||||
}) as { eventName: string; args: { oracleAddress: string; oracleOwner: string } };
|
||||
return decoded.eventName === "OracleAdded";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!oracleAddedEvent) continue;
|
||||
const decoded = decodeEventLog({
|
||||
abi: whitelistOracleAbi,
|
||||
data: oracleAddedEvent.data,
|
||||
topics: oracleAddedEvent.topics,
|
||||
}) as { eventName: string; args: { oracleAddress: string; oracleOwner: string } };
|
||||
ownerToOracleAddress.set(decoded.args.oracleOwner.toLowerCase(), decoded.args.oracleAddress);
|
||||
console.log(`✅ Created SimpleOracle at: ${decoded.args.oracleAddress}`);
|
||||
}
|
||||
|
||||
const createdOracleAddresses: string[] = nodeAccounts.map(acc => {
|
||||
const addr = ownerToOracleAddress.get(acc.account.address.toLowerCase());
|
||||
if (!addr) throw new Error(`Missing oracle address for owner ${acc.account.address}`);
|
||||
return addr;
|
||||
});
|
||||
|
||||
// Set initial prices for each created SimpleOracle
|
||||
console.log("Setting initial prices for each SimpleOracle...");
|
||||
const initialPrice = await fetchPriceFromUniswap();
|
||||
// Get SimpleOracle ABI from deployments
|
||||
const simpleOracleDeployment = await hre.deployments.getArtifact("SimpleOracle");
|
||||
const simpleOracleAbi = simpleOracleDeployment.abi;
|
||||
// Fire all setPrice transactions concurrently from each node owner
|
||||
const setPriceTxPromises = nodeAccounts.map((account, i) => {
|
||||
const oracleAddress = createdOracleAddresses[i];
|
||||
return account.writeContract({
|
||||
address: oracleAddress as `0x${string}`,
|
||||
abi: simpleOracleAbi,
|
||||
functionName: "setPrice",
|
||||
args: [initialPrice],
|
||||
});
|
||||
});
|
||||
const setPriceTxHashes = await Promise.all(setPriceTxPromises);
|
||||
await Promise.all(setPriceTxHashes.map(hash => publicClient.waitForTransactionReceipt({ hash })));
|
||||
for (let i = 0; i < createdOracleAddresses.length; i++) {
|
||||
console.log(`Set price for SimpleOracle ${i + 1} to: ${initialPrice}`);
|
||||
}
|
||||
|
||||
// Calculate initial median price
|
||||
console.log("Calculating initial median price...");
|
||||
const medianPrice = await publicClient.readContract({
|
||||
address: whitelistOracleAddress,
|
||||
abi: whitelistOracleAbi,
|
||||
functionName: "getPrice",
|
||||
args: [],
|
||||
});
|
||||
console.log(`Initial median price: ${medianPrice?.toString()}`);
|
||||
}
|
||||
console.log("WhitelistOracle contract deployed and configured successfully!");
|
||||
};
|
||||
|
||||
export default deployWhitelistOracleContracts;
|
||||
deployWhitelistOracleContracts.tags = ["Oracles"];
|
||||
61
packages/hardhat/deploy/01_deploy_staking.ts
Normal file
61
packages/hardhat/deploy/01_deploy_staking.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { HardhatRuntimeEnvironment } from "hardhat/types";
|
||||
import { DeployFunction } from "hardhat-deploy/types";
|
||||
|
||||
const deployStakingOracle: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
|
||||
const { deployer } = await hre.getNamedAccounts();
|
||||
const { deploy } = hre.deployments;
|
||||
const { viem } = hre;
|
||||
|
||||
// Deploy ORA independently, then wire it into StakingOracle and transfer ownership to StakingOracle.
|
||||
console.log("Deploying ORA token...");
|
||||
const oraDeployment = await deploy("ORA", {
|
||||
contract: "ORA",
|
||||
from: deployer,
|
||||
args: [],
|
||||
log: true,
|
||||
autoMine: false,
|
||||
});
|
||||
|
||||
console.log("Deploying StakingOracle (wired to ORA)...");
|
||||
const stakingDeployment = await deploy("StakingOracle", {
|
||||
contract: "StakingOracle",
|
||||
from: deployer,
|
||||
args: [oraDeployment.address],
|
||||
log: true,
|
||||
autoMine: false,
|
||||
});
|
||||
|
||||
const stakingOracleAddress = stakingDeployment.address as `0x${string}`;
|
||||
console.log("StakingOracle deployed at:", stakingOracleAddress);
|
||||
|
||||
// Set ORA owner to StakingOracle so it can mint rewards via ORA.mint(...)
|
||||
const publicClient = await viem.getPublicClient();
|
||||
const walletClients = await viem.getWalletClients();
|
||||
const deployerClient = walletClients.find(wc => wc.account.address.toLowerCase() === deployer.toLowerCase());
|
||||
if (!deployerClient) throw new Error("Deployer wallet client not found");
|
||||
|
||||
// Check current owner before attempting transfer
|
||||
const currentOwner = await publicClient.readContract({
|
||||
address: oraDeployment.address as `0x${string}`,
|
||||
abi: oraDeployment.abi,
|
||||
functionName: "owner",
|
||||
args: [],
|
||||
});
|
||||
|
||||
if ((currentOwner as unknown as string).toLowerCase() === stakingOracleAddress.toLowerCase()) {
|
||||
console.log("ORA ownership already transferred to StakingOracle, skipping...");
|
||||
} else {
|
||||
console.log("Transferring ORA ownership to StakingOracle...");
|
||||
const txHash = await deployerClient.writeContract({
|
||||
address: oraDeployment.address as `0x${string}`,
|
||||
abi: oraDeployment.abi,
|
||||
functionName: "transferOwnership",
|
||||
args: [stakingOracleAddress],
|
||||
});
|
||||
await publicClient.waitForTransactionReceipt({ hash: txHash });
|
||||
}
|
||||
|
||||
console.log("ORA deployed at:", oraDeployment.address);
|
||||
};
|
||||
|
||||
export default deployStakingOracle;
|
||||
47
packages/hardhat/deploy/02_deploy_optimistic.ts
Normal file
47
packages/hardhat/deploy/02_deploy_optimistic.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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...");
|
||||
// Get the deployer's current nonce
|
||||
const deployerNonce = await hre.ethers.provider.getTransactionCount(deployer);
|
||||
|
||||
const futureDeciderAddress = hre.ethers.getCreateAddress({
|
||||
from: deployer,
|
||||
nonce: deployerNonce + 1, // +1 because it will be our second deployment
|
||||
});
|
||||
// Deploy the OptimisticOracle contract with deployer as temporary decider
|
||||
const optimisticOracle = await deploy("OptimisticOracle", {
|
||||
contract: "OptimisticOracle",
|
||||
from: deployer,
|
||||
args: [futureDeciderAddress],
|
||||
log: true,
|
||||
autoMine: false,
|
||||
});
|
||||
|
||||
// Deploy the Decider contract
|
||||
const decider = await deploy("Decider", {
|
||||
contract: "Decider",
|
||||
from: deployer,
|
||||
args: [optimisticOracle.address],
|
||||
log: true,
|
||||
autoMine: false,
|
||||
});
|
||||
|
||||
// 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;
|
||||
44
packages/hardhat/eslint.config.mjs
Normal file
44
packages/hardhat/eslint.config.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import globals from "globals";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import prettierPlugin from "eslint-plugin-prettier";
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["**/artifacts", "**/cache", "**/contracts", "**/node_modules/", "**/typechain-types", "**/*.json"]),
|
||||
{
|
||||
extends: compat.extends("plugin:@typescript-eslint/recommended", "prettier"),
|
||||
|
||||
plugins: {
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
|
||||
parser: tsParser,
|
||||
},
|
||||
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
|
||||
"prettier/prettier": [
|
||||
"warn",
|
||||
{
|
||||
endOfLine: "auto",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
158
packages/hardhat/hardhat.config.ts
Normal file
158
packages/hardhat/hardhat.config.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { HardhatUserConfig } from "hardhat/config";
|
||||
import "@nomicfoundation/hardhat-ethers";
|
||||
import "@nomicfoundation/hardhat-chai-matchers";
|
||||
import "@typechain/hardhat";
|
||||
import "hardhat-gas-reporter";
|
||||
import "solidity-coverage";
|
||||
import "@nomicfoundation/hardhat-verify";
|
||||
import "hardhat-deploy";
|
||||
import "hardhat-deploy-ethers";
|
||||
import { task } from "hardhat/config";
|
||||
import generateTsAbis from "./scripts/generateTsAbis";
|
||||
|
||||
import "@nomicfoundation/hardhat-viem";
|
||||
|
||||
// If not set, it uses ours Alchemy's default API key.
|
||||
// You can get your own at https://dashboard.alchemyapi.io
|
||||
const providerApiKey = process.env.ALCHEMY_API_KEY || "cR4WnXePioePZ5fFrnSiR";
|
||||
// If not set, it uses the hardhat account 0 private key.
|
||||
// You can generate a random account with `yarn generate` or `yarn account:import` to import your existing PK
|
||||
const deployerPrivateKey =
|
||||
process.env.__RUNTIME_DEPLOYER_PRIVATE_KEY ?? "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
|
||||
// If not set, it uses our block explorers default API keys.
|
||||
const etherscanApiKey = process.env.ETHERSCAN_V2_API_KEY || "DNXJA8RX2Q3VZ4URQIWP7Z68CJXQZSC6AW";
|
||||
|
||||
const config: HardhatUserConfig = {
|
||||
solidity: {
|
||||
compilers: [
|
||||
{
|
||||
version: "0.8.20",
|
||||
settings: {
|
||||
optimizer: {
|
||||
enabled: true,
|
||||
// https://docs.soliditylang.org/en/latest/using-the-compiler.html#optimizer-options
|
||||
runs: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
defaultNetwork: "localhost",
|
||||
namedAccounts: {
|
||||
deployer: {
|
||||
// By default, it will take the first Hardhat account as the deployer
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
networks: {
|
||||
mainnet: {
|
||||
url: "https://mainnet.rpc.buidlguidl.com",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
sepolia: {
|
||||
url: `https://eth-sepolia.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
arbitrum: {
|
||||
url: `https://arb-mainnet.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
arbitrumSepolia: {
|
||||
url: `https://arb-sepolia.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
optimism: {
|
||||
url: `https://opt-mainnet.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
optimismSepolia: {
|
||||
url: `https://opt-sepolia.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
polygon: {
|
||||
url: `https://polygon-mainnet.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
polygonAmoy: {
|
||||
url: `https://polygon-amoy.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
polygonZkEvm: {
|
||||
url: `https://polygonzkevm-mainnet.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
polygonZkEvmCardona: {
|
||||
url: `https://polygonzkevm-cardona.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
gnosis: {
|
||||
url: "https://rpc.gnosischain.com",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
chiado: {
|
||||
url: "https://rpc.chiadochain.net",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
base: {
|
||||
url: "https://mainnet.base.org",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
baseSepolia: {
|
||||
url: "https://sepolia.base.org",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
scrollSepolia: {
|
||||
url: "https://sepolia-rpc.scroll.io",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
scroll: {
|
||||
url: "https://rpc.scroll.io",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
celo: {
|
||||
url: "https://forno.celo.org",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
celoSepolia: {
|
||||
url: "https://forno.celo-sepolia.celo-testnet.org/",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
// View the networks that are pre-configured.
|
||||
// If the network you are looking for is not here you can add new network settings
|
||||
hardhat: {
|
||||
forking: {
|
||||
url: `https://eth-mainnet.alchemyapi.io/v2/${providerApiKey}`,
|
||||
enabled: process.env.MAINNET_FORKING_ENABLED === "true",
|
||||
},
|
||||
mining: {
|
||||
auto: false,
|
||||
interval: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Configuration for harhdat-verify plugin
|
||||
etherscan: {
|
||||
apiKey: etherscanApiKey,
|
||||
},
|
||||
// Configuration for etherscan-verify from hardhat-deploy plugin
|
||||
verify: {
|
||||
etherscan: {
|
||||
apiKey: etherscanApiKey,
|
||||
},
|
||||
},
|
||||
sourcify: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Extend the deploy task
|
||||
task("deploy").setAction(async (args, hre, runSuper) => {
|
||||
// Run the original deploy task
|
||||
await runSuper(args);
|
||||
// Force run the generateTsAbis script
|
||||
await generateTsAbis(hre);
|
||||
});
|
||||
|
||||
export default config;
|
||||
67
packages/hardhat/package.json
Normal file
67
packages/hardhat/package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@se-2/hardhat",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"account": "hardhat run scripts/listAccount.ts",
|
||||
"account:generate": "hardhat run scripts/generateAccount.ts",
|
||||
"account:import": "hardhat run scripts/importAccount.ts",
|
||||
"account:reveal-pk": "hardhat run scripts/revealPK.ts",
|
||||
"chain": "hardhat node --network hardhat --no-deploy",
|
||||
"check-types": "tsc --noEmit --incremental",
|
||||
"clean": "hardhat clean",
|
||||
"compile": "hardhat compile",
|
||||
"deploy": "ts-node scripts/runHardhatDeployWithPK.ts",
|
||||
"flatten": "hardhat flatten",
|
||||
"fork": "MAINNET_FORKING_ENABLED=true hardhat node --network hardhat --no-deploy",
|
||||
"format": "prettier --write './**/*.(ts|sol)'",
|
||||
"generate": "yarn account:generate",
|
||||
"hardhat-verify": "hardhat verify",
|
||||
"lint": "eslint",
|
||||
"lint-staged": "eslint",
|
||||
"simulate:optimistic": "hardhat run scripts/runOptimisticBots.ts",
|
||||
"simulate:staking": "hardhat run scripts/runStakingOracleBots.ts",
|
||||
"simulate:whitelist": "hardhat run scripts/runWhitelistOracleBots.ts",
|
||||
"test": "REPORT_GAS=true hardhat test --network hardhat",
|
||||
"verify": "hardhat etherscan-verify"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/password": "^4.0.2",
|
||||
"@nomicfoundation/hardhat-viem": "^2.0.6",
|
||||
"@openzeppelin/contracts": "~5.0.2",
|
||||
"@typechain/ethers-v6": "~0.5.1",
|
||||
"dotenv": "~16.4.5",
|
||||
"envfile": "~7.1.0",
|
||||
"qrcode": "~1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ethersproject/abi": "~5.7.0",
|
||||
"@ethersproject/providers": "~5.7.2",
|
||||
"@nomicfoundation/hardhat-chai-matchers": "~2.0.7",
|
||||
"@nomicfoundation/hardhat-ethers": "~3.0.8",
|
||||
"@nomicfoundation/hardhat-network-helpers": "~1.0.11",
|
||||
"@nomicfoundation/hardhat-verify": "~2.0.10",
|
||||
"@typechain/ethers-v5": "~11.1.2",
|
||||
"@typechain/hardhat": "~9.1.0",
|
||||
"@types/eslint": "~9.6.1",
|
||||
"@types/mocha": "~10.0.10",
|
||||
"@types/prettier": "~3.0.0",
|
||||
"@types/qrcode": "~1.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "~8.27.0",
|
||||
"@typescript-eslint/parser": "~8.27.0",
|
||||
"chai": "~4.5.0",
|
||||
"eslint": "~9.23.0",
|
||||
"eslint-config-prettier": "~10.1.1",
|
||||
"eslint-plugin-prettier": "~5.2.4",
|
||||
"ethers": "~6.13.2",
|
||||
"hardhat": "~2.22.10",
|
||||
"hardhat-deploy": "^1.0.4",
|
||||
"hardhat-deploy-ethers": "~0.4.2",
|
||||
"hardhat-gas-reporter": "~2.2.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-solidity": "~1.4.1",
|
||||
"solidity-coverage": "~0.8.13",
|
||||
"ts-node": "~10.9.1",
|
||||
"typechain": "~8.3.2",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
68
packages/hardhat/scripts/fetchPriceFromUniswap.ts
Normal file
68
packages/hardhat/scripts/fetchPriceFromUniswap.ts
Normal file
@@ -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<bigint> => {
|
||||
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());
|
||||
}
|
||||
};
|
||||
58
packages/hardhat/scripts/generateAccount.ts
Normal file
58
packages/hardhat/scripts/generateAccount.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ethers } from "ethers";
|
||||
import { parse, stringify } from "envfile";
|
||||
import * as fs from "fs";
|
||||
import password from "@inquirer/password";
|
||||
|
||||
const envFilePath = "./.env";
|
||||
|
||||
const getValidatedPassword = async () => {
|
||||
while (true) {
|
||||
const pass = await password({ message: "Enter a password to encrypt your private key:" });
|
||||
const confirmation = await password({ message: "Confirm password:" });
|
||||
|
||||
if (pass === confirmation) {
|
||||
return pass;
|
||||
}
|
||||
console.log("❌ Passwords don't match. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const setNewEnvConfig = async (existingEnvConfig = {}) => {
|
||||
console.log("👛 Generating new Wallet\n");
|
||||
const randomWallet = ethers.Wallet.createRandom();
|
||||
|
||||
const pass = await getValidatedPassword();
|
||||
const encryptedJson = await randomWallet.encrypt(pass);
|
||||
|
||||
const newEnvConfig = {
|
||||
...existingEnvConfig,
|
||||
DEPLOYER_PRIVATE_KEY_ENCRYPTED: encryptedJson,
|
||||
};
|
||||
|
||||
// Store in .env
|
||||
fs.writeFileSync(envFilePath, stringify(newEnvConfig));
|
||||
console.log("\n📄 Encrypted Private Key saved to packages/hardhat/.env file");
|
||||
console.log("🪄 Generated wallet address:", randomWallet.address, "\n");
|
||||
console.log("⚠️ Make sure to remember your password! You'll need it to decrypt the private key.");
|
||||
};
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(envFilePath)) {
|
||||
// No .env file yet.
|
||||
await setNewEnvConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingEnvConfig = parse(fs.readFileSync(envFilePath).toString());
|
||||
if (existingEnvConfig.DEPLOYER_PRIVATE_KEY_ENCRYPTED) {
|
||||
console.log("⚠️ You already have a deployer account. Check the packages/hardhat/.env file");
|
||||
return;
|
||||
}
|
||||
|
||||
await setNewEnvConfig(existingEnvConfig);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
136
packages/hardhat/scripts/generateTsAbis.ts
Normal file
136
packages/hardhat/scripts/generateTsAbis.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* DON'T MODIFY OR DELETE THIS SCRIPT (unless you know what you're doing)
|
||||
*
|
||||
* This script generates the file containing the contracts Abi definitions.
|
||||
* These definitions are used to derive the types needed in the custom scaffold-eth hooks, for example.
|
||||
* This script should run as the last deploy script.
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import prettier from "prettier";
|
||||
import { DeployFunction } from "hardhat-deploy/types";
|
||||
|
||||
const generatedContractComment = `
|
||||
/**
|
||||
* This file is autogenerated by Scaffold-ETH.
|
||||
* You should not edit it manually or your changes might be overwritten.
|
||||
*/
|
||||
`;
|
||||
|
||||
const DEPLOYMENTS_DIR = "./deployments";
|
||||
const ARTIFACTS_DIR = "./artifacts";
|
||||
|
||||
function getDirectories(path: string) {
|
||||
return fs
|
||||
.readdirSync(path, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name);
|
||||
}
|
||||
|
||||
function getContractNames(path: string) {
|
||||
return fs
|
||||
.readdirSync(path, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isFile() && dirent.name.endsWith(".json"))
|
||||
.map(dirent => dirent.name.split(".")[0]);
|
||||
}
|
||||
|
||||
function getActualSourcesForContract(sources: Record<string, any>, contractName: string) {
|
||||
for (const sourcePath of Object.keys(sources)) {
|
||||
const sourceName = sourcePath.split("/").pop()?.split(".sol")[0];
|
||||
if (sourceName === contractName) {
|
||||
const contractContent = sources[sourcePath].content as string;
|
||||
const regex = /contract\s+(\w+)\s+is\s+([^{}]+)\{/;
|
||||
const match = contractContent.match(regex);
|
||||
|
||||
if (match) {
|
||||
const inheritancePart = match[2];
|
||||
// Split the inherited contracts by commas to get the list of inherited contracts
|
||||
const inheritedContracts = inheritancePart.split(",").map(contract => `${contract.trim()}.sol`);
|
||||
|
||||
return inheritedContracts;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getInheritedFunctions(sources: Record<string, any>, contractName: string) {
|
||||
const actualSources = getActualSourcesForContract(sources, contractName);
|
||||
const inheritedFunctions = {} as Record<string, any>;
|
||||
|
||||
for (const sourceContractName of actualSources) {
|
||||
const sourcePath = Object.keys(sources).find(key => key.includes(`/${sourceContractName}`));
|
||||
if (sourcePath) {
|
||||
const sourceName = sourcePath?.split("/").pop()?.split(".sol")[0];
|
||||
const { abi } = JSON.parse(fs.readFileSync(`${ARTIFACTS_DIR}/${sourcePath}/${sourceName}.json`).toString());
|
||||
for (const functionAbi of abi) {
|
||||
if (functionAbi.type === "function") {
|
||||
inheritedFunctions[functionAbi.name] = sourcePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inheritedFunctions;
|
||||
}
|
||||
|
||||
function getContractDataFromDeployments() {
|
||||
if (!fs.existsSync(DEPLOYMENTS_DIR)) {
|
||||
throw Error("At least one other deployment script should exist to generate an actual contract.");
|
||||
}
|
||||
const output = {} as Record<string, any>;
|
||||
const chainDirectories = getDirectories(DEPLOYMENTS_DIR);
|
||||
for (const chainName of chainDirectories) {
|
||||
let chainId;
|
||||
try {
|
||||
chainId = fs.readFileSync(`${DEPLOYMENTS_DIR}/${chainName}/.chainId`).toString();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (error) {
|
||||
console.log(`No chainId file found for ${chainName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const contracts = {} as Record<string, any>;
|
||||
for (const contractName of getContractNames(`${DEPLOYMENTS_DIR}/${chainName}`)) {
|
||||
const { abi, address, metadata, receipt } = JSON.parse(
|
||||
fs.readFileSync(`${DEPLOYMENTS_DIR}/${chainName}/${contractName}.json`).toString(),
|
||||
);
|
||||
const inheritedFunctions = metadata ? getInheritedFunctions(JSON.parse(metadata).sources, contractName) : {};
|
||||
contracts[contractName] = { address, abi, inheritedFunctions, deployedOnBlock: receipt?.blockNumber };
|
||||
}
|
||||
output[chainId] = contracts;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the TypeScript contract definition file based on the json output of the contract deployment scripts
|
||||
* This script should be run last.
|
||||
*/
|
||||
const generateTsAbis: DeployFunction = async function () {
|
||||
const TARGET_DIR = "../nextjs/contracts/";
|
||||
const allContractsData = getContractDataFromDeployments();
|
||||
|
||||
const fileContent = Object.entries(allContractsData).reduce((content, [chainId, chainConfig]) => {
|
||||
return `${content}${parseInt(chainId).toFixed(0)}:${JSON.stringify(chainConfig, null, 2)},`;
|
||||
}, "");
|
||||
|
||||
if (!fs.existsSync(TARGET_DIR)) {
|
||||
fs.mkdirSync(TARGET_DIR);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
`${TARGET_DIR}deployedContracts.ts`,
|
||||
await prettier.format(
|
||||
`${generatedContractComment} import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract"; \n\n
|
||||
const deployedContracts = {${fileContent}} as const; \n\n export default deployedContracts satisfies GenericContractsDeclaration`,
|
||||
{
|
||||
parser: "typescript",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
console.log(`📝 Updated TypeScript contract definition file on ${TARGET_DIR}deployedContracts.ts`);
|
||||
};
|
||||
|
||||
export default generateTsAbis;
|
||||
72
packages/hardhat/scripts/importAccount.ts
Normal file
72
packages/hardhat/scripts/importAccount.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ethers } from "ethers";
|
||||
import { parse, stringify } from "envfile";
|
||||
import * as fs from "fs";
|
||||
import password from "@inquirer/password";
|
||||
|
||||
const envFilePath = "./.env";
|
||||
|
||||
const getValidatedPassword = async () => {
|
||||
while (true) {
|
||||
const pass = await password({ message: "Enter a password to encrypt your private key:" });
|
||||
const confirmation = await password({ message: "Confirm password:" });
|
||||
|
||||
if (pass === confirmation) {
|
||||
return pass;
|
||||
}
|
||||
console.log("❌ Passwords don't match. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const getWalletFromPrivateKey = async () => {
|
||||
while (true) {
|
||||
const privateKey = await password({ message: "Paste your private key:" });
|
||||
try {
|
||||
const wallet = new ethers.Wallet(privateKey);
|
||||
return wallet;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.log("❌ Invalid private key format. Please try again.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setNewEnvConfig = async (existingEnvConfig = {}) => {
|
||||
console.log("👛 Importing Wallet\n");
|
||||
|
||||
const wallet = await getWalletFromPrivateKey();
|
||||
|
||||
const pass = await getValidatedPassword();
|
||||
const encryptedJson = await wallet.encrypt(pass);
|
||||
|
||||
const newEnvConfig = {
|
||||
...existingEnvConfig,
|
||||
DEPLOYER_PRIVATE_KEY_ENCRYPTED: encryptedJson,
|
||||
};
|
||||
|
||||
// Store in .env
|
||||
fs.writeFileSync(envFilePath, stringify(newEnvConfig));
|
||||
console.log("\n📄 Encrypted Private Key saved to packages/hardhat/.env file");
|
||||
console.log("🪄 Imported wallet address:", wallet.address, "\n");
|
||||
console.log("⚠️ Make sure to remember your password! You'll need it to decrypt the private key.");
|
||||
};
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(envFilePath)) {
|
||||
// No .env file yet.
|
||||
await setNewEnvConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingEnvConfig = parse(fs.readFileSync(envFilePath).toString());
|
||||
if (existingEnvConfig.DEPLOYER_PRIVATE_KEY_ENCRYPTED) {
|
||||
console.log("⚠️ You already have a deployer account. Check the packages/hardhat/.env file");
|
||||
return;
|
||||
}
|
||||
|
||||
await setNewEnvConfig(existingEnvConfig);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
52
packages/hardhat/scripts/listAccount.ts
Normal file
52
packages/hardhat/scripts/listAccount.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { ethers, Wallet } from "ethers";
|
||||
import QRCode from "qrcode";
|
||||
import { config } from "hardhat";
|
||||
import password from "@inquirer/password";
|
||||
|
||||
async function main() {
|
||||
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
|
||||
|
||||
if (!encryptedKey) {
|
||||
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
|
||||
return;
|
||||
}
|
||||
|
||||
const pass = await password({ message: "Enter your password to decrypt the private key:" });
|
||||
let wallet: Wallet;
|
||||
try {
|
||||
wallet = (await Wallet.fromEncryptedJson(encryptedKey, pass)) as Wallet;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.log("❌ Failed to decrypt private key. Wrong password?");
|
||||
return;
|
||||
}
|
||||
|
||||
const address = wallet.address;
|
||||
console.log(await QRCode.toString(address, { type: "terminal", small: true }));
|
||||
console.log("Public address:", address, "\n");
|
||||
|
||||
// Balance on each network
|
||||
const availableNetworks = config.networks;
|
||||
for (const networkName in availableNetworks) {
|
||||
try {
|
||||
const network = availableNetworks[networkName];
|
||||
if (!("url" in network)) continue;
|
||||
const provider = new ethers.JsonRpcProvider(network.url);
|
||||
await provider._detectNetwork();
|
||||
const balance = await provider.getBalance(address);
|
||||
console.log("--", networkName, "-- 📡");
|
||||
console.log(" balance:", +ethers.formatEther(balance));
|
||||
console.log(" nonce:", +(await provider.getTransactionCount(address)));
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.log("Can't connect to network", networkName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
29
packages/hardhat/scripts/oracle-bot/balances.ts
Normal file
29
packages/hardhat/scripts/oracle-bot/balances.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
56
packages/hardhat/scripts/oracle-bot/config.json
Normal file
56
packages/hardhat/scripts/oracle-bot/config.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"PRICE": {
|
||||
"CACHEDPRICE": 4000,
|
||||
"TIMESTAMP": 1761680177006
|
||||
},
|
||||
"INTERVALS": {
|
||||
"PRICE_REPORT": 1750,
|
||||
"VALIDATION": 1750
|
||||
},
|
||||
"NODE_CONFIGS": {
|
||||
"default": {
|
||||
"PROBABILITY_OF_SKIPPING_REPORT": 0,
|
||||
"PRICE_VARIANCE": 0
|
||||
},
|
||||
"0x70997970c51812dc3a010c7d01b50e0d17dc79c8": {
|
||||
"PROBABILITY_OF_SKIPPING_REPORT": 0,
|
||||
"PRICE_VARIANCE": 0
|
||||
},
|
||||
"0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc": {
|
||||
"PROBABILITY_OF_SKIPPING_REPORT": 0,
|
||||
"PRICE_VARIANCE": 0
|
||||
},
|
||||
"0x976ea74026e726554db657fa54763abd0c3a0aa9": {
|
||||
"PROBABILITY_OF_SKIPPING_REPORT": 0,
|
||||
"PRICE_VARIANCE": 0
|
||||
},
|
||||
"0x90f79bf6eb2c4f870365e785982e1f101e93b906": {
|
||||
"PROBABILITY_OF_SKIPPING_REPORT": 0,
|
||||
"PRICE_VARIANCE": 0
|
||||
},
|
||||
"0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc": {
|
||||
"PROBABILITY_OF_SKIPPING_REPORT": 0,
|
||||
"PRICE_VARIANCE": 0
|
||||
},
|
||||
"0x15d34aaf54267db7d7c367839aaf71a00a2c6a65": {
|
||||
"PROBABILITY_OF_SKIPPING_REPORT": 0,
|
||||
"PRICE_VARIANCE": 0
|
||||
},
|
||||
"0xbcd4042de499d14e55001ccbb24a551f3b954096": {
|
||||
"PROBABILITY_OF_SKIPPING_REPORT": 0,
|
||||
"PRICE_VARIANCE": 0
|
||||
},
|
||||
"0x14dc79964da2c08b23698b3d3cc7ca32193d9955": {
|
||||
"PROBABILITY_OF_SKIPPING_REPORT": 0,
|
||||
"PRICE_VARIANCE": 0
|
||||
},
|
||||
"0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f": {
|
||||
"PROBABILITY_OF_SKIPPING_REPORT": 0,
|
||||
"PRICE_VARIANCE": 0
|
||||
},
|
||||
"0xa0ee7a142d267c1f36714e4a8f75612f20a79720": {
|
||||
"PROBABILITY_OF_SKIPPING_REPORT": 0,
|
||||
"PRICE_VARIANCE": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
16
packages/hardhat/scripts/oracle-bot/price.ts
Normal file
16
packages/hardhat/scripts/oracle-bot/price.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getConfig } from "../utils";
|
||||
|
||||
export const getRandomPrice = async (nodeAddress: string, currentPrice: number): Promise<number> => {
|
||||
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);
|
||||
};
|
||||
80
packages/hardhat/scripts/oracle-bot/reporting.ts
Normal file
80
packages/hardhat/scripts/oracle-bot/reporting.ts
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
19
packages/hardhat/scripts/oracle-bot/types.ts
Normal file
19
packages/hardhat/scripts/oracle-bot/types.ts
Normal file
@@ -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;
|
||||
};
|
||||
}
|
||||
79
packages/hardhat/scripts/oracle-bot/validation.ts
Normal file
79
packages/hardhat/scripts/oracle-bot/validation.ts
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
31
packages/hardhat/scripts/revealPK.ts
Normal file
31
packages/hardhat/scripts/revealPK.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { Wallet } from "ethers";
|
||||
import password from "@inquirer/password";
|
||||
|
||||
async function main() {
|
||||
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
|
||||
|
||||
if (!encryptedKey) {
|
||||
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("👀 This will reveal your private key on the console.\n");
|
||||
|
||||
const pass = await password({ message: "Enter your password to decrypt the private key:" });
|
||||
let wallet: Wallet;
|
||||
try {
|
||||
wallet = (await Wallet.fromEncryptedJson(encryptedKey, pass)) as Wallet;
|
||||
} catch {
|
||||
console.log("❌ Failed to decrypt private key. Wrong password?");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\n🔑 Private key:", wallet.privateKey);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
58
packages/hardhat/scripts/runHardhatDeployWithPK.ts
Normal file
58
packages/hardhat/scripts/runHardhatDeployWithPK.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { Wallet } from "ethers";
|
||||
import password from "@inquirer/password";
|
||||
import { spawn } from "child_process";
|
||||
import { config } from "hardhat";
|
||||
|
||||
/**
|
||||
* Unencrypts the private key and runs the hardhat deploy command
|
||||
*/
|
||||
async function main() {
|
||||
const networkIndex = process.argv.indexOf("--network");
|
||||
const networkName = networkIndex !== -1 ? process.argv[networkIndex + 1] : config.defaultNetwork;
|
||||
|
||||
if (networkName === "localhost" || networkName === "hardhat") {
|
||||
// Deploy command on the localhost network
|
||||
const hardhat = spawn("hardhat", ["deploy", ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
hardhat.on("exit", code => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
|
||||
|
||||
if (!encryptedKey) {
|
||||
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
|
||||
return;
|
||||
}
|
||||
|
||||
const pass = await password({ message: "Enter password to decrypt private key:" });
|
||||
|
||||
try {
|
||||
const wallet = await Wallet.fromEncryptedJson(encryptedKey, pass);
|
||||
process.env.__RUNTIME_DEPLOYER_PRIVATE_KEY = wallet.privateKey;
|
||||
|
||||
const hardhat = spawn("hardhat", ["deploy", ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
hardhat.on("exit", code => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.error("Failed to decrypt private key. Wrong password?");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
258
packages/hardhat/scripts/runOptimisticBots.ts
Normal file
258
packages/hardhat/scripts/runOptimisticBots.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { deployments, ethers } from "hardhat";
|
||||
import hre from "hardhat";
|
||||
import { HardhatRuntimeEnvironment } from "hardhat/types";
|
||||
import { getRandomQuestion, sleep } from "./utils";
|
||||
import { WalletClient } from "@nomicfoundation/hardhat-viem/types";
|
||||
import { Deployment } from "hardhat-deploy/types";
|
||||
import { zeroAddress } from "viem";
|
||||
import { OptimisticOracle } from "../typechain-types";
|
||||
|
||||
const isHalfTimePassed = (assertion: any, currentTimestamp: bigint) => {
|
||||
const startTime: bigint = assertion.startTime;
|
||||
const endTime: bigint = assertion.endTime;
|
||||
const halfTimePassed = (endTime - startTime) / 2n;
|
||||
return currentTimestamp > startTime && startTime + halfTimePassed < currentTimestamp;
|
||||
};
|
||||
|
||||
const stopTrackingAssertion = (
|
||||
accountToAssertionIds: Record<string, bigint[]>,
|
||||
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<string, bigint[]>,
|
||||
) => {
|
||||
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<string, bigint[]>,
|
||||
) => {
|
||||
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<string, bigint[]>,
|
||||
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<string, bigint[]>,
|
||||
accounts: WalletClient[],
|
||||
) => {
|
||||
try {
|
||||
const trueResponder = accounts[0];
|
||||
const falseResponder = accounts[1];
|
||||
const randomResponder = accounts[2];
|
||||
const otherAccounts = accounts.slice(3);
|
||||
|
||||
const optimisticDeployment = await deployments.get("OptimisticOracle");
|
||||
const optimisticOracle = await ethers.getContractAt("OptimisticOracle", optimisticDeployment.address);
|
||||
const publicClient = await hre.viem.getPublicClient();
|
||||
|
||||
// get current timestamp
|
||||
const latestBlock = await publicClient.getBlock();
|
||||
const currentTimestamp = latestBlock.timestamp;
|
||||
// also track thex of the account start from the third account
|
||||
if (currentAction === 0) {
|
||||
console.log(`\n📝 === CREATING ASSERTIONS PHASE ===`);
|
||||
await createAssertions(optimisticDeployment, optimisticOracle, otherAccounts, accountToAssertionIds);
|
||||
} else if (currentAction === 1) {
|
||||
console.log(`\n🎯 === PROPOSING OUTCOMES PHASE ===`);
|
||||
await proposeAssertions(
|
||||
trueResponder,
|
||||
falseResponder,
|
||||
randomResponder,
|
||||
optimisticDeployment,
|
||||
optimisticOracle,
|
||||
currentTimestamp,
|
||||
otherAccounts,
|
||||
accountToAssertionIds,
|
||||
);
|
||||
} else if (currentAction === 2) {
|
||||
console.log(`\n⚔️ === DISPUTING ASSERTIONS PHASE ===`);
|
||||
await disputeAssertions(
|
||||
trueResponder,
|
||||
falseResponder,
|
||||
optimisticDeployment,
|
||||
optimisticOracle,
|
||||
currentTimestamp,
|
||||
accountToAssertionIds,
|
||||
otherAccounts,
|
||||
);
|
||||
}
|
||||
currentAction = (currentAction + 1) % 3;
|
||||
} catch (error) {
|
||||
console.error("Error in oracle cycle:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
async function run() {
|
||||
console.log("Starting optimistic oracle bots...");
|
||||
const accountToAssertionIds: Record<string, bigint[]> = {};
|
||||
|
||||
const accounts = (await hre.viem.getWalletClients()).slice(0, 8);
|
||||
for (const account of accounts) {
|
||||
accountToAssertionIds[account.account.address] = [];
|
||||
}
|
||||
while (true) {
|
||||
await runCycle(hre, accountToAssertionIds, accounts);
|
||||
await sleep(3000);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
// Handle process termination signals
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("\nReceived SIGINT (Ctrl+C). Cleaning up...");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("\nReceived SIGTERM. Cleaning up...");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on("uncaughtException", async error => {
|
||||
console.error("Uncaught Exception:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on("unhandledRejection", async (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
process.exit(1);
|
||||
});
|
||||
688
packages/hardhat/scripts/runStakingOracleBots.ts
Normal file
688
packages/hardhat/scripts/runStakingOracleBots.ts
Normal file
@@ -0,0 +1,688 @@
|
||||
import { HardhatRuntimeEnvironment } from "hardhat/types";
|
||||
import hre from "hardhat";
|
||||
import { sleep, getConfig } from "./utils";
|
||||
import { fetchPriceFromUniswap } from "./fetchPriceFromUniswap";
|
||||
import { parseEther } from "viem";
|
||||
|
||||
const oraTokenAbi = [
|
||||
{
|
||||
type: "function",
|
||||
name: "approve",
|
||||
stateMutability: "nonpayable",
|
||||
inputs: [
|
||||
{ name: "spender", type: "address" },
|
||||
{ name: "amount", type: "uint256" },
|
||||
],
|
||||
outputs: [{ name: "", type: "bool" }],
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
name: "balanceOf",
|
||||
stateMutability: "view",
|
||||
inputs: [{ name: "owner", type: "address" }],
|
||||
outputs: [{ name: "", type: "uint256" }],
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
name: "allowance",
|
||||
stateMutability: "view",
|
||||
inputs: [
|
||||
{ name: "owner", type: "address" },
|
||||
{ name: "spender", type: "address" },
|
||||
],
|
||||
outputs: [{ name: "", type: "uint256" }],
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
name: "transfer",
|
||||
stateMutability: "nonpayable",
|
||||
inputs: [
|
||||
{ name: "to", type: "address" },
|
||||
{ name: "amount", type: "uint256" },
|
||||
],
|
||||
outputs: [{ name: "", type: "bool" }],
|
||||
},
|
||||
] as const;
|
||||
|
||||
type WalletClient = Awaited<ReturnType<typeof hre.viem.getWalletClients>>[number];
|
||||
|
||||
const normalizeNodeInfo = (raw: any) => {
|
||||
const zero = 0n;
|
||||
if (!raw)
|
||||
return {
|
||||
stakedAmount: zero,
|
||||
lastReportedBucket: zero,
|
||||
reportCount: zero,
|
||||
claimedReportCount: zero,
|
||||
firstBucket: zero,
|
||||
active: false,
|
||||
};
|
||||
const get = (idx: number, name: string) => {
|
||||
const byName = raw[name];
|
||||
const byIndex = Array.isArray(raw) ? raw[idx] : undefined;
|
||||
if (typeof byName === "bigint") return byName as bigint;
|
||||
if (typeof byIndex === "bigint") return byIndex as bigint;
|
||||
const val = byName ?? byIndex ?? 0;
|
||||
try {
|
||||
return BigInt(String(val));
|
||||
} catch {
|
||||
return zero;
|
||||
}
|
||||
};
|
||||
return {
|
||||
stakedAmount: get(0, "stakedAmount"),
|
||||
lastReportedBucket: get(1, "lastReportedBucket"),
|
||||
reportCount: get(2, "reportCount"),
|
||||
claimedReportCount: get(3, "claimedReportCount"),
|
||||
firstBucket: get(4, "firstBucket"),
|
||||
active:
|
||||
typeof raw?.active === "boolean"
|
||||
? (raw.active as boolean)
|
||||
: Array.isArray(raw) && typeof raw[5] === "boolean"
|
||||
? (raw[5] as boolean)
|
||||
: false,
|
||||
};
|
||||
};
|
||||
|
||||
// Current base price used by the bot. Initialized once at start from Uniswap
|
||||
// and updated from on-chain contract prices thereafter.
|
||||
let currentPrice: bigint | null = null;
|
||||
|
||||
const stringToBool = (value: string | undefined | null): boolean => {
|
||||
if (!value) return false;
|
||||
const normalized = value.toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
||||
};
|
||||
|
||||
// Feature flag: enable automatic slashing when the AUTO_SLASH environment variable is truthy
|
||||
const AUTO_SLASH: boolean = stringToBool(process.env.AUTO_SLASH);
|
||||
|
||||
const getStakingOracleDeployment = async (runtime: HardhatRuntimeEnvironment) => {
|
||||
const deployment = await runtime.deployments.get("StakingOracle");
|
||||
return {
|
||||
address: deployment.address as `0x${string}`,
|
||||
abi: deployment.abi,
|
||||
deployedBlock: deployment.receipt?.blockNumber ? BigInt(deployment.receipt.blockNumber) : 0n,
|
||||
} as const;
|
||||
};
|
||||
|
||||
const getActiveNodeWalletClients = async (
|
||||
runtime: HardhatRuntimeEnvironment,
|
||||
stakingAddress: `0x${string}`,
|
||||
stakingAbi: any,
|
||||
): Promise<WalletClient[]> => {
|
||||
const accounts = await runtime.viem.getWalletClients();
|
||||
// Filter to only those that are registered (firstBucket != 0)
|
||||
const publicClient = await runtime.viem.getPublicClient();
|
||||
const nodeClients: WalletClient[] = [];
|
||||
for (const client of accounts) {
|
||||
try {
|
||||
const rawNodeInfo = await publicClient.readContract({
|
||||
address: stakingAddress,
|
||||
abi: stakingAbi,
|
||||
functionName: "nodes",
|
||||
args: [client.account.address],
|
||||
});
|
||||
const node = normalizeNodeInfo(rawNodeInfo);
|
||||
if (node.firstBucket !== 0n && node.active) {
|
||||
nodeClients.push(client);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return nodeClients;
|
||||
};
|
||||
|
||||
const findNodeIndex = async (
|
||||
runtime: HardhatRuntimeEnvironment,
|
||||
stakingAddress: `0x${string}`,
|
||||
stakingAbi: any,
|
||||
nodeAddress: `0x${string}`,
|
||||
): Promise<number | null> => {
|
||||
const publicClient = await runtime.viem.getPublicClient();
|
||||
// Iterate indices until out-of-bounds revert
|
||||
try {
|
||||
const addresses = (await publicClient.readContract({
|
||||
address: stakingAddress,
|
||||
abi: stakingAbi,
|
||||
functionName: "getNodeAddresses",
|
||||
args: [],
|
||||
})) as `0x${string}`[];
|
||||
return addresses.findIndex(addr => addr.toLowerCase() === nodeAddress.toLowerCase());
|
||||
} catch {}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getReportIndexForNode = async (
|
||||
publicClient: Awaited<ReturnType<typeof hre.viem.getPublicClient>>,
|
||||
stakingAddress: `0x${string}`,
|
||||
stakingAbi: any,
|
||||
bucketNumber: bigint,
|
||||
nodeAddress: `0x${string}`,
|
||||
fromBlock: bigint,
|
||||
): Promise<number | null> => {
|
||||
try {
|
||||
const events = (await publicClient.getContractEvents({
|
||||
address: stakingAddress,
|
||||
abi: stakingAbi,
|
||||
eventName: "PriceReported",
|
||||
fromBlock,
|
||||
toBlock: "latest",
|
||||
})) as any[];
|
||||
const bucketEvents = events.filter((ev: any) => {
|
||||
const bucket = ev.args?.bucketNumber as bigint | undefined;
|
||||
return bucket !== undefined && bucket === bucketNumber;
|
||||
});
|
||||
const idx = bucketEvents.findIndex((ev: any) => {
|
||||
const reporter = (ev.args?.node as string | undefined) ?? "";
|
||||
return reporter.toLowerCase() === nodeAddress.toLowerCase();
|
||||
});
|
||||
return idx === -1 ? null : idx;
|
||||
} catch (error) {
|
||||
console.warn("Failed to compute report index:", (error as Error).message);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const runCycle = async (runtime: HardhatRuntimeEnvironment) => {
|
||||
try {
|
||||
const { address, abi, deployedBlock } = await getStakingOracleDeployment(runtime);
|
||||
const publicClient = await runtime.viem.getPublicClient();
|
||||
const allWalletClients = await runtime.viem.getWalletClients();
|
||||
const blockNumber = await publicClient.getBlockNumber();
|
||||
console.log(`\n[Block ${blockNumber}] Starting new oracle cycle...`);
|
||||
|
||||
// Read current bucket window and bucket number
|
||||
const [bucketWindow, currentBucket] = await Promise.all([
|
||||
publicClient
|
||||
.readContract({ address, abi, functionName: "BUCKET_WINDOW", args: [] })
|
||||
.then(value => BigInt(String(value))),
|
||||
publicClient
|
||||
.readContract({ address, abi, functionName: "getCurrentBucketNumber", args: [] })
|
||||
.then(value => BigInt(String(value))),
|
||||
]);
|
||||
const previousBucket = currentBucket > 0n ? currentBucket - 1n : 0n;
|
||||
console.log(`BUCKET_WINDOW=${bucketWindow} | currentBucket=${currentBucket}`);
|
||||
|
||||
// Update base price from previous bucket using the RECORDED MEDIAN (not an average of reports).
|
||||
// Fallback to contract's latest price, then to previous cached value.
|
||||
try {
|
||||
const previous = previousBucket;
|
||||
if (previous > 0n) {
|
||||
try {
|
||||
// `getPastPrice(bucket)` returns the recorded median for that bucket (0 if not recorded yet).
|
||||
const pastMedian = await publicClient.readContract({
|
||||
address,
|
||||
abi,
|
||||
functionName: "getPastPrice",
|
||||
args: [previous],
|
||||
});
|
||||
const median = BigInt(String(pastMedian));
|
||||
if (median > 0n) {
|
||||
currentPrice = median;
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall back
|
||||
}
|
||||
|
||||
if (currentPrice === null) {
|
||||
// Fallback to on-chain latest average (previous bucket average)
|
||||
try {
|
||||
const onchain = await publicClient.readContract({ address, abi, functionName: "getLatestPrice", args: [] });
|
||||
currentPrice = BigInt(String(onchain));
|
||||
} catch {
|
||||
// keep prior currentPrice
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// keep prior currentPrice
|
||||
}
|
||||
|
||||
// Load config once per cycle so runtime edits to the config file are picked up
|
||||
const cfg = getConfig();
|
||||
|
||||
// 1) Reporting: each node only once per bucket
|
||||
const nodeWalletClients = await getActiveNodeWalletClients(runtime, address, abi);
|
||||
// Ensure we have an initial price (set once at startup in run())
|
||||
if (currentPrice === null) {
|
||||
currentPrice = await fetchPriceFromUniswap();
|
||||
}
|
||||
const reportTxHashes: `0x${string}`[] = [];
|
||||
for (const client of nodeWalletClients) {
|
||||
try {
|
||||
const rawNodeInfo = await publicClient.readContract({
|
||||
address,
|
||||
abi,
|
||||
functionName: "nodes",
|
||||
args: [client.account.address],
|
||||
});
|
||||
const node = normalizeNodeInfo(rawNodeInfo);
|
||||
if (node.lastReportedBucket !== currentBucket) {
|
||||
// Determine node config (probability to skip and variance)
|
||||
const nodeCfg = cfg.NODE_CONFIGS[client.account.address.toLowerCase()] || cfg.NODE_CONFIGS.default;
|
||||
const skipProb = Number(nodeCfg.PROBABILITY_OF_SKIPPING_REPORT ?? 0);
|
||||
if (Math.random() < skipProb) {
|
||||
console.log(`Skipping report (by probability) for ${client.account.address}`);
|
||||
continue;
|
||||
}
|
||||
// Compute deviated price as integer math using parts-per-million (ppm)
|
||||
const variancePpm = Math.floor((Number(nodeCfg.PRICE_VARIANCE) || 0) * 1_000_000);
|
||||
const randomPpm = variancePpm > 0 ? Math.floor(Math.random() * (variancePpm * 2 + 1)) - variancePpm : 0;
|
||||
const basePrice = currentPrice!; // derived from previous bucket excluding outliers
|
||||
const delta = (basePrice * BigInt(randomPpm)) / 1_000_000n;
|
||||
const priceToReport = basePrice + delta;
|
||||
|
||||
console.log(
|
||||
`Reporting price for node ${client.account.address} in bucket ${currentBucket} (price=${priceToReport})...`,
|
||||
);
|
||||
const txHash = await client.writeContract({
|
||||
address,
|
||||
abi,
|
||||
functionName: "reportPrice",
|
||||
args: [priceToReport],
|
||||
});
|
||||
reportTxHashes.push(txHash as `0x${string}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Skipping report for ${client.account.address}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for report transactions to be mined so subsequent reads (claiming) see the updated state.
|
||||
if (reportTxHashes.length > 0) {
|
||||
try {
|
||||
await Promise.all(reportTxHashes.map(hash => publicClient.waitForTransactionReceipt({ hash } as any)));
|
||||
} catch (err) {
|
||||
// If waiting fails, continue — claims will be attempted anyway but may not see the latest reports.
|
||||
console.warn("Error while waiting for report tx receipts:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Finalize median automatically when quorum is reached
|
||||
// You can only finalize buckets strictly in the past, so we finalize the *previous* bucket (current - 1).
|
||||
if (previousBucket > 0n) {
|
||||
let medianAlreadyRecorded = false;
|
||||
try {
|
||||
const median = await publicClient.readContract({
|
||||
address,
|
||||
abi,
|
||||
functionName: "getPastPrice",
|
||||
args: [previousBucket],
|
||||
});
|
||||
medianAlreadyRecorded = BigInt(String(median)) > 0n;
|
||||
} catch {
|
||||
medianAlreadyRecorded = false;
|
||||
}
|
||||
|
||||
if (!medianAlreadyRecorded) {
|
||||
try {
|
||||
const activeNodeAddresses = (await publicClient.readContract({
|
||||
address,
|
||||
abi,
|
||||
functionName: "getNodeAddresses",
|
||||
args: [],
|
||||
})) as `0x${string}`[];
|
||||
|
||||
const reportStatuses = await Promise.all(
|
||||
activeNodeAddresses.map(async nodeAddr => {
|
||||
try {
|
||||
const [price] = (await publicClient.readContract({
|
||||
address,
|
||||
abi,
|
||||
functionName: "getSlashedStatus",
|
||||
args: [nodeAddr, previousBucket],
|
||||
})) as [bigint, boolean];
|
||||
return price;
|
||||
} catch {
|
||||
return 0n;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const reportedCount = reportStatuses.reduce((acc, price) => acc + (price > 0n ? 1n : 0n), 0n);
|
||||
const requiredReports =
|
||||
activeNodeAddresses.length === 0 ? 0n : (2n * BigInt(activeNodeAddresses.length) + 2n) / 3n;
|
||||
|
||||
if (activeNodeAddresses.length === 0) {
|
||||
console.log("No active nodes; skipping recordBucketMedian evaluation.");
|
||||
} else if (reportedCount >= requiredReports) {
|
||||
const finalizer = allWalletClients[0];
|
||||
try {
|
||||
await finalizer.writeContract({
|
||||
address,
|
||||
abi,
|
||||
functionName: "recordBucketMedian",
|
||||
args: [previousBucket],
|
||||
});
|
||||
console.log(
|
||||
`Recorded median for bucket ${previousBucket} (reports ${reportedCount}/${requiredReports}).`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to record median for bucket ${previousBucket}:`, (err as Error).message);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`Skipping median recording for bucket ${previousBucket}; only ${reportedCount}/${requiredReports} reports.`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Unable to evaluate automatic recordBucketMedian:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Slashing: if previous bucket had outliers
|
||||
if (AUTO_SLASH) {
|
||||
try {
|
||||
const outliers = (await publicClient.readContract({
|
||||
address,
|
||||
abi,
|
||||
functionName: "getOutlierNodes",
|
||||
args: [previousBucket],
|
||||
})) as `0x${string}`[];
|
||||
|
||||
if (outliers.length > 0) {
|
||||
console.log(`Found ${outliers.length} outliers in bucket ${previousBucket}, attempting to slash...`);
|
||||
// Use the first wallet (deployer) to slash
|
||||
const slasher = allWalletClients[0];
|
||||
for (const nodeAddr of outliers) {
|
||||
const index = await findNodeIndex(runtime, address, abi, nodeAddr);
|
||||
if (index === null) {
|
||||
console.warn(`Index not found for node ${nodeAddr}, skipping slashing.`);
|
||||
continue;
|
||||
}
|
||||
const reportIndex = await getReportIndexForNode(
|
||||
publicClient,
|
||||
address,
|
||||
abi,
|
||||
previousBucket,
|
||||
nodeAddr,
|
||||
deployedBlock,
|
||||
);
|
||||
if (reportIndex === null) {
|
||||
console.warn(`Report index not found for node ${nodeAddr}, skipping slashing.`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await slasher.writeContract({
|
||||
address,
|
||||
abi,
|
||||
functionName: "slashNode",
|
||||
args: [nodeAddr, previousBucket, BigInt(reportIndex), BigInt(index)],
|
||||
});
|
||||
console.log(
|
||||
`Slashed node ${nodeAddr} for bucket ${previousBucket} at indices report=${reportIndex}, node=${index}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to slash ${nodeAddr}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// getOutlierNodes may revert for small sample sizes (e.g., 0 or 1 report)
|
||||
console.log(`Skipping slashing check for bucket ${previousBucket}:`, (err as Error).message);
|
||||
}
|
||||
} else {
|
||||
// Auto-slash disabled by flag
|
||||
console.log(`Auto-slash disabled; skipping slashing for bucket ${previousBucket}`);
|
||||
}
|
||||
|
||||
// 4) Rewards: claim when there are unclaimed reports
|
||||
// Wait a couple seconds after reports have been mined before claiming
|
||||
console.log("Waiting 2s before claiming rewards...");
|
||||
await sleep(2000);
|
||||
for (const client of nodeWalletClients) {
|
||||
try {
|
||||
const rawNodeInfo = await publicClient.readContract({
|
||||
address,
|
||||
abi,
|
||||
functionName: "nodes",
|
||||
args: [client.account.address],
|
||||
});
|
||||
const node = normalizeNodeInfo(rawNodeInfo);
|
||||
if (node.reportCount > node.claimedReportCount) {
|
||||
await client.writeContract({ address, abi, functionName: "claimReward", args: [] });
|
||||
console.log(`Claimed rewards for ${client.account.address}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to claim rewards for ${client.account.address}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in oracle cycle:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
console.log("Starting oracle bot system...");
|
||||
// Fetch Uniswap price once at startup; subsequent cycles will base price on on-chain reports
|
||||
currentPrice = await fetchPriceFromUniswap();
|
||||
console.log(`Initial base price from Uniswap: ${currentPrice}`);
|
||||
|
||||
// Spin up nodes (fund + approve + register) for local testing if they aren't registered yet.
|
||||
try {
|
||||
const { address, abi } = await getStakingOracleDeployment(hre);
|
||||
const publicClient = await hre.viem.getPublicClient();
|
||||
const accounts = await hre.viem.getWalletClients();
|
||||
// Mirror deploy script: use accounts[1..10] as oracle nodes
|
||||
const nodeAccounts = accounts.slice(1, 11);
|
||||
const deployerClient = accounts[0];
|
||||
|
||||
const [minimumStake, oraTokenAddress] = await Promise.all([
|
||||
publicClient.readContract({ address, abi, functionName: "MINIMUM_STAKE", args: [] }).then(v => BigInt(String(v))),
|
||||
publicClient
|
||||
.readContract({
|
||||
address,
|
||||
abi,
|
||||
functionName: "oracleToken",
|
||||
args: [],
|
||||
})
|
||||
.then(v => v as unknown as `0x${string}`),
|
||||
]);
|
||||
|
||||
// Default bot stake for local simulations (keep it small so it matches the new UX expectations)
|
||||
const defaultStake = parseEther("500");
|
||||
const stakeAmount = minimumStake > defaultStake ? minimumStake : defaultStake;
|
||||
|
||||
// Build an idempotent setup plan based on current on-chain state (so restarts resume cleanly).
|
||||
const snapshots = await Promise.all(
|
||||
nodeAccounts.map(async nodeClient => {
|
||||
const nodeAddress = nodeClient.account.address;
|
||||
const [rawNodeInfo, balance, allowance] = await Promise.all([
|
||||
publicClient
|
||||
.readContract({ address, abi, functionName: "nodes", args: [nodeAddress] })
|
||||
.catch(() => null as any),
|
||||
publicClient.readContract({
|
||||
address: oraTokenAddress,
|
||||
abi: oraTokenAbi,
|
||||
functionName: "balanceOf",
|
||||
args: [nodeAddress],
|
||||
}) as Promise<bigint>,
|
||||
publicClient.readContract({
|
||||
address: oraTokenAddress,
|
||||
abi: oraTokenAbi,
|
||||
functionName: "allowance",
|
||||
args: [nodeAddress, address],
|
||||
}) as Promise<bigint>,
|
||||
]);
|
||||
|
||||
const node = normalizeNodeInfo(rawNodeInfo);
|
||||
const effectiveStake = node.active
|
||||
? await publicClient
|
||||
.readContract({ address, abi, functionName: "getEffectiveStake", args: [nodeAddress] })
|
||||
.then(v => BigInt(String(v)))
|
||||
.catch(() => 0n)
|
||||
: 0n;
|
||||
|
||||
return { nodeClient, nodeAddress, node, effectiveStake, balance, allowance };
|
||||
}),
|
||||
);
|
||||
|
||||
const transfers: { to: `0x${string}`; amount: bigint }[] = [];
|
||||
const perNodeActions: {
|
||||
nodeClient: WalletClient;
|
||||
nodeAddress: `0x${string}`;
|
||||
approveAmount: bigint;
|
||||
kind: "register" | "addStake" | "none";
|
||||
amount: bigint;
|
||||
note: string;
|
||||
}[] = [];
|
||||
|
||||
for (const snap of snapshots) {
|
||||
const { nodeClient, nodeAddress, node, effectiveStake, balance, allowance } = snap;
|
||||
|
||||
if (node.active) {
|
||||
if (effectiveStake < minimumStake) {
|
||||
const needed = minimumStake - effectiveStake;
|
||||
const transferAmount = balance < needed ? needed - balance : 0n;
|
||||
if (transferAmount > 0n) transfers.push({ to: nodeAddress, amount: transferAmount });
|
||||
|
||||
const approveAmount = allowance < needed ? needed : 0n;
|
||||
perNodeActions.push({
|
||||
nodeClient,
|
||||
nodeAddress,
|
||||
approveAmount,
|
||||
kind: "addStake",
|
||||
amount: needed,
|
||||
note: `top up effectiveStake=${effectiveStake} by ${needed}`,
|
||||
});
|
||||
} else {
|
||||
perNodeActions.push({
|
||||
nodeClient,
|
||||
nodeAddress,
|
||||
approveAmount: 0n,
|
||||
kind: "none",
|
||||
amount: 0n,
|
||||
note: "already active (no action)",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inactive -> fund/approve/register. On restart, we only do the missing pieces.
|
||||
const transferAmount = balance < stakeAmount ? stakeAmount - balance : 0n;
|
||||
if (transferAmount > 0n) transfers.push({ to: nodeAddress, amount: transferAmount });
|
||||
|
||||
const approveAmount = allowance < stakeAmount ? stakeAmount : 0n;
|
||||
perNodeActions.push({
|
||||
nodeClient,
|
||||
nodeAddress,
|
||||
approveAmount,
|
||||
kind: "register",
|
||||
amount: stakeAmount,
|
||||
note: `register with stake=${stakeAmount}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 1) Fund nodes in one burst from deployer using nonce chaining.
|
||||
if (transfers.length > 0) {
|
||||
const deployerNonce = await publicClient.getTransactionCount({ address: deployerClient.account.address });
|
||||
const transferTxs: `0x${string}`[] = [];
|
||||
console.log(`Funding ${transfers.length} node(s) from deployer (burst)...`);
|
||||
for (const [i, t] of transfers.entries()) {
|
||||
const tx = await deployerClient.writeContract({
|
||||
address: oraTokenAddress,
|
||||
abi: oraTokenAbi,
|
||||
functionName: "transfer",
|
||||
nonce: deployerNonce + i,
|
||||
args: [t.to, t.amount],
|
||||
});
|
||||
transferTxs.push(tx as `0x${string}`);
|
||||
}
|
||||
await Promise.all(transferTxs.map(hash => publicClient.waitForTransactionReceipt({ hash })));
|
||||
console.log("Funding burst mined.");
|
||||
}
|
||||
|
||||
// 2) For each node, chain approve -> (register|addStake) with explicit nonces, then wait for all receipts once.
|
||||
const nodeNonces = await Promise.all(
|
||||
perNodeActions.map(a => publicClient.getTransactionCount({ address: a.nodeAddress })),
|
||||
);
|
||||
const nodeTxs: `0x${string}`[] = [];
|
||||
|
||||
for (const [idx, action] of perNodeActions.entries()) {
|
||||
const { nodeClient, nodeAddress, approveAmount, kind, amount, note } = action;
|
||||
let nonce = nodeNonces[idx];
|
||||
|
||||
if (kind === "none") {
|
||||
console.log(`Node ${nodeAddress}: ${note}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Node ${nodeAddress}: ${note}`);
|
||||
|
||||
if (approveAmount > 0n) {
|
||||
const tx = await nodeClient.writeContract({
|
||||
address: oraTokenAddress,
|
||||
abi: oraTokenAbi,
|
||||
functionName: "approve",
|
||||
nonce,
|
||||
args: [address, approveAmount],
|
||||
});
|
||||
nodeTxs.push(tx as `0x${string}`);
|
||||
nonce += 1;
|
||||
}
|
||||
|
||||
if (kind === "register") {
|
||||
const tx = await nodeClient.writeContract({
|
||||
address,
|
||||
abi,
|
||||
functionName: "registerNode",
|
||||
nonce,
|
||||
args: [amount],
|
||||
});
|
||||
nodeTxs.push(tx as `0x${string}`);
|
||||
} else if (kind === "addStake") {
|
||||
const tx = await nodeClient.writeContract({
|
||||
address,
|
||||
abi,
|
||||
functionName: "addStake",
|
||||
nonce,
|
||||
args: [amount],
|
||||
});
|
||||
nodeTxs.push(tx as `0x${string}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeTxs.length > 0) {
|
||||
console.log(`Waiting for ${nodeTxs.length} node tx(s) to be mined...`);
|
||||
await Promise.all(nodeTxs.map(hash => publicClient.waitForTransactionReceipt({ hash })));
|
||||
console.log("Node setup txs mined.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Node registration step failed:", (err as Error).message);
|
||||
}
|
||||
while (true) {
|
||||
await runCycle(hre);
|
||||
await sleep(12000);
|
||||
}
|
||||
};
|
||||
|
||||
run().catch(error => {
|
||||
console.error("Fatal error in oracle bot system:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle process termination signals
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("\nReceived SIGINT (Ctrl+C). Cleaning up...");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("\nReceived SIGTERM. Cleaning up...");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on("uncaughtException", async error => {
|
||||
console.error("Uncaught Exception:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on("unhandledRejection", async (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
process.exit(1);
|
||||
});
|
||||
112
packages/hardhat/scripts/runWhitelistOracleBots.ts
Normal file
112
packages/hardhat/scripts/runWhitelistOracleBots.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { ethers } from "hardhat";
|
||||
import { WhitelistOracle } from "../typechain-types";
|
||||
import hre from "hardhat";
|
||||
import { HardhatRuntimeEnvironment } from "hardhat/types";
|
||||
import { fetchPriceFromUniswap } from "./fetchPriceFromUniswap";
|
||||
import { sleep } from "./utils";
|
||||
|
||||
async function getAllOracles() {
|
||||
const [deployer] = await ethers.getSigners();
|
||||
const whitelistContract = await ethers.getContract<WhitelistOracle>("WhitelistOracle", deployer.address);
|
||||
|
||||
const oracleAddresses = [];
|
||||
let index = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const oracle = await whitelistContract.oracles(index);
|
||||
oracleAddresses.push(oracle);
|
||||
index++;
|
||||
}
|
||||
} catch {
|
||||
// When we hit an out-of-bounds error, we've found all oracles
|
||||
console.log(`Found ${oracleAddresses.length} oracles`);
|
||||
}
|
||||
|
||||
return oracleAddresses;
|
||||
}
|
||||
|
||||
function getRandomPrice(basePrice: bigint): bigint {
|
||||
const percentageShifts = [1, 2, 5, 7, 10, 15, 20];
|
||||
const randomIndex = Math.floor(Math.random() * percentageShifts.length);
|
||||
const percentage = BigInt(percentageShifts[randomIndex]);
|
||||
|
||||
const direction = Math.random() < 0.5 ? -1n : 1n;
|
||||
const offset = (basePrice * percentage * direction) / 100n;
|
||||
|
||||
return basePrice + offset;
|
||||
}
|
||||
|
||||
const runCycle = async (hre: HardhatRuntimeEnvironment, basePrice: bigint) => {
|
||||
try {
|
||||
const accounts = await hre.viem.getWalletClients();
|
||||
const simpleOracleFactory = await ethers.getContractFactory("SimpleOracle");
|
||||
const publicClient = await hre.viem.getPublicClient();
|
||||
|
||||
const blockNumber = await publicClient.getBlockNumber();
|
||||
console.log(`\n[Block ${blockNumber}] Starting new whitelist oracle cycle...`);
|
||||
const oracleAddresses = await getAllOracles();
|
||||
if (oracleAddresses.length === 0) {
|
||||
console.log("No oracles found");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const oracleAddress of oracleAddresses) {
|
||||
if (Math.random() < 0.4) {
|
||||
console.log(`Skipping oracle at ${oracleAddress}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const randomPrice = getRandomPrice(basePrice);
|
||||
console.log(`Setting price for oracle at ${oracleAddress} to ${randomPrice}`);
|
||||
|
||||
await accounts[0].writeContract({
|
||||
address: oracleAddress as `0x${string}`,
|
||||
abi: simpleOracleFactory.interface.fragments,
|
||||
functionName: "setPrice",
|
||||
args: [randomPrice],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in oracle cycle:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
async function run() {
|
||||
console.log("Starting whitelist oracle bots...");
|
||||
const basePrice = await fetchPriceFromUniswap();
|
||||
|
||||
while (true) {
|
||||
await runCycle(hre, basePrice);
|
||||
await sleep(4000);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
// Handle process termination signals
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("\nReceived SIGINT (Ctrl+C). Cleaning up...");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("\nReceived SIGTERM. Cleaning up...");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on("uncaughtException", async error => {
|
||||
console.error("Uncaught Exception:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
process.on("unhandledRejection", async (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
process.exit(1);
|
||||
});
|
||||
102
packages/hardhat/scripts/utils.ts
Normal file
102
packages/hardhat/scripts/utils.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Config } from "./oracle-bot/types";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const getConfigPath = (): string => {
|
||||
return path.join(__dirname, "oracle-bot", "config.json");
|
||||
};
|
||||
|
||||
export const getConfig = (): Config => {
|
||||
const configPath = getConfigPath();
|
||||
const configContent = fs.readFileSync(configPath, "utf-8");
|
||||
const config = JSON.parse(configContent) as Config;
|
||||
return config;
|
||||
};
|
||||
|
||||
export const updateConfig = (updates: Partial<Config>): void => {
|
||||
const configPath = getConfigPath();
|
||||
const currentConfig = getConfig();
|
||||
const updatedConfig = { ...currentConfig, ...updates };
|
||||
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
|
||||
};
|
||||
|
||||
export const updatePriceCache = (price: number, timestamp: number): void => {
|
||||
updateConfig({
|
||||
PRICE: {
|
||||
CACHEDPRICE: price,
|
||||
TIMESTAMP: timestamp,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export const QUESTIONS_FOR_OO: string[] = [
|
||||
"Did ETH/USD exceed $3,000 at 00:00 UTC on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did the BTC/ETH ratio fall below 14 on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did Uniswap's TVL exceed $10B on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did the Ethereum Cancun upgrade activate before {MONTH} {DAY}, {YEAR}?",
|
||||
"Did the average gas price on Ethereum exceed 200 gwei on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did Ethereum's staking participation rate exceed 25% on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did Base chain have more than 1M daily transactions on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did the SEC approve a Bitcoin ETF before {MONTH} {DAY}, {YEAR}?",
|
||||
"Did OpenSea's trading volume exceed $500M in {MONTH} {YEAR}?",
|
||||
"Did Farcaster have more than 10K active users on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did ENS domains exceed 5M total registrations before {MONTH} {YEAR}?",
|
||||
"Did the total bridged USDC on Arbitrum exceed $2B on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did Optimism's native token OP increase above $1.50 on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did Aave v3 have higher borrow volume than v2 on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did Compound see more than 1,000 liquidations on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did BTC's 24-hour volume exceed $50B on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did Real Madrid win the UEFA Champions League Final in {YEAR}?",
|
||||
"Did G2 Esports win a major tournament in {MONTH} {YEAR}?",
|
||||
"Did the temperature in New York exceed 35°C on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did it rain more than 50mm in London on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did Tokyo experience an earthquake of magnitude 5.0 or higher in {MONTH} {YEAR}?",
|
||||
"Did the Nasdaq Composite fall more than 3% on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did the S&P 500 set a new all-time high on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did the US unemployment rate drop below 4% in {MONTH} {YEAR}?",
|
||||
"Did the average global temperature for {MONTH} {YEAR} exceed that of the previous year?",
|
||||
"Did gold price exceed $2,200/oz on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did YouTube's most viewed video gain more than 10M new views in {MONTH} {YEAR}?",
|
||||
"Did the population of India officially surpass China according to the UN in {YEAR}?",
|
||||
"Did the UEFA Euro 2024 Final have more than 80,000 attendees in the stadium?",
|
||||
"Did a pigeon successfully complete a 500km race in under 10 hours in {MONTH} {YEAR}?",
|
||||
"Did a goat attend a university graduation ceremony wearing a cap and gown on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did someone eat 100 chicken nuggets in under 10 minutes on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did a cat walk across a live TV weather report in {MONTH} {YEAR}?",
|
||||
"Did a cow escape from a farm and get caught on camera riding a water slide in {YEAR}?",
|
||||
"Did a man legally change his name to 'Bitcoin McMoneyface' on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did a squirrel steal a GoPro and film itself on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did someone cosplay as Shrek and complete a full marathon on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did a group of people attempt to cook the world's largest pancake using a flamethrower?",
|
||||
"Did a man propose using a pizza drone delivery on {MONTH} {DAY}, {YEAR}?",
|
||||
"Did a woman knit a sweater large enough to cover a school bus in {MONTH} {YEAR}?",
|
||||
"Did someone attempt to break the world record for most dad jokes told in 1 hour?",
|
||||
"Did an alpaca accidentally join a Zoom meeting for a tech startup on {MONTH} {DAY}, {YEAR}?",
|
||||
];
|
||||
|
||||
const generateRandomPastDate = (now: Date): Date => {
|
||||
const daysBack = Math.floor(Math.random() * 45) + 1; // 1 - 45 days
|
||||
|
||||
const pastDate = new Date(now);
|
||||
pastDate.setDate(pastDate.getDate() - daysBack);
|
||||
|
||||
return pastDate;
|
||||
};
|
||||
|
||||
const replaceDatePlaceholders = (question: string): string => {
|
||||
const now = new Date();
|
||||
const past = generateRandomPastDate(now);
|
||||
|
||||
return question
|
||||
.replace(/\{DAY\}/g, past.getDate().toString())
|
||||
.replace(/\{MONTH\}/g, past.toLocaleDateString("en-US", { month: "long" }))
|
||||
.replace(/\{YEAR\}/g, past.getFullYear().toString());
|
||||
};
|
||||
|
||||
export const getRandomQuestion = (): string => {
|
||||
const randomIndex = Math.floor(Math.random() * QUESTIONS_FOR_OO.length);
|
||||
const question = QUESTIONS_FOR_OO[randomIndex];
|
||||
return replaceDatePlaceholders(question);
|
||||
};
|
||||
2
packages/hardhat/test/.gitkeep
Normal file
2
packages/hardhat/test/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# Write tests for your smart contract in this directory
|
||||
# Example: YourContract.ts
|
||||
666
packages/hardhat/test/OptimisticOracle.ts
Normal file
666
packages/hardhat/test/OptimisticOracle.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
import { expect } from "chai";
|
||||
import { ethers } from "hardhat";
|
||||
import { OptimisticOracle, Decider } from "../typechain-types";
|
||||
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
|
||||
|
||||
describe("OptimisticOracle", function () {
|
||||
before(async () => {
|
||||
await ethers.provider.send("evm_setAutomine", [true]);
|
||||
await ethers.provider.send("evm_setIntervalMining", [0]);
|
||||
});
|
||||
|
||||
let optimisticOracle: OptimisticOracle;
|
||||
let deciderContract: Decider;
|
||||
let owner: HardhatEthersSigner;
|
||||
let asserter: HardhatEthersSigner;
|
||||
let proposer: HardhatEthersSigner;
|
||||
let disputer: HardhatEthersSigner;
|
||||
let otherUser: HardhatEthersSigner;
|
||||
|
||||
const contractAddress = process.env.CONTRACT_ADDRESS;
|
||||
|
||||
let contractArtifact: string;
|
||||
if (contractAddress) {
|
||||
// For the autograder
|
||||
contractArtifact = `contracts/download-${contractAddress}.sol:OptimisticOracle`;
|
||||
} else {
|
||||
contractArtifact = "contracts/02_Optimistic/OptimisticOracle.sol:OptimisticOracle";
|
||||
}
|
||||
|
||||
// Enum for state
|
||||
const State = {
|
||||
Invalid: 0n,
|
||||
Asserted: 1n,
|
||||
Proposed: 2n,
|
||||
Disputed: 3n,
|
||||
Settled: 4n,
|
||||
Expired: 5n,
|
||||
};
|
||||
|
||||
beforeEach(async function () {
|
||||
[owner, asserter, proposer, disputer, otherUser] = await ethers.getSigners();
|
||||
|
||||
// Deploy OptimisticOracle with temporary decider (owner)
|
||||
const OptimisticOracleFactory = await ethers.getContractFactory(contractArtifact);
|
||||
optimisticOracle = (await OptimisticOracleFactory.deploy(owner.address)) as OptimisticOracle;
|
||||
|
||||
// Deploy Decider
|
||||
const DeciderFactory = await ethers.getContractFactory("Decider");
|
||||
deciderContract = await DeciderFactory.deploy(optimisticOracle.target);
|
||||
|
||||
// Set the decider in the oracle
|
||||
await optimisticOracle.setDecider(deciderContract.target);
|
||||
});
|
||||
describe("Checkpoint4", function () {
|
||||
describe("Deployment", function () {
|
||||
it("Should deploy successfully", function () {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
expect(optimisticOracle.target).to.not.be.undefined;
|
||||
});
|
||||
|
||||
it("Should set the correct owner", async function () {
|
||||
const contractOwner = await optimisticOracle.owner();
|
||||
expect(contractOwner).to.equal(owner.address);
|
||||
});
|
||||
|
||||
it("Should have correct constants", async function () {
|
||||
const minimumAssertionWindow = await optimisticOracle.MINIMUM_ASSERTION_WINDOW();
|
||||
const disputeWindow = await optimisticOracle.DISPUTE_WINDOW();
|
||||
|
||||
expect(minimumAssertionWindow).to.equal(180n); // 3 minutes
|
||||
expect(disputeWindow).to.equal(180n); // 3 minutes
|
||||
});
|
||||
|
||||
it("Should start with nextAssertionId at 1", async function () {
|
||||
const nextAssertionId = await optimisticOracle.nextAssertionId();
|
||||
expect(nextAssertionId).to.equal(1n);
|
||||
});
|
||||
|
||||
it("Should return correct assertionId for first assertion", async function () {
|
||||
const description = "Will Bitcoin reach $1m by end of 2026?";
|
||||
const reward = ethers.parseEther("1");
|
||||
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
// Get the assertionId from the event
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
const assertionId = parsedEvent!.args[0];
|
||||
|
||||
expect(assertionId).to.equal(1n);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Event Assertion", function () {
|
||||
it("Should allow users to assert events with reward", async function () {
|
||||
const description = "Will Bitcoin reach $1m by end of 2026?";
|
||||
const reward = ethers.parseEther("1");
|
||||
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
|
||||
// Get the assertionId from the event
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
const assertionId = parsedEvent!.args[0];
|
||||
|
||||
expect(tx)
|
||||
.to.emit(optimisticOracle, "EventAsserted")
|
||||
.withArgs(assertionId, asserter.address, description, reward);
|
||||
});
|
||||
|
||||
it("Should reject assertions with insufficient reward", async function () {
|
||||
const description = "Will Bitcoin reach $1m by end of 2026?";
|
||||
const insufficientReward = ethers.parseEther("0.0");
|
||||
|
||||
await expect(
|
||||
optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: insufficientReward }),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "InvalidValue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Outcome Proposal", function () {
|
||||
let assertionId: bigint;
|
||||
let description: string;
|
||||
let reward: bigint;
|
||||
|
||||
beforeEach(async function () {
|
||||
description = "Will Bitcoin reach $1m by end of 2026?";
|
||||
reward = ethers.parseEther("1");
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
// Get the assertionId from the event
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
assertionId = parsedEvent!.args[0];
|
||||
});
|
||||
|
||||
it("Should allow proposing outcomes with correct bond", async function () {
|
||||
const bond = reward * 2n;
|
||||
const outcome = true;
|
||||
|
||||
const tx = await optimisticOracle.connect(proposer).proposeOutcome(assertionId, outcome, { value: bond });
|
||||
|
||||
expect(tx).to.emit(optimisticOracle, "OutcomeProposed").withArgs(assertionId, proposer.address, outcome);
|
||||
});
|
||||
|
||||
it("Should reject proposals with incorrect bond", async function () {
|
||||
const wrongBond = ethers.parseEther("0.05");
|
||||
const outcome = true;
|
||||
|
||||
await expect(
|
||||
optimisticOracle.connect(proposer).proposeOutcome(assertionId, outcome, { value: wrongBond }),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "InvalidValue");
|
||||
});
|
||||
|
||||
it("Should reject duplicate proposals", async function () {
|
||||
const bond = reward * 2n;
|
||||
const outcome = true;
|
||||
|
||||
await optimisticOracle.connect(proposer).proposeOutcome(assertionId, outcome, { value: bond });
|
||||
|
||||
await expect(
|
||||
optimisticOracle.connect(otherUser).proposeOutcome(assertionId, !outcome, { value: bond }),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "AssertionProposed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Outcome Dispute", function () {
|
||||
let assertionId: bigint;
|
||||
let description: string;
|
||||
let reward: bigint;
|
||||
|
||||
beforeEach(async function () {
|
||||
description = "Will Bitcoin reach $1m by end of 2026?";
|
||||
reward = ethers.parseEther("1");
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
assertionId = parsedEvent!.args[0];
|
||||
|
||||
const bond = reward * 2n;
|
||||
await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond });
|
||||
});
|
||||
|
||||
it("Should allow disputing outcomes with correct bond", async function () {
|
||||
const bond = reward * 2n;
|
||||
|
||||
const tx = await optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond });
|
||||
|
||||
expect(tx).to.emit(optimisticOracle, "OutcomeDisputed").withArgs(assertionId, disputer.address);
|
||||
});
|
||||
|
||||
it("Should reject disputes with incorrect bond", async function () {
|
||||
const wrongBond = ethers.parseEther("0.05");
|
||||
|
||||
await expect(
|
||||
optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: wrongBond }),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "InvalidValue");
|
||||
});
|
||||
|
||||
it("Should reject disputes after deadline", async function () {
|
||||
// Fast forward time past dispute window
|
||||
await ethers.provider.send("evm_increaseTime", [181]); // 3 minutes + 1 second
|
||||
await ethers.provider.send("evm_mine");
|
||||
|
||||
const bond = reward * 2n;
|
||||
await expect(
|
||||
optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond }),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "InvalidTime");
|
||||
});
|
||||
|
||||
it("Should reject duplicate disputes", async function () {
|
||||
const bond = reward * 2n;
|
||||
await optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond });
|
||||
|
||||
await expect(
|
||||
optimisticOracle.connect(otherUser).disputeOutcome(assertionId, { value: bond }),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "ProposalDisputed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Start and End Time Logic", function () {
|
||||
it("Should not allow proposal before startTime", async function () {
|
||||
const reward = ethers.parseEther("1");
|
||||
const now = (await ethers.provider.getBlock("latest"))!.timestamp;
|
||||
const start = now + 1000;
|
||||
const end = start + 1000;
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent("future event", start, end, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
if (!parsedEvent) throw new Error("Event not found");
|
||||
const assertionId = parsedEvent.args[0];
|
||||
|
||||
const bond = reward * 2n;
|
||||
await expect(
|
||||
optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "InvalidTime");
|
||||
});
|
||||
|
||||
it("Should not allow proposal after endTime", async function () {
|
||||
const reward = ethers.parseEther("1");
|
||||
const now = (await ethers.provider.getBlock("latest"))!.timestamp;
|
||||
const start = now + 1; // Start time must be in the future
|
||||
const end = now + 200; // 200 seconds, which is more than DISPUTE_WINDOW (180 seconds)
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent("short event", start, end, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
if (!parsedEvent) throw new Error("Event not found");
|
||||
const assertionId = parsedEvent.args[0];
|
||||
|
||||
// Wait until after endTime
|
||||
await ethers.provider.send("evm_increaseTime", [201]);
|
||||
await ethers.provider.send("evm_mine");
|
||||
|
||||
const bond = reward * 2n;
|
||||
await expect(
|
||||
optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "InvalidTime");
|
||||
});
|
||||
|
||||
it("Should allow proposal only within [startTime, endTime]", async function () {
|
||||
const reward = ethers.parseEther("1");
|
||||
const now = (await ethers.provider.getBlock("latest"))!.timestamp;
|
||||
const start = now + 10; // Start time in the future
|
||||
const end = start + 200; // Ensure endTime is far enough in the future
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent("window event", start, end, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
if (!parsedEvent) throw new Error("Event not found");
|
||||
const assertionId = parsedEvent.args[0];
|
||||
|
||||
const bond = reward * 2n;
|
||||
|
||||
// Before startTime - should fail
|
||||
await expect(
|
||||
optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond }),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "InvalidTime");
|
||||
|
||||
// Move to startTime
|
||||
await ethers.provider.send("evm_increaseTime", [10]);
|
||||
await ethers.provider.send("evm_mine");
|
||||
|
||||
// Now it should work
|
||||
await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Checkpoint5", function () {
|
||||
describe("Undisputed Reward Claiming", function () {
|
||||
let assertionId: bigint;
|
||||
let description: string;
|
||||
let reward: bigint;
|
||||
|
||||
beforeEach(async function () {
|
||||
description = "Will Bitcoin reach $1m by end of 2026?";
|
||||
reward = ethers.parseEther("1");
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
assertionId = parsedEvent!.args[0];
|
||||
|
||||
const bond = reward * 2n;
|
||||
await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond });
|
||||
});
|
||||
|
||||
it("Should allow claiming undisputed rewards after deadline", async function () {
|
||||
// Fast forward time past dispute window
|
||||
await ethers.provider.send("evm_increaseTime", [181]);
|
||||
await ethers.provider.send("evm_mine");
|
||||
|
||||
const initialBalance = await ethers.provider.getBalance(proposer.address);
|
||||
const tx = await optimisticOracle.connect(proposer).claimUndisputedReward(assertionId);
|
||||
const receipt = await tx.wait();
|
||||
const finalBalance = await ethers.provider.getBalance(proposer.address);
|
||||
|
||||
// Check that proposer received the reward (reward + bond - gas costs)
|
||||
const expectedReward = reward + reward * 2n;
|
||||
const gasCost = receipt!.gasUsed * receipt!.gasPrice!;
|
||||
expect(finalBalance - initialBalance + gasCost).to.equal(expectedReward);
|
||||
});
|
||||
|
||||
it("Should reject claiming before deadline", async function () {
|
||||
await expect(
|
||||
optimisticOracle.connect(proposer).claimUndisputedReward(assertionId),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "InvalidTime");
|
||||
});
|
||||
|
||||
it("Should reject claiming disputed assertions", async function () {
|
||||
const bond = reward * 2n;
|
||||
await optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond });
|
||||
|
||||
await expect(
|
||||
optimisticOracle.connect(proposer).claimUndisputedReward(assertionId),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "ProposalDisputed");
|
||||
});
|
||||
|
||||
it("Should reject claiming already claimed rewards", async function () {
|
||||
// Fast forward time and claim
|
||||
await ethers.provider.send("evm_increaseTime", [181]);
|
||||
await ethers.provider.send("evm_mine");
|
||||
await optimisticOracle.connect(proposer).claimUndisputedReward(assertionId);
|
||||
|
||||
await expect(
|
||||
optimisticOracle.connect(proposer).claimUndisputedReward(assertionId),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "AlreadyClaimed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disputed Reward Claiming", function () {
|
||||
let assertionId: bigint;
|
||||
let description: string;
|
||||
let reward: bigint;
|
||||
|
||||
beforeEach(async function () {
|
||||
description = "Will Bitcoin reach $1m by end of 2026?";
|
||||
reward = ethers.parseEther("1");
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
assertionId = parsedEvent!.args[0];
|
||||
|
||||
const bond = reward * 2n;
|
||||
await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond });
|
||||
await optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond });
|
||||
});
|
||||
|
||||
it("Should allow winner to claim disputed rewards after settlement", async function () {
|
||||
// Settle with proposer winning
|
||||
await deciderContract.connect(owner).settleDispute(assertionId, true);
|
||||
|
||||
const initialBalance = await ethers.provider.getBalance(proposer.address);
|
||||
const tx = await optimisticOracle.connect(proposer).claimDisputedReward(assertionId);
|
||||
const receipt = await tx.wait();
|
||||
const finalBalance = await ethers.provider.getBalance(proposer.address);
|
||||
|
||||
// Check that proposer received the reward (reward + bond - gas costs)
|
||||
const expectedReward = reward * 3n;
|
||||
const gasCost = receipt!.gasUsed * receipt!.gasPrice!;
|
||||
expect(finalBalance - initialBalance + gasCost).to.equal(expectedReward);
|
||||
});
|
||||
|
||||
it("Should allow disputer to claim when they win", async function () {
|
||||
// Settle with disputer winning
|
||||
await deciderContract.connect(owner).settleDispute(assertionId, false);
|
||||
|
||||
const initialBalance = await ethers.provider.getBalance(disputer.address);
|
||||
const tx = await optimisticOracle.connect(disputer).claimDisputedReward(assertionId);
|
||||
const receipt = await tx.wait();
|
||||
const finalBalance = await ethers.provider.getBalance(disputer.address);
|
||||
|
||||
// Check that disputer received the reward
|
||||
const expectedReward = reward * 3n;
|
||||
const gasCost = receipt!.gasUsed * receipt!.gasPrice!;
|
||||
expect(finalBalance - initialBalance + gasCost).to.equal(expectedReward);
|
||||
});
|
||||
|
||||
it("Should reject claiming before settlement", async function () {
|
||||
await expect(optimisticOracle.connect(proposer).claimDisputedReward(assertionId)).to.be.revertedWithCustomError(
|
||||
optimisticOracle,
|
||||
"AwaitingDecider",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should reject claiming already claimed rewards", async function () {
|
||||
await deciderContract.connect(owner).settleDispute(assertionId, true);
|
||||
await optimisticOracle.connect(proposer).claimDisputedReward(assertionId);
|
||||
|
||||
await expect(optimisticOracle.connect(proposer).claimDisputedReward(assertionId)).to.be.revertedWithCustomError(
|
||||
optimisticOracle,
|
||||
"AlreadyClaimed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Refund Claiming", function () {
|
||||
let assertionId: bigint;
|
||||
let description: string;
|
||||
let reward: bigint;
|
||||
|
||||
beforeEach(async function () {
|
||||
description = "Will Bitcoin reach $1m by end of 2026?";
|
||||
reward = ethers.parseEther("1");
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
assertionId = parsedEvent!.args[0];
|
||||
});
|
||||
|
||||
it("Should allow asserter to claim refund for assertions without proposals", async function () {
|
||||
// Fast forward time past dispute window
|
||||
await ethers.provider.send("evm_increaseTime", [181]);
|
||||
await ethers.provider.send("evm_mine");
|
||||
|
||||
const initialBalance = await ethers.provider.getBalance(asserter.address);
|
||||
const tx = await optimisticOracle.connect(asserter).claimRefund(assertionId);
|
||||
const receipt = await tx.wait();
|
||||
const finalBalance = await ethers.provider.getBalance(asserter.address);
|
||||
|
||||
// Check that asserter received the refund (reward - gas costs)
|
||||
const gasCost = receipt!.gasUsed * receipt!.gasPrice!;
|
||||
expect(finalBalance - initialBalance + gasCost).to.equal(reward);
|
||||
});
|
||||
|
||||
it("Should reject refund claiming for assertions with proposals", async function () {
|
||||
const bond = reward * 2n;
|
||||
await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond });
|
||||
|
||||
await expect(optimisticOracle.connect(asserter).claimRefund(assertionId)).to.be.revertedWithCustomError(
|
||||
optimisticOracle,
|
||||
"AssertionProposed",
|
||||
);
|
||||
});
|
||||
|
||||
it("Should reject claiming already claimed refunds", async function () {
|
||||
// Fast forward time and claim
|
||||
await ethers.provider.send("evm_increaseTime", [181]);
|
||||
await ethers.provider.send("evm_mine");
|
||||
await optimisticOracle.connect(asserter).claimRefund(assertionId);
|
||||
|
||||
await expect(optimisticOracle.connect(asserter).claimRefund(assertionId)).to.be.revertedWithCustomError(
|
||||
optimisticOracle,
|
||||
"AlreadyClaimed",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Checkpoint6", function () {
|
||||
describe("Dispute Settlement", function () {
|
||||
let assertionId: bigint;
|
||||
let description: string;
|
||||
let reward: bigint;
|
||||
|
||||
beforeEach(async function () {
|
||||
description = "Will Bitcoin reach $1m by end of 2026?";
|
||||
reward = ethers.parseEther("1");
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
assertionId = parsedEvent!.args[0];
|
||||
|
||||
const bond = reward * 2n;
|
||||
await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond });
|
||||
await optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond });
|
||||
});
|
||||
|
||||
it("Should allow decider to settle disputed assertions", async function () {
|
||||
const resolvedOutcome = true;
|
||||
const tx = await deciderContract.connect(owner).settleDispute(assertionId, resolvedOutcome);
|
||||
|
||||
expect(tx)
|
||||
.to.emit(optimisticOracle, "AssertionSettled")
|
||||
.withArgs(assertionId, resolvedOutcome, proposer.address);
|
||||
|
||||
// Check that the assertion was settled correctly by checking the state
|
||||
const state = await optimisticOracle.getState(assertionId);
|
||||
expect(state).to.equal(State.Settled); // Settled state
|
||||
});
|
||||
|
||||
it("Should reject settlement by non-decider", async function () {
|
||||
const resolvedOutcome = true;
|
||||
|
||||
await expect(
|
||||
optimisticOracle.connect(otherUser).settleAssertion(assertionId, resolvedOutcome),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "OnlyDecider");
|
||||
});
|
||||
|
||||
it("Should reject settling undisputed assertions", async function () {
|
||||
// Create a new undisputed assertion
|
||||
const newDescription = "Will Ethereum reach $10k by end of 2024?";
|
||||
const newTx = await optimisticOracle.connect(asserter).assertEvent(newDescription, 0, 0, { value: reward });
|
||||
const newReceipt = await newTx.wait();
|
||||
const newEvent = newReceipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const newParsedEvent = optimisticOracle.interface.parseLog(newEvent as any);
|
||||
const newAssertionId = newParsedEvent!.args[0];
|
||||
|
||||
const bond = reward * 2n;
|
||||
await optimisticOracle.connect(proposer).proposeOutcome(newAssertionId, true, { value: bond });
|
||||
|
||||
const resolvedOutcome = true;
|
||||
await expect(
|
||||
deciderContract.connect(owner).settleDispute(newAssertionId, resolvedOutcome),
|
||||
).to.be.revertedWithCustomError(optimisticOracle, "NotDisputedAssertion");
|
||||
});
|
||||
});
|
||||
|
||||
describe("State Management", function () {
|
||||
it("Should return correct states for different scenarios", async function () {
|
||||
const description = "Will Bitcoin reach $1m by end of 2026?";
|
||||
const reward = ethers.parseEther("1");
|
||||
|
||||
// Invalid state for non-existent assertion
|
||||
let state = await optimisticOracle.getState(999n);
|
||||
expect(state).to.equal(State.Invalid); // Invalid
|
||||
|
||||
// Asserted state
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
const assertionId = parsedEvent!.args[0];
|
||||
|
||||
state = await optimisticOracle.getState(assertionId);
|
||||
expect(state).to.equal(State.Asserted); // Asserted
|
||||
|
||||
// Proposed state
|
||||
const bond = reward * 2n;
|
||||
await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond });
|
||||
state = await optimisticOracle.getState(assertionId);
|
||||
expect(state).to.equal(State.Proposed); // Proposed
|
||||
|
||||
// Disputed state
|
||||
await optimisticOracle.connect(disputer).disputeOutcome(assertionId, { value: bond });
|
||||
state = await optimisticOracle.getState(assertionId);
|
||||
expect(state).to.equal(State.Disputed); // Disputed
|
||||
|
||||
// Settled state (after decider resolution)
|
||||
await deciderContract.connect(owner).settleDispute(assertionId, true);
|
||||
state = await optimisticOracle.getState(assertionId);
|
||||
expect(state).to.equal(State.Settled); // Settled
|
||||
});
|
||||
|
||||
it("Should show settled state for claimable uncontested assertions", async function () {
|
||||
const description = "Will Ethereum reach $10k by end of 2024?";
|
||||
const reward = ethers.parseEther("1");
|
||||
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
const assertionId = parsedEvent!.args[0];
|
||||
|
||||
const bond = reward * 2n;
|
||||
await optimisticOracle.connect(proposer).proposeOutcome(assertionId, true, { value: bond });
|
||||
|
||||
// Fast forward time past dispute window
|
||||
await ethers.provider.send("evm_increaseTime", [181]);
|
||||
await ethers.provider.send("evm_mine");
|
||||
|
||||
const state = await optimisticOracle.getState(assertionId);
|
||||
expect(state).to.equal(State.Settled); // Settled (can be claimed)
|
||||
});
|
||||
|
||||
it("Should show expired state for assertions without proposals after deadline", async function () {
|
||||
const description = "Will Ethereum reach $10k by end of 2024?";
|
||||
const reward = ethers.parseEther("1");
|
||||
|
||||
const tx = await optimisticOracle.connect(asserter).assertEvent(description, 0, 0, { value: reward });
|
||||
const receipt = await tx.wait();
|
||||
const event = receipt!.logs.find(
|
||||
log => optimisticOracle.interface.parseLog(log as any)?.name === "EventAsserted",
|
||||
);
|
||||
const parsedEvent = optimisticOracle.interface.parseLog(event as any);
|
||||
const assertionId = parsedEvent!.args[0];
|
||||
|
||||
// Fast forward time past dispute window without any proposal
|
||||
await ethers.provider.send("evm_increaseTime", [181]);
|
||||
await ethers.provider.send("evm_mine");
|
||||
|
||||
const state = await optimisticOracle.getState(assertionId);
|
||||
expect(state).to.equal(State.Expired); // Expired
|
||||
});
|
||||
|
||||
it("Should revert getResolution for expired assertions without proposals", 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 assertion window without any proposal
|
||||
await ethers.provider.send("evm_increaseTime", [181]);
|
||||
await ethers.provider.send("evm_mine");
|
||||
|
||||
// getResolution should revert because no proposal was ever made
|
||||
// (expired assertions without proposals have no valid resolution)
|
||||
await expect(optimisticOracle.getResolution(assertionId)).to.be.revertedWithCustomError(
|
||||
optimisticOracle,
|
||||
"NotProposedAssertion",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
572
packages/hardhat/test/StakingOracle.ts
Normal file
572
packages/hardhat/test/StakingOracle.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
import { expect } from "chai";
|
||||
import { ethers } from "hardhat";
|
||||
import { mine } from "@nomicfoundation/hardhat-network-helpers";
|
||||
import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
|
||||
import type { StakingOracle, ORA } from "../typechain-types";
|
||||
|
||||
describe("Checkpoint2 - StakingOracle", function () {
|
||||
before(async () => {
|
||||
await ethers.provider.send("evm_setAutomine", [true]);
|
||||
await ethers.provider.send("evm_setIntervalMining", [0]);
|
||||
});
|
||||
|
||||
let oracle: StakingOracle;
|
||||
let oraToken: ORA;
|
||||
let node1: HardhatEthersSigner;
|
||||
let node2: HardhatEthersSigner;
|
||||
let node3: HardhatEthersSigner;
|
||||
let node4: HardhatEthersSigner;
|
||||
let node5: HardhatEthersSigner;
|
||||
let node6: HardhatEthersSigner;
|
||||
let slasher: HardhatEthersSigner;
|
||||
|
||||
const contractAddress = process.env.CONTRACT_ADDRESS;
|
||||
|
||||
if (contractAddress) {
|
||||
// If env variable is set then skip this test file (for the auto-grader)
|
||||
return true;
|
||||
}
|
||||
|
||||
async function mineBuckets(count: number) {
|
||||
const bucketWindow = Number(await oracle.BUCKET_WINDOW());
|
||||
await mine(bucketWindow * count);
|
||||
}
|
||||
|
||||
async function moveToFreshBucket() {
|
||||
// Ensure we have plenty of blocks left in the current bucket so a multi-tx reporting sequence
|
||||
// doesn't accidentally cross a bucket boundary mid-test.
|
||||
const bucketWindow = Number(await oracle.BUCKET_WINDOW());
|
||||
const blockNum = await ethers.provider.getBlockNumber();
|
||||
const toNext = (bucketWindow - (blockNum % bucketWindow)) % bucketWindow; // 0..bucketWindow-1
|
||||
await mine(toNext + 1);
|
||||
}
|
||||
|
||||
async function oracleAddr() {
|
||||
return await oracle.getAddress();
|
||||
}
|
||||
|
||||
async function stakeForDelayedFirstReport() {
|
||||
// If a node registers and doesn't report in its registration bucket, it will be penalized
|
||||
// once the bucket advances. Give enough buffer so tests can safely mine buckets before first report.
|
||||
const MINIMUM_STAKE = await oracle.MINIMUM_STAKE();
|
||||
const INACTIVITY_PENALTY = await oracle.INACTIVITY_PENALTY();
|
||||
// Buffer several missed buckets to avoid edge cases where setup txs + mining advance multiple buckets.
|
||||
return MINIMUM_STAKE + 10n * INACTIVITY_PENALTY;
|
||||
}
|
||||
|
||||
async function fundApproveAndRegister(node: HardhatEthersSigner, amount: bigint) {
|
||||
// node1 is the ORA deployer and is minted a huge ORA balance in the ORA constructor.
|
||||
if (node.address.toLowerCase() !== node1.address.toLowerCase()) {
|
||||
await (await oraToken.connect(node1).transfer(node.address, amount)).wait();
|
||||
}
|
||||
await (await oraToken.connect(node).approve(await oracleAddr(), amount)).wait();
|
||||
await (await oracle.connect(node).registerNode(amount)).wait();
|
||||
}
|
||||
|
||||
async function indexOfNodeAddress(address: string) {
|
||||
const arr = await oracle.getNodeAddresses();
|
||||
return arr.findIndex(a => a.toLowerCase() === address.toLowerCase());
|
||||
}
|
||||
|
||||
beforeEach(async function () {
|
||||
[node1, node2, node3, node4, node5, node6, slasher] = await ethers.getSigners();
|
||||
const ORAFactory = await ethers.getContractFactory("ORA");
|
||||
oraToken = (await ORAFactory.deploy()) as ORA;
|
||||
await oraToken.waitForDeployment();
|
||||
|
||||
const StakingOracleFactory = await ethers.getContractFactory("StakingOracle");
|
||||
// TypeChain types update on compile; keep test TS-safe even before regeneration.
|
||||
oracle = (await (StakingOracleFactory as any).deploy(await oraToken.getAddress())) as StakingOracle;
|
||||
await oracle.waitForDeployment();
|
||||
|
||||
// StakingOracle must own the ORA token to mint rewards
|
||||
await (await oraToken.transferOwnership(await oracle.getAddress())).wait();
|
||||
});
|
||||
describe("constructor", function () {
|
||||
it("wires the provided ORA token", async function () {
|
||||
const tokenAddress = await oracle.oracleToken();
|
||||
expect(tokenAddress).to.equal(await oraToken.getAddress());
|
||||
});
|
||||
|
||||
it("mints ORA to deployer via token constructor", async function () {
|
||||
const bal = await oraToken.balanceOf(node1.address);
|
||||
expect(bal).to.be.gt(0n);
|
||||
});
|
||||
});
|
||||
describe("getNodeAddresses", function () {
|
||||
it("returns all registered nodes in order", async function () {
|
||||
const MINIMUM_STAKE = await oracle.MINIMUM_STAKE();
|
||||
await fundApproveAndRegister(node1, MINIMUM_STAKE);
|
||||
await fundApproveAndRegister(node2, MINIMUM_STAKE);
|
||||
await fundApproveAndRegister(node3, MINIMUM_STAKE);
|
||||
const nodeAddresses = await oracle.getNodeAddresses();
|
||||
expect(nodeAddresses.length).to.equal(3);
|
||||
expect(nodeAddresses[0]).to.equal(node1.address);
|
||||
expect(nodeAddresses[1]).to.equal(node2.address);
|
||||
expect(nodeAddresses[2]).to.equal(node3.address);
|
||||
});
|
||||
});
|
||||
describe("Node Registration", function () {
|
||||
it("allows register with minimum stake and emits events", async function () {
|
||||
const MINIMUM_STAKE = await oracle.MINIMUM_STAKE();
|
||||
await (await oraToken.connect(node1).approve(await oracleAddr(), MINIMUM_STAKE)).wait();
|
||||
await expect(oracle.connect(node1).registerNode(MINIMUM_STAKE))
|
||||
.to.emit(oracle, "NodeRegistered")
|
||||
.withArgs(node1.address, MINIMUM_STAKE);
|
||||
const info = await oracle.nodes(node1.address);
|
||||
expect(info.stakedAmount).to.equal(MINIMUM_STAKE);
|
||||
expect(info.active).to.equal(true);
|
||||
expect(info.reportCount).to.equal(0n);
|
||||
expect(info.claimedReportCount).to.equal(0n);
|
||||
expect(await oracle.getNodeAddresses()).to.deep.equal([node1.address]);
|
||||
});
|
||||
it("rejects insufficient stake and duplicate registration", async function () {
|
||||
const MINIMUM_STAKE = await oracle.MINIMUM_STAKE();
|
||||
await expect(oracle.connect(node1).registerNode(MINIMUM_STAKE - 1n)).to.be.revertedWithCustomError(
|
||||
oracle,
|
||||
"InsufficientStake",
|
||||
);
|
||||
await (await oraToken.connect(node1).approve(await oracleAddr(), MINIMUM_STAKE)).wait();
|
||||
await oracle.connect(node1).registerNode(MINIMUM_STAKE);
|
||||
await (await oraToken.connect(node1).approve(await oracleAddr(), MINIMUM_STAKE)).wait();
|
||||
await expect(oracle.connect(node1).registerNode(MINIMUM_STAKE)).to.be.revertedWithCustomError(
|
||||
oracle,
|
||||
"NodeAlreadyRegistered",
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("Price Reporting", function () {
|
||||
beforeEach(async function () {
|
||||
await fundApproveAndRegister(node1, await stakeForDelayedFirstReport());
|
||||
});
|
||||
|
||||
it("emits PriceReported and prevents double report in same bucket", async function () {
|
||||
await mineBuckets(1);
|
||||
const tx = await oracle.connect(node1).reportPrice(1600);
|
||||
const rcpt = await tx.wait();
|
||||
if (!rcpt) throw new Error("no receipt");
|
||||
|
||||
const parsed = rcpt.logs
|
||||
.map(log => {
|
||||
try {
|
||||
return oracle.interface.parseLog(log);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.find(e => e?.name === "PriceReported");
|
||||
if (!parsed) throw new Error("PriceReported event not found");
|
||||
|
||||
const reportedNode = parsed.args[0] as string;
|
||||
const reportedPrice = parsed.args[1] as bigint;
|
||||
const reportedBucket = parsed.args[2] as bigint;
|
||||
|
||||
expect(reportedNode).to.equal(node1.address);
|
||||
expect(reportedPrice).to.equal(1600n);
|
||||
|
||||
const [p, slashed] = await oracle.getSlashedStatus(node1.address, reportedBucket);
|
||||
expect(p).to.equal(1600n);
|
||||
expect(slashed).to.equal(false);
|
||||
|
||||
await expect(oracle.connect(node1).reportPrice(1700)).to.be.revertedWithCustomError(
|
||||
oracle,
|
||||
"AlreadyReportedInCurrentBucket",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects zero price and unregistered node", async function () {
|
||||
await expect(oracle.connect(node1).reportPrice(0)).to.be.revertedWithCustomError(oracle, "InvalidPrice");
|
||||
await expect(oracle.connect(node2).reportPrice(1000)).to.be.revertedWithCustomError(oracle, "NodeNotRegistered");
|
||||
});
|
||||
|
||||
it("rejects when effective stake falls below minimum after missed buckets", async function () {
|
||||
// With exact MINIMUM_STAKE, missing 1 expected report applies INACTIVITY_PENALTY and drops below MINIMUM_STAKE.
|
||||
const MINIMUM_STAKE = await oracle.MINIMUM_STAKE();
|
||||
await fundApproveAndRegister(node2, MINIMUM_STAKE);
|
||||
await mineBuckets(1);
|
||||
await expect(oracle.connect(node2).reportPrice(1600)).to.be.revertedWithCustomError(oracle, "InsufficientStake");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claim Reward", function () {
|
||||
beforeEach(async function () {
|
||||
await fundApproveAndRegister(node1, await stakeForDelayedFirstReport());
|
||||
});
|
||||
|
||||
it("reverts when there are no unclaimed report rewards", async function () {
|
||||
await expect(oracle.connect(node1).claimReward()).to.be.revertedWithCustomError(oracle, "NoRewardsAvailable");
|
||||
});
|
||||
|
||||
it("mints 1 ORA per report and reverts with no additional rewards", async function () {
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(node1).reportPrice(1600)).wait();
|
||||
const beforeBal = await oraToken.balanceOf(node1.address);
|
||||
await (await oracle.connect(node1).claimReward()).wait();
|
||||
const afterBal = await oraToken.balanceOf(node1.address);
|
||||
const REWARD_PER_REPORT = await oracle.REWARD_PER_REPORT();
|
||||
expect(afterBal - beforeBal).to.equal(REWARD_PER_REPORT);
|
||||
await expect(oracle.connect(node1).claimReward()).to.be.revertedWithCustomError(oracle, "NoRewardsAvailable");
|
||||
});
|
||||
|
||||
it("accumulates rewards across multiple buckets", async function () {
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(node1).reportPrice(1600)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(node1).reportPrice(1700)).wait();
|
||||
const beforeBal = await oraToken.balanceOf(node1.address);
|
||||
await (await oracle.connect(node1).claimReward()).wait();
|
||||
const afterBal = await oraToken.balanceOf(node1.address);
|
||||
const REWARD_PER_REPORT = await oracle.REWARD_PER_REPORT();
|
||||
expect(afterBal - beforeBal).to.equal(REWARD_PER_REPORT * 2n);
|
||||
});
|
||||
});
|
||||
describe("Prices by bucket", function () {
|
||||
beforeEach(async function () {
|
||||
const stake = await stakeForDelayedFirstReport();
|
||||
await fundApproveAndRegister(node1, stake);
|
||||
await fundApproveAndRegister(node2, stake);
|
||||
await moveToFreshBucket();
|
||||
});
|
||||
it("reverts getLatestPrice until a bucket median is recorded", async function () {
|
||||
await expect(oracle.getLatestPrice()).to.be.revertedWithCustomError(oracle, "MedianNotRecorded");
|
||||
});
|
||||
|
||||
it("returns median for previous bucket via getLatestPrice after recordBucketMedian", async function () {
|
||||
await mineBuckets(1);
|
||||
const bucketA = await oracle.getCurrentBucketNumber();
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1100)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(node6).recordBucketMedian(bucketA)).wait();
|
||||
const latest = await oracle.getLatestPrice();
|
||||
expect(latest).to.equal(1050n);
|
||||
});
|
||||
|
||||
it("getPastPrice returns stored median for a finalized bucket", async function () {
|
||||
await mineBuckets(1);
|
||||
const bucketA = await oracle.getCurrentBucketNumber();
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1100)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(node6).recordBucketMedian(bucketA)).wait();
|
||||
const pastMedian = await oracle.getPastPrice(bucketA);
|
||||
expect(pastMedian).to.equal(1050n);
|
||||
const [p1] = await oracle.getSlashedStatus(node1.address, bucketA);
|
||||
const [p2] = await oracle.getSlashedStatus(node2.address, bucketA);
|
||||
expect(p1).to.equal(1000n);
|
||||
expect(p2).to.equal(1100n);
|
||||
});
|
||||
|
||||
it("getPastPrice reverts for bucket without recorded median", async function () {
|
||||
await mineBuckets(1);
|
||||
const futureBucket = await oracle.getCurrentBucketNumber();
|
||||
await expect(oracle.getPastPrice(futureBucket)).to.be.revertedWithCustomError(oracle, "MedianNotRecorded");
|
||||
});
|
||||
});
|
||||
describe("Effective stake and addStake", function () {
|
||||
beforeEach(async function () {
|
||||
await moveToFreshBucket();
|
||||
const MINIMUM_STAKE = await oracle.MINIMUM_STAKE();
|
||||
await fundApproveAndRegister(node1, MINIMUM_STAKE + 10n);
|
||||
});
|
||||
it("penalizes missed buckets and floors at zero; addStake increases", async function () {
|
||||
const INACTIVITY_PENALTY = await oracle.INACTIVITY_PENALTY();
|
||||
await mineBuckets(2);
|
||||
const eff1 = await oracle.getEffectiveStake(node1.address);
|
||||
// With 2 buckets elapsed since registration and 0 reports, expectedReports=2 so penalty = 2*INACTIVITY_PENALTY.
|
||||
const staked = (await oracle.nodes(node1.address)).stakedAmount;
|
||||
expect(eff1).to.equal(staked - 2n * INACTIVITY_PENALTY);
|
||||
|
||||
const addAmount = 500n;
|
||||
await (await oraToken.connect(node1).approve(await oracleAddr(), addAmount)).wait();
|
||||
await (await oracle.connect(node1).addStake(addAmount)).wait();
|
||||
const eff2 = await oracle.getEffectiveStake(node1.address);
|
||||
expect(eff2).to.equal(staked + addAmount - 2n * INACTIVITY_PENALTY);
|
||||
});
|
||||
it("rejects zero value stake addition", async function () {
|
||||
await expect(oracle.connect(node1).addStake(0)).to.be.revertedWithCustomError(oracle, "InsufficientStake");
|
||||
});
|
||||
});
|
||||
describe("Slashing - deviation in past bucket", function () {
|
||||
beforeEach(async function () {
|
||||
// Ensure we have plenty of blocks left in the current bucket so setup txs + the first report
|
||||
// don't accidentally cross a bucket boundary and trigger an immediate inactivity penalty.
|
||||
await moveToFreshBucket();
|
||||
|
||||
const MINIMUM_STAKE = await oracle.MINIMUM_STAKE();
|
||||
const stake = await stakeForDelayedFirstReport();
|
||||
await fundApproveAndRegister(node1, stake);
|
||||
await fundApproveAndRegister(node2, stake);
|
||||
|
||||
// Keep node3 at exactly MINIMUM_STAKE so MISREPORT_PENALTY can fully slash to zero in removal-path tests.
|
||||
// To avoid inactivity penalties breaking future reports, have node3 report once immediately in its registration bucket.
|
||||
await fundApproveAndRegister(node3, MINIMUM_STAKE);
|
||||
await (await oracle.connect(node3).reportPrice(1000)).wait();
|
||||
});
|
||||
it("reverts for current bucket and for non-deviated prices", async function () {
|
||||
const current = await oracle.getCurrentBucketNumber();
|
||||
const node3AddressesIndex = await indexOfNodeAddress(node3.address);
|
||||
// reportIndex=0 is irrelevant here because current bucket check happens first
|
||||
await expect(
|
||||
oracle.connect(slasher).slashNode(node3.address, current, 0, node3AddressesIndex),
|
||||
).to.be.revertedWithCustomError(oracle, "OnlyPastBucketsAllowed");
|
||||
|
||||
await mineBuckets(1);
|
||||
const bucketB = await oracle.getCurrentBucketNumber();
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node3).reportPrice(1050)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(node4).recordBucketMedian(bucketB)).wait();
|
||||
const node3AddressesIndexB = await indexOfNodeAddress(node3.address);
|
||||
// node3 reported third in this bucket => reportIndex=2
|
||||
await expect(
|
||||
oracle.connect(slasher).slashNode(node3.address, bucketB, 2, node3AddressesIndexB),
|
||||
).to.be.revertedWithCustomError(oracle, "NotDeviated");
|
||||
});
|
||||
it("slashes deviated node, rewards slasher, and cannot slash again", async function () {
|
||||
const MINIMUM_STAKE = await oracle.MINIMUM_STAKE();
|
||||
const extra = MINIMUM_STAKE; // ensure stake remains after MISREPORT_PENALTY
|
||||
// fund node3 for the extra stake (it spent its entire balance staking MINIMUM_STAKE in beforeEach)
|
||||
await (await oraToken.connect(node1).transfer(node3.address, extra)).wait();
|
||||
await (await oraToken.connect(node3).approve(await oracleAddr(), extra)).wait();
|
||||
await (await oracle.connect(node3).addStake(extra)).wait();
|
||||
|
||||
await mineBuckets(1);
|
||||
const bucketB = await oracle.getCurrentBucketNumber();
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node3).reportPrice(1200)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(node4).recordBucketMedian(bucketB)).wait();
|
||||
|
||||
const node3AddressesIndex = await indexOfNodeAddress(node3.address);
|
||||
const slasherBalBefore = await oraToken.balanceOf(slasher.address);
|
||||
const tx = await oracle.connect(slasher).slashNode(node3.address, bucketB, 2, node3AddressesIndex);
|
||||
await tx.wait();
|
||||
|
||||
const SLASHER_REWARD_PERCENTAGE = await oracle.SLASHER_REWARD_PERCENTAGE();
|
||||
const MISREPORT_PENALTY = await oracle.MISREPORT_PENALTY();
|
||||
const expectedReward = (MISREPORT_PENALTY * SLASHER_REWARD_PERCENTAGE) / 100n;
|
||||
const slasherBalAfter = await oraToken.balanceOf(slasher.address);
|
||||
expect(slasherBalAfter - slasherBalBefore).to.equal(expectedReward);
|
||||
|
||||
await expect(
|
||||
oracle.connect(slasher).slashNode(node3.address, bucketB, 2, node3AddressesIndex),
|
||||
).to.be.revertedWithCustomError(oracle, "NodeAlreadySlashed");
|
||||
});
|
||||
it("slashes deviated node and removes when stake hits zero", async function () {
|
||||
await mineBuckets(1);
|
||||
const bucketB = await oracle.getCurrentBucketNumber();
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node3).reportPrice(1200)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(node4).recordBucketMedian(bucketB)).wait();
|
||||
|
||||
const node3AddressesIndex = await indexOfNodeAddress(node3.address);
|
||||
await (await oracle.connect(slasher).slashNode(node3.address, bucketB, 2, node3AddressesIndex)).wait();
|
||||
|
||||
const addresses = await oracle.getNodeAddresses();
|
||||
expect(addresses).to.not.include(node3.address);
|
||||
const infoAfter = await oracle.nodes(node3.address);
|
||||
expect(infoAfter.active).to.equal(false);
|
||||
});
|
||||
it("verifies slashed flag is set correctly after slashing", async function () {
|
||||
await mineBuckets(1);
|
||||
const bucketB = await oracle.getCurrentBucketNumber();
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node3).reportPrice(1200)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(node4).recordBucketMedian(bucketB)).wait();
|
||||
|
||||
const node3AddressesIndex = await indexOfNodeAddress(node3.address);
|
||||
await (await oracle.connect(slasher).slashNode(node3.address, bucketB, 2, node3AddressesIndex)).wait();
|
||||
const [price, slashedFlag] = await oracle.getSlashedStatus(node3.address, bucketB);
|
||||
expect(price).to.equal(1200n);
|
||||
expect(slashedFlag).to.equal(true);
|
||||
});
|
||||
it("reverts for exact 10% deviation threshold (should not slash)", async function () {
|
||||
// Median is 1000, so 10% deviation means 1100 or 900.
|
||||
// With MAX_DEVIATION_BPS = 1000 (10%), exactly 10% should NOT slash (strict >).
|
||||
// NOTE: Because bucket boundaries depend on block.number and tests mine blocks, it’s possible to
|
||||
// advance more than 1 bucket between registration and this first report (due to setup txs).
|
||||
// Keep this test deterministic by topping up node3 so it always remains >= MINIMUM_STAKE.
|
||||
const MINIMUM_STAKE = await oracle.MINIMUM_STAKE();
|
||||
await (await oraToken.connect(node1).transfer(node3.address, MINIMUM_STAKE)).wait();
|
||||
await (await oraToken.connect(node3).approve(await oracleAddr(), MINIMUM_STAKE)).wait();
|
||||
await (await oracle.connect(node3).addStake(MINIMUM_STAKE)).wait();
|
||||
|
||||
await mineBuckets(1);
|
||||
const bucketB = await oracle.getCurrentBucketNumber();
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node3).reportPrice(1100)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(node4).recordBucketMedian(bucketB)).wait();
|
||||
const node3AddressesIndex = await indexOfNodeAddress(node3.address);
|
||||
await expect(
|
||||
oracle.connect(slasher).slashNode(node3.address, bucketB, 2, node3AddressesIndex),
|
||||
).to.be.revertedWithCustomError(oracle, "NotDeviated");
|
||||
});
|
||||
it("reverts IndexOutOfBounds when index is out of range", async function () {
|
||||
// Trigger the removal path (stake -> 0) and provide an invalid nodeAddressesIndex.
|
||||
await mineBuckets(1);
|
||||
const bucketB = await oracle.getCurrentBucketNumber();
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node3).reportPrice(1200)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(node4).recordBucketMedian(bucketB)).wait();
|
||||
|
||||
const addresses = await oracle.getNodeAddresses();
|
||||
const invalidIndex = addresses.length; // Index out of bounds
|
||||
await expect(
|
||||
oracle.connect(slasher).slashNode(node3.address, bucketB, 2, invalidIndex),
|
||||
).to.be.revertedWithCustomError(oracle, "IndexOutOfBounds");
|
||||
});
|
||||
it("reverts NodeNotAtGivenIndex when index doesn't match address", async function () {
|
||||
await mineBuckets(1);
|
||||
const bucketB = await oracle.getCurrentBucketNumber();
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node3).reportPrice(1200)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(node4).recordBucketMedian(bucketB)).wait();
|
||||
|
||||
const node3AddressesIndex = await indexOfNodeAddress(node3.address);
|
||||
// Try to slash node3 but use node1's reportIndex (0)
|
||||
await expect(
|
||||
oracle.connect(slasher).slashNode(node3.address, bucketB, 0, node3AddressesIndex),
|
||||
).to.be.revertedWithCustomError(oracle, "NodeNotAtGivenIndex");
|
||||
});
|
||||
|
||||
it("reverts MedianNotRecorded if slashing is attempted before recordBucketMedian", async function () {
|
||||
await moveToFreshBucket();
|
||||
const bucketB = await oracle.getCurrentBucketNumber();
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node3).reportPrice(1200)).wait();
|
||||
await mineBuckets(1);
|
||||
const node3AddressesIndex = await indexOfNodeAddress(node3.address);
|
||||
await expect(
|
||||
oracle.connect(slasher).slashNode(node3.address, bucketB, 2, node3AddressesIndex),
|
||||
).to.be.revertedWithCustomError(oracle, "MedianNotRecorded");
|
||||
});
|
||||
});
|
||||
describe("exitNode", function () {
|
||||
beforeEach(async function () {
|
||||
const MINIMUM_STAKE = await oracle.MINIMUM_STAKE();
|
||||
await fundApproveAndRegister(node1, MINIMUM_STAKE);
|
||||
await fundApproveAndRegister(node2, MINIMUM_STAKE);
|
||||
});
|
||||
it("reverts before waiting period and exits with effective stake after", async function () {
|
||||
const idx = await indexOfNodeAddress(node1.address);
|
||||
// Ensure lastReportedBucket is set so the waiting period is measured from the last report.
|
||||
await (await oracle.connect(node1).reportPrice(1500)).wait();
|
||||
await expect(oracle.connect(node1).exitNode(idx)).to.be.revertedWithCustomError(oracle, "WaitingPeriodNotOver");
|
||||
const WAITING_PERIOD = Number(await oracle.WAITING_PERIOD());
|
||||
await mineBuckets(WAITING_PERIOD);
|
||||
const effectiveStake = await oracle.getEffectiveStake(node1.address);
|
||||
const balBefore = await oraToken.balanceOf(node1.address);
|
||||
const tx = await oracle.connect(node1).exitNode(idx);
|
||||
await tx.wait();
|
||||
const balAfter = await oraToken.balanceOf(node1.address);
|
||||
expect(balAfter - balBefore).to.equal(effectiveStake);
|
||||
// Verify node is removed
|
||||
const addresses = await oracle.getNodeAddresses();
|
||||
expect(addresses).to.not.include(node1.address);
|
||||
// Verify node is deleted (effectiveStake should be 0 for inactive nodes)
|
||||
expect(await oracle.getEffectiveStake(node1.address)).to.equal(0);
|
||||
});
|
||||
it("reverts IndexOutOfBounds when index is out of range", async function () {
|
||||
await mineBuckets(2);
|
||||
const addresses = await oracle.getNodeAddresses();
|
||||
const invalidIndex = addresses.length; // Index out of bounds
|
||||
await expect(oracle.connect(node1).exitNode(invalidIndex)).to.be.revertedWithCustomError(
|
||||
oracle,
|
||||
"IndexOutOfBounds",
|
||||
);
|
||||
});
|
||||
it("reverts NodeNotAtGivenIndex when index doesn't match address", async function () {
|
||||
await mineBuckets(2);
|
||||
const idx2 = await indexOfNodeAddress(node2.address);
|
||||
// Try to exit node1 but use node2's index
|
||||
await expect(oracle.connect(node1).exitNode(idx2)).to.be.revertedWithCustomError(oracle, "NodeNotAtGivenIndex");
|
||||
});
|
||||
});
|
||||
describe("getOutlierNodes", function () {
|
||||
beforeEach(async function () {
|
||||
const stake = await stakeForDelayedFirstReport();
|
||||
await fundApproveAndRegister(node1, stake);
|
||||
await fundApproveAndRegister(node2, stake);
|
||||
await fundApproveAndRegister(node3, stake);
|
||||
await fundApproveAndRegister(node4, stake);
|
||||
await fundApproveAndRegister(node5, stake);
|
||||
await fundApproveAndRegister(node6, stake);
|
||||
});
|
||||
it("returns empty array when no outliers exist", async function () {
|
||||
await moveToFreshBucket();
|
||||
const bucketB = await oracle.getCurrentBucketNumber();
|
||||
// All nodes report the same price in this bucket
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node3).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node4).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node5).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node6).reportPrice(1000)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(slasher).recordBucketMedian(bucketB)).wait();
|
||||
const outliers = await oracle.getOutlierNodes(bucketB);
|
||||
expect(outliers.length).to.equal(0);
|
||||
});
|
||||
it("returns deviated node addresses", async function () {
|
||||
await moveToFreshBucket();
|
||||
const bucketB = await oracle.getCurrentBucketNumber();
|
||||
// node4 reports 1200 while others report 1000 (median = 1000)
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node3).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node4).reportPrice(1200)).wait();
|
||||
await (await oracle.connect(node5).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node6).reportPrice(1000)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(slasher).recordBucketMedian(bucketB)).wait();
|
||||
const outliers = await oracle.getOutlierNodes(bucketB);
|
||||
expect(outliers.length).to.equal(1);
|
||||
expect(outliers[0]).to.equal(node4.address);
|
||||
});
|
||||
it("excludes nodes that did not report in the bucket", async function () {
|
||||
await moveToFreshBucket();
|
||||
const bucketB = await oracle.getCurrentBucketNumber();
|
||||
// Only 4 reporters (meets the 2/3 threshold for 6 nodes: requiredReports = 4)
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node4).reportPrice(1200)).wait();
|
||||
await (await oracle.connect(node5).reportPrice(1000)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(slasher).recordBucketMedian(bucketB)).wait();
|
||||
const outliers = await oracle.getOutlierNodes(bucketB);
|
||||
expect(outliers.length).to.equal(1);
|
||||
expect(outliers[0]).to.equal(node4.address);
|
||||
expect(outliers).to.not.include(node3.address);
|
||||
});
|
||||
it("handles multiple outliers correctly", async function () {
|
||||
await moveToFreshBucket();
|
||||
const bucketB = await oracle.getCurrentBucketNumber();
|
||||
await (await oracle.connect(node1).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node2).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node3).reportPrice(1000)).wait();
|
||||
await (await oracle.connect(node4).reportPrice(1400)).wait(); // outlier (>10% from median 1000)
|
||||
await (await oracle.connect(node5).reportPrice(1400)).wait(); // outlier
|
||||
await (await oracle.connect(node6).reportPrice(1000)).wait();
|
||||
await mineBuckets(1);
|
||||
await (await oracle.connect(slasher).recordBucketMedian(bucketB)).wait();
|
||||
const outliers = await oracle.getOutlierNodes(bucketB);
|
||||
expect(outliers.length).to.equal(2);
|
||||
expect(outliers).to.include(node4.address);
|
||||
expect(outliers).to.include(node5.address);
|
||||
});
|
||||
});
|
||||
});
|
||||
233
packages/hardhat/test/WhitelistOracle.ts
Normal file
233
packages/hardhat/test/WhitelistOracle.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { expect } from "chai";
|
||||
import { ethers } from "hardhat";
|
||||
import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
|
||||
import type { WhitelistOracle, SimpleOracle } from "../typechain-types";
|
||||
|
||||
describe("Checkpoint1", function () {
|
||||
before(async () => {
|
||||
await ethers.provider.send("evm_setAutomine", [true]);
|
||||
await ethers.provider.send("evm_setIntervalMining", [0]);
|
||||
});
|
||||
|
||||
let whitelistOracle: WhitelistOracle;
|
||||
let owner: HardhatEthersSigner,
|
||||
addr1: HardhatEthersSigner,
|
||||
addr2: HardhatEthersSigner,
|
||||
addr3: HardhatEthersSigner,
|
||||
addr4: HardhatEthersSigner;
|
||||
|
||||
const contractAddress = process.env.CONTRACT_ADDRESS;
|
||||
|
||||
if (contractAddress) {
|
||||
// If env variable is set then skip this test file (for the auto-grader)
|
||||
return true;
|
||||
}
|
||||
|
||||
beforeEach(async function () {
|
||||
[owner, addr1, addr2, addr3, addr4] = await ethers.getSigners();
|
||||
const WhitelistOracleFactory = await ethers.getContractFactory("WhitelistOracle");
|
||||
whitelistOracle = await WhitelistOracleFactory.deploy();
|
||||
});
|
||||
|
||||
it("Should deploy and set owner", async function () {
|
||||
expect(await whitelistOracle.owner()).to.equal(owner.address);
|
||||
});
|
||||
|
||||
it("Should allow adding oracles and deploy SimpleOracle contracts", async function () {
|
||||
await whitelistOracle.addOracle(addr1.address);
|
||||
|
||||
const oracleAddress = await whitelistOracle.oracles(0);
|
||||
expect(oracleAddress).to.not.equal(ethers.ZeroAddress);
|
||||
|
||||
// Check that the oracle is a SimpleOracle contract
|
||||
const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle");
|
||||
const oracle = SimpleOracleFactory.attach(oracleAddress) as SimpleOracle;
|
||||
expect(await oracle.owner()).to.equal(addr1.address);
|
||||
});
|
||||
|
||||
it("Should allow removing oracles by index", async function () {
|
||||
await whitelistOracle.addOracle(addr1.address);
|
||||
await whitelistOracle.addOracle(addr2.address);
|
||||
|
||||
const oracle1Address = await whitelistOracle.oracles(0);
|
||||
|
||||
await whitelistOracle.removeOracle(0);
|
||||
|
||||
// After removal, the oracle at index 0 should be different (swapped from end)
|
||||
const newOracle0Address = await whitelistOracle.oracles(0);
|
||||
expect(newOracle0Address).to.not.equal(oracle1Address);
|
||||
|
||||
// Should only have one oracle left
|
||||
await expect(whitelistOracle.oracles(1)).to.be.reverted;
|
||||
});
|
||||
|
||||
it("Should emit OracleAdded event when an oracle is added", async function () {
|
||||
const tx = await whitelistOracle.addOracle(addr1.address);
|
||||
await tx.wait();
|
||||
const oracleAddress = await whitelistOracle.oracles(0);
|
||||
|
||||
expect(tx).to.emit(whitelistOracle, "OracleAdded").withArgs(oracleAddress, addr1.address);
|
||||
});
|
||||
|
||||
it("Should emit OracleRemoved event when an oracle is removed", async function () {
|
||||
await whitelistOracle.addOracle(addr1.address);
|
||||
const oracleAddress = await whitelistOracle.oracles(0);
|
||||
|
||||
await expect(whitelistOracle.removeOracle(0)).to.emit(whitelistOracle, "OracleRemoved").withArgs(oracleAddress);
|
||||
});
|
||||
|
||||
it("Should revert with IndexOutOfBounds when trying to remove non-existent oracle", async function () {
|
||||
await expect(whitelistOracle.removeOracle(0)).to.be.revertedWithCustomError(whitelistOracle, "IndexOutOfBounds");
|
||||
|
||||
await whitelistOracle.addOracle(addr1.address);
|
||||
await expect(whitelistOracle.removeOracle(1)).to.be.revertedWithCustomError(whitelistOracle, "IndexOutOfBounds");
|
||||
|
||||
await whitelistOracle.removeOracle(0);
|
||||
await expect(whitelistOracle.removeOracle(0)).to.be.revertedWithCustomError(whitelistOracle, "IndexOutOfBounds");
|
||||
});
|
||||
|
||||
it("Should revert with NoOraclesAvailable when getPrice is called with no oracles", async function () {
|
||||
await expect(whitelistOracle.getPrice()).to.be.revertedWithCustomError(whitelistOracle, "NoOraclesAvailable");
|
||||
});
|
||||
|
||||
it("Should return correct price with one oracle", async function () {
|
||||
await whitelistOracle.addOracle(addr1.address);
|
||||
const oracleAddress = await whitelistOracle.oracles(0);
|
||||
|
||||
const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle");
|
||||
const oracle = SimpleOracleFactory.attach(oracleAddress) as SimpleOracle;
|
||||
|
||||
await oracle.setPrice(1000n);
|
||||
|
||||
const price = await whitelistOracle.getPrice();
|
||||
expect(price).to.equal(1000n);
|
||||
});
|
||||
|
||||
it("Should return correct median price with odd number of oracles", async function () {
|
||||
await whitelistOracle.addOracle(addr1.address);
|
||||
await whitelistOracle.addOracle(addr2.address);
|
||||
await whitelistOracle.addOracle(addr3.address);
|
||||
|
||||
const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle");
|
||||
const oracle1 = SimpleOracleFactory.attach(await whitelistOracle.oracles(0)) as SimpleOracle;
|
||||
const oracle2 = SimpleOracleFactory.attach(await whitelistOracle.oracles(1)) as SimpleOracle;
|
||||
const oracle3 = SimpleOracleFactory.attach(await whitelistOracle.oracles(2)) as SimpleOracle;
|
||||
|
||||
await oracle1.setPrice(1000n);
|
||||
await oracle2.setPrice(3000n);
|
||||
await oracle3.setPrice(2000n);
|
||||
|
||||
const medianPrice = await whitelistOracle.getPrice();
|
||||
expect(medianPrice).to.equal(2000n);
|
||||
});
|
||||
|
||||
it("Should return correct median price with even number of oracles", async function () {
|
||||
await whitelistOracle.addOracle(addr1.address);
|
||||
await whitelistOracle.addOracle(addr2.address);
|
||||
await whitelistOracle.addOracle(addr3.address);
|
||||
await whitelistOracle.addOracle(addr4.address);
|
||||
|
||||
const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle");
|
||||
const oracle1 = SimpleOracleFactory.attach(await whitelistOracle.oracles(0)) as SimpleOracle;
|
||||
const oracle2 = SimpleOracleFactory.attach(await whitelistOracle.oracles(1)) as SimpleOracle;
|
||||
const oracle3 = SimpleOracleFactory.attach(await whitelistOracle.oracles(2)) as SimpleOracle;
|
||||
const oracle4 = SimpleOracleFactory.attach(await whitelistOracle.oracles(3)) as SimpleOracle;
|
||||
|
||||
await oracle1.setPrice(1000n);
|
||||
await oracle2.setPrice(3000n);
|
||||
await oracle3.setPrice(2000n);
|
||||
await oracle4.setPrice(4000n);
|
||||
|
||||
const medianPrice = await whitelistOracle.getPrice();
|
||||
expect(medianPrice).to.equal(2500n);
|
||||
});
|
||||
|
||||
it("Should exclude price reports older than 24 seconds from median calculation", async function () {
|
||||
await whitelistOracle.addOracle(addr1.address);
|
||||
await whitelistOracle.addOracle(addr2.address);
|
||||
await whitelistOracle.addOracle(addr3.address);
|
||||
|
||||
const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle");
|
||||
const oracle1 = SimpleOracleFactory.attach(await whitelistOracle.oracles(0)) as SimpleOracle;
|
||||
const oracle2 = SimpleOracleFactory.attach(await whitelistOracle.oracles(1)) as SimpleOracle;
|
||||
const oracle3 = SimpleOracleFactory.attach(await whitelistOracle.oracles(2)) as SimpleOracle;
|
||||
|
||||
await oracle1.setPrice(1000n);
|
||||
await oracle2.setPrice(2000n);
|
||||
await oracle3.setPrice(3000n);
|
||||
|
||||
let medianPrice = await whitelistOracle.getPrice();
|
||||
expect(medianPrice).to.equal(2000n);
|
||||
|
||||
// Advance time by 25 seconds (more than STALE_DATA_WINDOW of 24 seconds)
|
||||
await ethers.provider.send("evm_increaseTime", [25]);
|
||||
await ethers.provider.send("evm_mine");
|
||||
|
||||
// Set new prices for only two oracles (the old prices should be stale)
|
||||
await oracle1.setPrice(5000n);
|
||||
await oracle2.setPrice(3000n);
|
||||
|
||||
// Should only use the two fresh prices: median of [5000, 3000] = 4000
|
||||
medianPrice = await whitelistOracle.getPrice();
|
||||
expect(medianPrice).to.equal(4000n);
|
||||
});
|
||||
|
||||
it("Should return empty array when no oracles are active", async function () {
|
||||
const activeNodes = await whitelistOracle.getActiveOracleNodes();
|
||||
expect(activeNodes.length).to.equal(0);
|
||||
});
|
||||
|
||||
it("Should return correct active oracle nodes", async function () {
|
||||
await whitelistOracle.addOracle(addr1.address);
|
||||
await whitelistOracle.addOracle(addr2.address);
|
||||
|
||||
const oracle1Address = await whitelistOracle.oracles(0);
|
||||
const oracle2Address = await whitelistOracle.oracles(1);
|
||||
|
||||
const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle");
|
||||
const oracle1 = SimpleOracleFactory.attach(oracle1Address) as SimpleOracle;
|
||||
const oracle2 = SimpleOracleFactory.attach(oracle2Address) as SimpleOracle;
|
||||
|
||||
await oracle1.setPrice(1000n);
|
||||
await oracle2.setPrice(2000n);
|
||||
|
||||
let activeNodes = await whitelistOracle.getActiveOracleNodes();
|
||||
expect(activeNodes.length).to.equal(2);
|
||||
expect(activeNodes).to.include(oracle1Address);
|
||||
expect(activeNodes).to.include(oracle2Address);
|
||||
|
||||
// Make oracle1's price stale
|
||||
await ethers.provider.send("evm_increaseTime", [25]);
|
||||
await ethers.provider.send("evm_mine");
|
||||
|
||||
// Update only oracle2
|
||||
await oracle2.setPrice(3000n);
|
||||
|
||||
activeNodes = await whitelistOracle.getActiveOracleNodes();
|
||||
expect(activeNodes.length).to.equal(1);
|
||||
expect(activeNodes[0]).to.equal(oracle2Address);
|
||||
});
|
||||
|
||||
it("Should handle edge case when all prices are stale but array is not empty", async function () {
|
||||
await whitelistOracle.addOracle(addr1.address);
|
||||
await whitelistOracle.addOracle(addr2.address);
|
||||
|
||||
const SimpleOracleFactory = await ethers.getContractFactory("SimpleOracle");
|
||||
const oracle1 = SimpleOracleFactory.attach(await whitelistOracle.oracles(0)) as SimpleOracle;
|
||||
const oracle2 = SimpleOracleFactory.attach(await whitelistOracle.oracles(1)) as SimpleOracle;
|
||||
|
||||
await oracle1.setPrice(1000n);
|
||||
await oracle2.setPrice(2000n);
|
||||
|
||||
// Verify median works initially
|
||||
const medianPrice = await whitelistOracle.getPrice();
|
||||
expect(medianPrice).to.equal(1500n);
|
||||
|
||||
// Make all prices stale
|
||||
await ethers.provider.send("evm_increaseTime", [25]);
|
||||
await ethers.provider.send("evm_mine");
|
||||
|
||||
const activeNodes = await whitelistOracle.getActiveOracleNodes();
|
||||
expect(activeNodes.length).to.equal(0);
|
||||
});
|
||||
});
|
||||
11
packages/hardhat/tsconfig.json
Normal file
11
packages/hardhat/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
14
packages/nextjs/.env.example
Normal file
14
packages/nextjs/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Template for NextJS environment variables.
|
||||
|
||||
# For local development, copy this file, rename it to .env.local, and fill in the values.
|
||||
# When deploying live, you'll need to store the vars in Vercel/System config.
|
||||
|
||||
# If not set, we provide default values (check `scaffold.config.ts`) so developers can start prototyping out of the box,
|
||||
# but we recommend getting your own API Keys for Production Apps.
|
||||
|
||||
# To access the values stored in this env file you can use: process.env.VARIABLENAME
|
||||
# You'll need to prefix the variables names with NEXT_PUBLIC_ if you want to access them on the client side.
|
||||
# More info: https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables
|
||||
NEXT_PUBLIC_ALCHEMY_API_KEY=
|
||||
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=
|
||||
|
||||
38
packages/nextjs/.gitignore
vendored
Normal file
38
packages/nextjs/.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
.vercel
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
ipfs-upload.config.json
|
||||
9
packages/nextjs/.prettierrc.js
Normal file
9
packages/nextjs/.prettierrc.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
arrowParens: "avoid",
|
||||
printWidth: 120,
|
||||
tabWidth: 2,
|
||||
trailingComma: "all",
|
||||
importOrder: ["^react$", "^next/(.*)$", "<THIRD_PARTY_MODULES>", "^@heroicons/(.*)$", "^~~/(.*)$"],
|
||||
importOrderSortSpecifiers: true,
|
||||
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
|
||||
};
|
||||
56
packages/nextjs/app/api/config/price-variance/route.ts
Normal file
56
packages/nextjs/app/api/config/price-variance/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
56
packages/nextjs/app/api/config/skip-probability/route.ts
Normal file
56
packages/nextjs/app/api/config/skip-probability/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
88
packages/nextjs/app/api/ora-faucet/route.ts
Normal file
88
packages/nextjs/app/api/ora-faucet/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createPublicClient, createWalletClient, http, parseEther } from "viem";
|
||||
import { privateKeyToAccount } from "viem/accounts";
|
||||
import { hardhat } from "viem/chains";
|
||||
import deployedContracts from "~~/contracts/deployedContracts";
|
||||
|
||||
const oraTokenAbi = [
|
||||
{
|
||||
type: "function",
|
||||
name: "transfer",
|
||||
stateMutability: "nonpayable",
|
||||
inputs: [
|
||||
{ name: "to", type: "address" },
|
||||
{ name: "amount", type: "uint256" },
|
||||
],
|
||||
outputs: [{ name: "", type: "bool" }],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const stakingOracleAbi = [
|
||||
{
|
||||
type: "function",
|
||||
name: "oracleToken",
|
||||
stateMutability: "view",
|
||||
inputs: [],
|
||||
outputs: [{ name: "", type: "address" }],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const DEPLOYER_PRIVATE_KEY =
|
||||
(process.env.__RUNTIME_DEPLOYER_PRIVATE_KEY as `0x${string}` | undefined) ??
|
||||
// Hardhat default account #0 private key (localhost only).
|
||||
("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const);
|
||||
|
||||
function isAddress(value: unknown): value is `0x${string}` {
|
||||
return typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const to = body?.to;
|
||||
const amount = body?.amount ?? "2000";
|
||||
|
||||
if (!isAddress(to)) {
|
||||
return NextResponse.json({ error: "Invalid `to` address" }, { status: 400 });
|
||||
}
|
||||
if (typeof amount !== "string" || !/^\d+(\.\d+)?$/.test(amount)) {
|
||||
return NextResponse.json({ error: "Invalid `amount`" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Safety: this faucet is intended for local Hardhat usage only.
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return NextResponse.json({ error: "ORA faucet disabled in production" }, { status: 403 });
|
||||
}
|
||||
|
||||
const publicClient = createPublicClient({ chain: hardhat, transport: http() });
|
||||
const account = privateKeyToAccount(DEPLOYER_PRIVATE_KEY);
|
||||
const walletClient = createWalletClient({ chain: hardhat, transport: http(), account });
|
||||
|
||||
const stakingOracleAddress = (deployedContracts as any)?.[hardhat.id]?.StakingOracle?.address as
|
||||
| `0x${string}`
|
||||
| undefined;
|
||||
if (!stakingOracleAddress) {
|
||||
return NextResponse.json({ error: "StakingOracle not deployed on this network" }, { status: 500 });
|
||||
}
|
||||
|
||||
const oraTokenAddress = (await publicClient.readContract({
|
||||
address: stakingOracleAddress,
|
||||
abi: stakingOracleAbi,
|
||||
functionName: "oracleToken",
|
||||
})) as `0x${string}`;
|
||||
|
||||
const hash = await walletClient.writeContract({
|
||||
address: oraTokenAddress,
|
||||
abi: oraTokenAbi,
|
||||
functionName: "transfer",
|
||||
args: [to, parseEther(amount)],
|
||||
});
|
||||
|
||||
await publicClient.waitForTransactionReceipt({ hash });
|
||||
|
||||
return NextResponse.json({ success: true, hash });
|
||||
} catch (error) {
|
||||
console.error("Error funding ORA:", error);
|
||||
return NextResponse.json({ error: "Failed to fund ORA" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
type AddressCodeTabProps = {
|
||||
bytecode: string;
|
||||
assembly: string;
|
||||
};
|
||||
|
||||
export const AddressCodeTab = ({ bytecode, assembly }: AddressCodeTabProps) => {
|
||||
const formattedAssembly = Array.from(assembly.matchAll(/\w+( 0x[a-fA-F0-9]+)?/g))
|
||||
.map(it => it[0])
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
Bytecode
|
||||
<div className="mockup-code -indent-5 overflow-y-auto max-h-[500px]">
|
||||
<pre className="px-5">
|
||||
<code className="whitespace-pre-wrap overflow-auto break-words">{bytecode}</code>
|
||||
</pre>
|
||||
</div>
|
||||
Opcodes
|
||||
<div className="mockup-code -indent-5 overflow-y-auto max-h-[500px]">
|
||||
<pre className="px-5">
|
||||
<code>{formattedAssembly}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { BackButton } from "./BackButton";
|
||||
import { ContractTabs } from "./ContractTabs";
|
||||
import { Address, Balance } from "@scaffold-ui/components";
|
||||
import { Address as AddressType } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth";
|
||||
|
||||
export const AddressComponent = ({
|
||||
address,
|
||||
contractData,
|
||||
}: {
|
||||
address: AddressType;
|
||||
contractData: { bytecode: string; assembly: string } | null;
|
||||
}) => {
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
return (
|
||||
<div className="m-10 mb-20">
|
||||
<div className="flex justify-start mb-5">
|
||||
<BackButton />
|
||||
</div>
|
||||
<div className="col-span-5 grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-10">
|
||||
<div className="col-span-1 flex flex-col">
|
||||
<div className="bg-base-100 border-base-300 border shadow-md shadow-secondary rounded-3xl px-6 lg:px-8 mb-6 space-y-1 py-4 overflow-x-auto">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Address
|
||||
address={address}
|
||||
format="long"
|
||||
onlyEnsOrAddress
|
||||
blockExplorerAddressLink={
|
||||
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${address}` : undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="font-bold text-sm">Balance:</span>
|
||||
<Balance address={address} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ContractTabs address={address} contractData={contractData} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Address } from "viem";
|
||||
import { useContractLogs } from "~~/hooks/scaffold-eth";
|
||||
import { replacer } from "~~/utils/scaffold-eth/common";
|
||||
|
||||
export const AddressLogsTab = ({ address }: { address: Address }) => {
|
||||
const contractLogs = useContractLogs(address);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="mockup-code overflow-auto max-h-[500px]">
|
||||
<pre className="px-5 whitespace-pre-wrap break-words">
|
||||
{contractLogs.map((log, i) => (
|
||||
<div key={i}>
|
||||
<strong>Log:</strong> {JSON.stringify(log, replacer, 2)}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Address, createPublicClient, http, toHex } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
|
||||
const publicClient = createPublicClient({
|
||||
chain: hardhat,
|
||||
transport: http(),
|
||||
});
|
||||
|
||||
export const AddressStorageTab = ({ address }: { address: Address }) => {
|
||||
const [storage, setStorage] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStorage = async () => {
|
||||
try {
|
||||
const storageData = [];
|
||||
let idx = 0;
|
||||
|
||||
while (true) {
|
||||
const storageAtPosition = await publicClient.getStorageAt({
|
||||
address: address,
|
||||
slot: toHex(idx),
|
||||
});
|
||||
|
||||
if (storageAtPosition === "0x" + "0".repeat(64)) break;
|
||||
|
||||
if (storageAtPosition) {
|
||||
storageData.push(storageAtPosition);
|
||||
}
|
||||
|
||||
idx++;
|
||||
}
|
||||
setStorage(storageData);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch storage:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStorage();
|
||||
}, [address]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{storage.length > 0 ? (
|
||||
<div className="mockup-code overflow-auto max-h-[500px]">
|
||||
<pre className="px-5 whitespace-pre-wrap break-words">
|
||||
{storage.map((data, i) => (
|
||||
<div key={i}>
|
||||
<strong>Storage Slot {i}:</strong> {data}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-lg">This contract does not have any variables.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
12
packages/nextjs/app/blockexplorer/_components/BackButton.tsx
Normal file
12
packages/nextjs/app/blockexplorer/_components/BackButton.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export const BackButton = () => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<button className="btn btn-sm btn-primary" onClick={() => router.back()}>
|
||||
Back
|
||||
</button>
|
||||
);
|
||||
};
|
||||
102
packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx
Normal file
102
packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AddressCodeTab } from "./AddressCodeTab";
|
||||
import { AddressLogsTab } from "./AddressLogsTab";
|
||||
import { AddressStorageTab } from "./AddressStorageTab";
|
||||
import { PaginationButton } from "./PaginationButton";
|
||||
import { TransactionsTable } from "./TransactionsTable";
|
||||
import { Address, createPublicClient, http } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useFetchBlocks } from "~~/hooks/scaffold-eth";
|
||||
|
||||
type AddressCodeTabProps = {
|
||||
bytecode: string;
|
||||
assembly: string;
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
address: Address;
|
||||
contractData: AddressCodeTabProps | null;
|
||||
};
|
||||
|
||||
const publicClient = createPublicClient({
|
||||
chain: hardhat,
|
||||
transport: http(),
|
||||
});
|
||||
|
||||
export const ContractTabs = ({ address, contractData }: PageProps) => {
|
||||
const { blocks, transactionReceipts, currentPage, totalBlocks, setCurrentPage } = useFetchBlocks();
|
||||
const [activeTab, setActiveTab] = useState("transactions");
|
||||
const [isContract, setIsContract] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkIsContract = async () => {
|
||||
const contractCode = await publicClient.getBytecode({ address: address });
|
||||
setIsContract(contractCode !== undefined && contractCode !== "0x");
|
||||
};
|
||||
|
||||
checkIsContract();
|
||||
}, [address]);
|
||||
|
||||
const filteredBlocks = blocks.filter(block =>
|
||||
block.transactions.some(tx => {
|
||||
if (typeof tx === "string") {
|
||||
return false;
|
||||
}
|
||||
return tx.from.toLowerCase() === address.toLowerCase() || tx.to?.toLowerCase() === address.toLowerCase();
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isContract && (
|
||||
<div role="tablist" className="tabs tabs-lift">
|
||||
<button
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "transactions" ? "tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("transactions")}
|
||||
>
|
||||
Transactions
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "code" ? "tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("code")}
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "storage" ? "tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("storage")}
|
||||
>
|
||||
Storage
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "logs" ? "tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("logs")}
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "transactions" && (
|
||||
<div className="pt-4">
|
||||
<TransactionsTable blocks={filteredBlocks} transactionReceipts={transactionReceipts} />
|
||||
<PaginationButton
|
||||
currentPage={currentPage}
|
||||
totalItems={Number(totalBlocks)}
|
||||
setCurrentPage={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "code" && contractData && (
|
||||
<AddressCodeTab bytecode={contractData.bytecode} assembly={contractData.assembly} />
|
||||
)}
|
||||
{activeTab === "storage" && <AddressStorageTab address={address} />}
|
||||
{activeTab === "logs" && <AddressLogsTab address={address} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type PaginationButtonProps = {
|
||||
currentPage: number;
|
||||
totalItems: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
};
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export const PaginationButton = ({ currentPage, totalItems, setCurrentPage }: PaginationButtonProps) => {
|
||||
const isPrevButtonDisabled = currentPage === 0;
|
||||
const isNextButtonDisabled = currentPage + 1 >= Math.ceil(totalItems / ITEMS_PER_PAGE);
|
||||
|
||||
const prevButtonClass = isPrevButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
|
||||
const nextButtonClass = isNextButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
|
||||
|
||||
if (isNextButtonDisabled && isPrevButtonDisabled) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-5 justify-end flex gap-3 mx-5">
|
||||
<button
|
||||
className={`btn btn-sm ${prevButtonClass}`}
|
||||
disabled={isPrevButtonDisabled}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="self-center text-primary-content font-medium">Page {currentPage + 1}</span>
|
||||
<button
|
||||
className={`btn btn-sm ${nextButtonClass}`}
|
||||
disabled={isNextButtonDisabled}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
49
packages/nextjs/app/blockexplorer/_components/SearchBar.tsx
Normal file
49
packages/nextjs/app/blockexplorer/_components/SearchBar.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isAddress, isHex } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { usePublicClient } from "wagmi";
|
||||
|
||||
export const SearchBar = () => {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const client = usePublicClient({ chainId: hardhat.id });
|
||||
|
||||
const handleSearch = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (isHex(searchInput)) {
|
||||
try {
|
||||
const tx = await client?.getTransaction({ hash: searchInput });
|
||||
if (tx) {
|
||||
router.push(`/blockexplorer/transaction/${searchInput}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch transaction:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAddress(searchInput)) {
|
||||
router.push(`/blockexplorer/address/${searchInput}`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSearch} className="flex items-center justify-end mb-5 space-x-3 mx-5">
|
||||
<input
|
||||
className="border-primary bg-base-100 text-base-content placeholder:text-base-content/50 p-2 mr-2 w-full md:w-1/2 lg:w-1/3 rounded-md shadow-md focus:outline-hidden focus:ring-2 focus:ring-accent"
|
||||
type="text"
|
||||
value={searchInput}
|
||||
placeholder="Search by hash or address"
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-sm btn-primary" type="submit">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
import { CheckCircleIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
|
||||
import { useCopyToClipboard } from "~~/hooks/scaffold-eth/useCopyToClipboard";
|
||||
|
||||
export const TransactionHash = ({ hash }: { hash: string }) => {
|
||||
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
|
||||
useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Link href={`/blockexplorer/transaction/${hash}`}>
|
||||
{hash?.substring(0, 6)}...{hash?.substring(hash.length - 4)}
|
||||
</Link>
|
||||
{isAddressCopiedToClipboard ? (
|
||||
<CheckCircleIcon
|
||||
className="ml-1.5 text-xl font-normal text-base-content h-5 w-5 cursor-pointer"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<DocumentDuplicateIcon
|
||||
className="ml-1.5 text-xl font-normal h-5 w-5 cursor-pointer"
|
||||
aria-hidden="true"
|
||||
onClick={() => copyAddressToClipboard(hash)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { TransactionHash } from "./TransactionHash";
|
||||
import { Address } from "@scaffold-ui/components";
|
||||
import { formatEther } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { TransactionWithFunction } from "~~/utils/scaffold-eth";
|
||||
import { TransactionsTableProps } from "~~/utils/scaffold-eth/";
|
||||
|
||||
export const TransactionsTable = ({ blocks, transactionReceipts }: TransactionsTableProps) => {
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4 md:px-0">
|
||||
<div className="overflow-x-auto w-full shadow-2xl rounded-xl">
|
||||
<table className="table text-xl bg-base-100 table-zebra w-full md:table-md table-sm">
|
||||
<thead>
|
||||
<tr className="rounded-xl text-sm text-base-content">
|
||||
<th className="bg-primary">Transaction Hash</th>
|
||||
<th className="bg-primary">Function Called</th>
|
||||
<th className="bg-primary">Block Number</th>
|
||||
<th className="bg-primary">Time Mined</th>
|
||||
<th className="bg-primary">From</th>
|
||||
<th className="bg-primary">To</th>
|
||||
<th className="bg-primary text-end">Value ({targetNetwork.nativeCurrency.symbol})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{blocks.map(block =>
|
||||
(block.transactions as TransactionWithFunction[]).map(tx => {
|
||||
const receipt = transactionReceipts[tx.hash];
|
||||
const timeMined = new Date(Number(block.timestamp) * 1000).toLocaleString();
|
||||
const functionCalled = tx.input.substring(0, 10);
|
||||
|
||||
return (
|
||||
<tr key={tx.hash} className="hover text-sm">
|
||||
<td className="w-1/12 md:py-4">
|
||||
<TransactionHash hash={tx.hash} />
|
||||
</td>
|
||||
<td className="w-2/12 md:py-4">
|
||||
{tx.functionName === "0x" ? "" : <span className="mr-1">{tx.functionName}</span>}
|
||||
{functionCalled !== "0x" && (
|
||||
<span className="badge badge-primary font-bold text-xs">{functionCalled}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="w-1/12 md:py-4">{block.number?.toString()}</td>
|
||||
<td className="w-2/12 md:py-4">{timeMined}</td>
|
||||
<td className="w-2/12 md:py-4">
|
||||
<Address
|
||||
address={tx.from}
|
||||
size="sm"
|
||||
onlyEnsOrAddress
|
||||
blockExplorerAddressLink={
|
||||
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${tx.from}` : undefined
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="w-2/12 md:py-4">
|
||||
{!receipt?.contractAddress ? (
|
||||
tx.to && (
|
||||
<Address
|
||||
address={tx.to}
|
||||
size="sm"
|
||||
onlyEnsOrAddress
|
||||
blockExplorerAddressLink={
|
||||
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${tx.to}` : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Address
|
||||
address={receipt.contractAddress}
|
||||
size="sm"
|
||||
onlyEnsOrAddress
|
||||
blockExplorerAddressLink={
|
||||
targetNetwork.id === hardhat.id
|
||||
? `/blockexplorer/address/${receipt.contractAddress}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<small className="absolute top-4 left-4">(Contract Creation)</small>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right md:py-4">
|
||||
{formatEther(tx.value)} {targetNetwork.nativeCurrency.symbol}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
packages/nextjs/app/blockexplorer/_components/index.tsx
Normal file
7
packages/nextjs/app/blockexplorer/_components/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./SearchBar";
|
||||
export * from "./BackButton";
|
||||
export * from "./AddressCodeTab";
|
||||
export * from "./TransactionHash";
|
||||
export * from "./ContractTabs";
|
||||
export * from "./PaginationButton";
|
||||
export * from "./TransactionsTable";
|
||||
101
packages/nextjs/app/blockexplorer/address/[address]/page.tsx
Normal file
101
packages/nextjs/app/blockexplorer/address/[address]/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { Address } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { AddressComponent } from "~~/app/blockexplorer/_components/AddressComponent";
|
||||
import deployedContracts from "~~/contracts/deployedContracts";
|
||||
import { isZeroAddress } from "~~/utils/scaffold-eth/common";
|
||||
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ address: Address }>;
|
||||
};
|
||||
|
||||
async function fetchByteCodeAndAssembly(buildInfoDirectory: string, contractPath: string) {
|
||||
const buildInfoFiles = fs.readdirSync(buildInfoDirectory);
|
||||
let bytecode = "";
|
||||
let assembly = "";
|
||||
|
||||
for (let i = 0; i < buildInfoFiles.length; i++) {
|
||||
const filePath = path.join(buildInfoDirectory, buildInfoFiles[i]);
|
||||
|
||||
const buildInfo = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
|
||||
if (buildInfo.output.contracts[contractPath]) {
|
||||
for (const contract in buildInfo.output.contracts[contractPath]) {
|
||||
bytecode = buildInfo.output.contracts[contractPath][contract].evm.bytecode.object;
|
||||
assembly = buildInfo.output.contracts[contractPath][contract].evm.bytecode.opcodes;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bytecode && assembly) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { bytecode, assembly };
|
||||
}
|
||||
|
||||
const getContractData = async (address: Address) => {
|
||||
const contracts = deployedContracts as GenericContractsDeclaration | null;
|
||||
const chainId = hardhat.id;
|
||||
|
||||
if (!contracts || !contracts[chainId] || Object.keys(contracts[chainId]).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let contractPath = "";
|
||||
|
||||
const buildInfoDirectory = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"hardhat",
|
||||
"artifacts",
|
||||
"build-info",
|
||||
);
|
||||
|
||||
if (!fs.existsSync(buildInfoDirectory)) {
|
||||
throw new Error(`Directory ${buildInfoDirectory} not found.`);
|
||||
}
|
||||
|
||||
const deployedContractsOnChain = contracts[chainId];
|
||||
for (const [contractName, contractInfo] of Object.entries(deployedContractsOnChain)) {
|
||||
if (contractInfo.address.toLowerCase() === address.toLowerCase()) {
|
||||
contractPath = `contracts/${contractName}.sol`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!contractPath) {
|
||||
// No contract found at this address
|
||||
return null;
|
||||
}
|
||||
|
||||
const { bytecode, assembly } = await fetchByteCodeAndAssembly(buildInfoDirectory, contractPath);
|
||||
|
||||
return { bytecode, assembly };
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
// An workaround to enable static exports in Next.js, generating single dummy page.
|
||||
return [{ address: "0x0000000000000000000000000000000000000000" }];
|
||||
}
|
||||
|
||||
const AddressPage = async (props: PageProps) => {
|
||||
const params = await props.params;
|
||||
const address = params?.address as Address;
|
||||
|
||||
if (isZeroAddress(address)) return null;
|
||||
|
||||
const contractData: { bytecode: string; assembly: string } | null = await getContractData(address);
|
||||
return <AddressComponent address={address} contractData={contractData} />;
|
||||
};
|
||||
|
||||
export default AddressPage;
|
||||
12
packages/nextjs/app/blockexplorer/layout.tsx
Normal file
12
packages/nextjs/app/blockexplorer/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
|
||||
|
||||
export const metadata = getMetadata({
|
||||
title: "Block Explorer",
|
||||
description: "Block Explorer created with 🏗 Scaffold-ETH 2",
|
||||
});
|
||||
|
||||
const BlockExplorerLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default BlockExplorerLayout;
|
||||
83
packages/nextjs/app/blockexplorer/page.tsx
Normal file
83
packages/nextjs/app/blockexplorer/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { PaginationButton, SearchBar, TransactionsTable } from "./_components";
|
||||
import type { NextPage } from "next";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useFetchBlocks } from "~~/hooks/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
const BlockExplorer: NextPage = () => {
|
||||
const { blocks, transactionReceipts, currentPage, totalBlocks, setCurrentPage, error } = useFetchBlocks();
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const [isLocalNetwork, setIsLocalNetwork] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetNetwork.id !== hardhat.id) {
|
||||
setIsLocalNetwork(false);
|
||||
}
|
||||
}, [targetNetwork.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetNetwork.id === hardhat.id && error) {
|
||||
setHasError(true);
|
||||
}
|
||||
}, [targetNetwork.id, error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLocalNetwork) {
|
||||
notification.error(
|
||||
<>
|
||||
<p className="font-bold mt-0 mb-1">
|
||||
<code className="italic bg-base-300 text-base font-bold"> targetNetwork </code> is not localhost
|
||||
</p>
|
||||
<p className="m-0">
|
||||
- You are on <code className="italic bg-base-300 text-base font-bold">{targetNetwork.name}</code> .This
|
||||
block explorer is only for <code className="italic bg-base-300 text-base font-bold">localhost</code>.
|
||||
</p>
|
||||
<p className="mt-1 break-normal">
|
||||
- You can use{" "}
|
||||
<a className="text-accent" href={targetNetwork.blockExplorers?.default.url}>
|
||||
{targetNetwork.blockExplorers?.default.name}
|
||||
</a>{" "}
|
||||
instead
|
||||
</p>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
isLocalNetwork,
|
||||
targetNetwork.blockExplorers?.default.name,
|
||||
targetNetwork.blockExplorers?.default.url,
|
||||
targetNetwork.name,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasError) {
|
||||
notification.error(
|
||||
<>
|
||||
<p className="font-bold mt-0 mb-1">Cannot connect to local provider</p>
|
||||
<p className="m-0">
|
||||
- Did you forget to run <code className="italic bg-base-300 text-base font-bold">yarn chain</code> ?
|
||||
</p>
|
||||
<p className="mt-1 break-normal">
|
||||
- Or you can change <code className="italic bg-base-300 text-base font-bold">targetNetwork</code> in{" "}
|
||||
<code className="italic bg-base-300 text-base font-bold">scaffold.config.ts</code>
|
||||
</p>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
}, [hasError]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto my-10">
|
||||
<SearchBar />
|
||||
<TransactionsTable blocks={blocks} transactionReceipts={transactionReceipts} />
|
||||
<PaginationButton currentPage={currentPage} totalItems={Number(totalBlocks)} setCurrentPage={setCurrentPage} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockExplorer;
|
||||
@@ -0,0 +1,23 @@
|
||||
import TransactionComp from "../_components/TransactionComp";
|
||||
import type { NextPage } from "next";
|
||||
import { Hash } from "viem";
|
||||
import { isZeroAddress } from "~~/utils/scaffold-eth/common";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ txHash?: Hash }>;
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
// An workaround to enable static exports in Next.js, generating single dummy page.
|
||||
return [{ txHash: "0x0000000000000000000000000000000000000000" }];
|
||||
}
|
||||
const TransactionPage: NextPage<PageProps> = async (props: PageProps) => {
|
||||
const params = await props.params;
|
||||
const txHash = params?.txHash as Hash;
|
||||
|
||||
if (isZeroAddress(txHash)) return null;
|
||||
|
||||
return <TransactionComp txHash={txHash} />;
|
||||
};
|
||||
|
||||
export default TransactionPage;
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Address } from "@scaffold-ui/components";
|
||||
import { Hash, Transaction, TransactionReceipt, formatEther, formatUnits } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { usePublicClient } from "wagmi";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { decodeTransactionData, getFunctionDetails } from "~~/utils/scaffold-eth";
|
||||
import { replacer } from "~~/utils/scaffold-eth/common";
|
||||
|
||||
const TransactionComp = ({ txHash }: { txHash: Hash }) => {
|
||||
const client = usePublicClient({ chainId: hardhat.id });
|
||||
const router = useRouter();
|
||||
const [transaction, setTransaction] = useState<Transaction>();
|
||||
const [receipt, setReceipt] = useState<TransactionReceipt>();
|
||||
const [functionCalled, setFunctionCalled] = useState<string>();
|
||||
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
|
||||
useEffect(() => {
|
||||
if (txHash && client) {
|
||||
const fetchTransaction = async () => {
|
||||
const tx = await client.getTransaction({ hash: txHash });
|
||||
const receipt = await client.getTransactionReceipt({ hash: txHash });
|
||||
|
||||
const transactionWithDecodedData = decodeTransactionData(tx);
|
||||
setTransaction(transactionWithDecodedData);
|
||||
setReceipt(receipt);
|
||||
|
||||
const functionCalled = transactionWithDecodedData.input.substring(0, 10);
|
||||
setFunctionCalled(functionCalled);
|
||||
};
|
||||
|
||||
fetchTransaction();
|
||||
}
|
||||
}, [client, txHash]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto mt-10 mb-20 px-10 md:px-0">
|
||||
<button className="btn btn-sm btn-primary" onClick={() => router.back()}>
|
||||
Back
|
||||
</button>
|
||||
{transaction ? (
|
||||
<div className="overflow-x-auto">
|
||||
<h2 className="text-3xl font-bold mb-4 text-center text-primary-content">Transaction Details</h2>{" "}
|
||||
<table className="table rounded-lg bg-base-100 w-full shadow-lg md:table-lg table-md">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Transaction Hash:</strong>
|
||||
</td>
|
||||
<td>{transaction.hash}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Block Number:</strong>
|
||||
</td>
|
||||
<td>{Number(transaction.blockNumber)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>From:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<Address
|
||||
address={transaction.from}
|
||||
format="long"
|
||||
onlyEnsOrAddress
|
||||
blockExplorerAddressLink={
|
||||
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${transaction.from}` : undefined
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>To:</strong>
|
||||
</td>
|
||||
<td>
|
||||
{!receipt?.contractAddress ? (
|
||||
transaction.to && (
|
||||
<Address
|
||||
address={transaction.to}
|
||||
format="long"
|
||||
onlyEnsOrAddress
|
||||
blockExplorerAddressLink={
|
||||
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${transaction.to}` : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<span>
|
||||
Contract Creation:
|
||||
<Address
|
||||
address={receipt.contractAddress}
|
||||
format="long"
|
||||
onlyEnsOrAddress
|
||||
blockExplorerAddressLink={
|
||||
targetNetwork.id === hardhat.id
|
||||
? `/blockexplorer/address/${receipt.contractAddress}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Value:</strong>
|
||||
</td>
|
||||
<td>
|
||||
{formatEther(transaction.value)} {targetNetwork.nativeCurrency.symbol}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Function called:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div className="w-full md:max-w-[600px] lg:max-w-[800px] overflow-x-auto whitespace-nowrap">
|
||||
{functionCalled === "0x" ? (
|
||||
"This transaction did not call any function."
|
||||
) : (
|
||||
<>
|
||||
<span className="mr-2">{getFunctionDetails(transaction)}</span>
|
||||
<span className="badge badge-primary font-bold">{functionCalled}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Gas Price:</strong>
|
||||
</td>
|
||||
<td>{formatUnits(transaction.gasPrice || 0n, 9)} Gwei</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Data:</strong>
|
||||
</td>
|
||||
<td className="form-control">
|
||||
<textarea
|
||||
readOnly
|
||||
value={transaction.input}
|
||||
className="p-0 w-full textarea-primary bg-inherit h-[150px]"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Logs:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
{receipt?.logs?.map((log, i) => (
|
||||
<li key={i}>
|
||||
<strong>Log {i} topics:</strong> {JSON.stringify(log.topics, replacer, 2)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-2xl text-base-content">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionComp;
|
||||
38
packages/nextjs/app/debug/_components/ContractUI.tsx
Normal file
38
packages/nextjs/app/debug/_components/ContractUI.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
// @refresh reset
|
||||
import { Contract } from "@scaffold-ui/debug-contracts";
|
||||
import { useDeployedContractInfo } from "~~/hooks/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { ContractName } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
type ContractUIProps = {
|
||||
contractName: ContractName;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* UI component to interface with deployed contracts.
|
||||
**/
|
||||
export const ContractUI = ({ contractName }: ContractUIProps) => {
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const { data: deployedContractData, isLoading: deployedContractLoading } = useDeployedContractInfo({ contractName });
|
||||
|
||||
if (deployedContractLoading) {
|
||||
return (
|
||||
<div className="mt-14">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deployedContractData) {
|
||||
return (
|
||||
<p className="text-3xl mt-14">
|
||||
No contract found by the name of {contractName} on chain {targetNetwork.name}!
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return <Contract contractName={contractName as string} contract={deployedContractData} chainId={targetNetwork.id} />;
|
||||
};
|
||||
71
packages/nextjs/app/debug/_components/DebugContracts.tsx
Normal file
71
packages/nextjs/app/debug/_components/DebugContracts.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ContractUI } from "./ContractUI";
|
||||
import "@scaffold-ui/debug-contracts/styles.css";
|
||||
import { useSessionStorage } from "usehooks-ts";
|
||||
import { BarsArrowUpIcon } from "@heroicons/react/20/solid";
|
||||
import { ContractName, GenericContract } from "~~/utils/scaffold-eth/contract";
|
||||
import { useAllContracts } from "~~/utils/scaffold-eth/contractsData";
|
||||
|
||||
const selectedContractStorageKey = "scaffoldEth2.selectedContract";
|
||||
|
||||
export function DebugContracts() {
|
||||
const contractsData = useAllContracts();
|
||||
const contractNames = useMemo(
|
||||
() =>
|
||||
Object.keys(contractsData).sort((a, b) => {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
||||
}) as ContractName[],
|
||||
[contractsData],
|
||||
);
|
||||
|
||||
const [selectedContract, setSelectedContract] = useSessionStorage<ContractName>(
|
||||
selectedContractStorageKey,
|
||||
contractNames[0],
|
||||
{ initializeWithValue: false },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contractNames.includes(selectedContract)) {
|
||||
setSelectedContract(contractNames[0]);
|
||||
}
|
||||
}, [contractNames, selectedContract, setSelectedContract]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-6 lg:gap-y-8 py-8 lg:py-12 justify-center items-center">
|
||||
{contractNames.length === 0 ? (
|
||||
<p className="text-3xl mt-14">No contracts found!</p>
|
||||
) : (
|
||||
<>
|
||||
{contractNames.length > 1 && (
|
||||
<div className="flex flex-row gap-2 w-full max-w-7xl pb-1 px-6 lg:px-10 flex-wrap">
|
||||
{contractNames.map(contractName => (
|
||||
<button
|
||||
className={`btn btn-secondary btn-sm font-light hover:border-transparent ${
|
||||
contractName === selectedContract
|
||||
? "bg-base-300 hover:bg-base-300 no-animation"
|
||||
: "bg-base-100 hover:bg-secondary"
|
||||
}`}
|
||||
key={contractName}
|
||||
onClick={() => setSelectedContract(contractName)}
|
||||
>
|
||||
{contractName}
|
||||
{(contractsData[contractName] as GenericContract)?.external && (
|
||||
<span className="tooltip tooltip-top tooltip-accent" data-tip="External contract">
|
||||
<BarsArrowUpIcon className="h-4 w-4 cursor-pointer" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{contractNames.map(
|
||||
contractName =>
|
||||
contractName === selectedContract && <ContractUI key={contractName} contractName={contractName} />,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
packages/nextjs/app/debug/page.tsx
Normal file
28
packages/nextjs/app/debug/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { DebugContracts } from "./_components/DebugContracts";
|
||||
import type { NextPage } from "next";
|
||||
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
|
||||
|
||||
export const metadata = getMetadata({
|
||||
title: "Debug Contracts",
|
||||
description: "Debug your deployed 🏗 Scaffold-ETH 2 contracts in an easy way",
|
||||
});
|
||||
|
||||
const Debug: NextPage = () => {
|
||||
return (
|
||||
<>
|
||||
<DebugContracts />
|
||||
<div className="text-center mt-8 bg-secondary p-10">
|
||||
<h1 className="text-4xl my-0">Debug Contracts</h1>
|
||||
<p className="text-neutral">
|
||||
You can debug & interact with your deployed contracts here.
|
||||
<br /> Check{" "}
|
||||
<code className="italic bg-base-300 text-base font-bold [word-spacing:-0.5rem] px-1">
|
||||
packages / nextjs / app / debug / page.tsx
|
||||
</code>{" "}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Debug;
|
||||
31
packages/nextjs/app/layout.tsx
Normal file
31
packages/nextjs/app/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Space_Grotesk } from "next/font/google";
|
||||
import "@rainbow-me/rainbowkit/styles.css";
|
||||
import "@scaffold-ui/components/styles.css";
|
||||
import { ScaffoldEthAppWithProviders } from "~~/components/ScaffoldEthAppWithProviders";
|
||||
import { ThemeProvider } from "~~/components/ThemeProvider";
|
||||
import "~~/styles/globals.css";
|
||||
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-space-grotesk",
|
||||
});
|
||||
|
||||
export const metadata = getMetadata({
|
||||
title: "Oracles | Speedrun Ethereum",
|
||||
description: "Built with 🏗 Scaffold-ETH 2",
|
||||
});
|
||||
|
||||
const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html suppressHydrationWarning className={`${spaceGrotesk.variable} font-space-grotesk`}>
|
||||
<body>
|
||||
<ThemeProvider enableSystem>
|
||||
<ScaffoldEthAppWithProviders>{children}</ScaffoldEthAppWithProviders>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScaffoldEthApp;
|
||||
16
packages/nextjs/app/not-found.tsx
Normal file
16
packages/nextjs/app/not-found.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex items-center h-full flex-1 justify-center bg-base-200">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold m-0 mb-1">404</h1>
|
||||
<h2 className="text-2xl font-semibold m-0">Page Not Found</h2>
|
||||
<p className="text-base-content/70 m-0 mb-4">The page you're looking for doesn't exist.</p>
|
||||
<Link href="/" className="btn btn-primary">
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
packages/nextjs/app/optimistic/page.tsx
Normal file
117
packages/nextjs/app/optimistic/page.tsx
Normal file
@@ -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 = () => (
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-4 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="container mx-auto px-8 py-8 max-w-screen-lg xl:max-w-screen-xl">
|
||||
{/* Show loading spinner only during initial load */}
|
||||
{isFirstLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<>
|
||||
{/* Submit Assertion Button with Modal */}
|
||||
<SubmitAssertionButton />
|
||||
|
||||
{/* Tables */}
|
||||
<h2 className="text-2xl font-bold my-4">Asserted</h2>
|
||||
<AssertedTable assertions={assertionStateMap.filter(assertion => assertion.state === 1)} />
|
||||
<h2 className="text-2xl font-bold mt-12 mb-4">Proposed</h2>
|
||||
<ProposedTable assertions={assertionStateMap.filter(assertion => assertion.state === 2)} />
|
||||
<h2 className="text-2xl font-bold mt-12 mb-4">Disputed</h2>
|
||||
<DisputedTable assertions={assertionStateMap.filter(assertion => assertion.state === 3)} />
|
||||
<h2 className="text-2xl font-bold mt-12 mb-4">Settled</h2>
|
||||
<SettledTable assertions={assertionStateMap.filter(assertion => assertion.state === 4)} />
|
||||
<h2 className="text-2xl font-bold mt-12 mb-4">Expired</h2>
|
||||
<ExpiredTable assertions={assertionStateMap.filter(assertion => assertion.state === 5)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<AssertionModal />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
102
packages/nextjs/app/page.tsx
Normal file
102
packages/nextjs/app/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Address } from "@scaffold-ui/components";
|
||||
import type { NextPage } from "next";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useAccount } from "wagmi";
|
||||
import { BugAntIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth";
|
||||
|
||||
const Home: NextPage = () => {
|
||||
const { address: connectedAddress } = useAccount();
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center flex-col grow pt-10">
|
||||
<div className="px-5">
|
||||
<h1 className="text-center">
|
||||
<span className="block text-2xl mb-2">Welcome to</span>
|
||||
<span className="block text-4xl font-bold">Scaffold-ETH 2</span>
|
||||
<span className="block text-xl font-bold">(Speedrun Ethereum Oracles extension)</span>
|
||||
</h1>
|
||||
<div className="flex justify-center items-center space-x-2 flex-col">
|
||||
<p className="my-2 font-medium">Connected Address:</p>
|
||||
<Address
|
||||
address={connectedAddress}
|
||||
chain={targetNetwork}
|
||||
blockExplorerAddressLink={
|
||||
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${connectedAddress}` : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center flex-col flex-grow mt-4">
|
||||
<div className="px-5 w-[90%]">
|
||||
<h1 className="text-center mb-6">
|
||||
<span className="block text-4xl font-bold">Oracles</span>
|
||||
</h1>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Image
|
||||
src="/hero.png"
|
||||
width="727"
|
||||
height="231"
|
||||
alt="challenge banner"
|
||||
className="rounded-xl border-4 border-primary"
|
||||
/>
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-center text-lg mt-8">
|
||||
🔮 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.
|
||||
</p>
|
||||
<p className="text-center text-lg">
|
||||
🌟 The final deliverable is a comprehensive understanding of oracle architectures through hands-on
|
||||
implementation. You'll explore two existing oracle systems (Whitelist and Staking) to
|
||||
understand their mechanics, then implement the Optimistic Oracle from scratch. Deploy your
|
||||
optimistic oracle to a testnet and demonstrate how it handles assertions, proposals, disputes, and
|
||||
settlements. Then build and upload your app to a public web server. Submit the url on{" "}
|
||||
<a href="https://speedrunethereum.com/" target="_blank" rel="noreferrer" className="underline">
|
||||
SpeedrunEthereum.com
|
||||
</a>{" "}
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grow bg-base-300 w-full mt-16 px-8 py-12">
|
||||
<div className="flex justify-center items-center gap-12 flex-col md:flex-row">
|
||||
<div className="flex flex-col bg-base-100 px-10 py-10 text-center items-center max-w-xs rounded-3xl">
|
||||
<BugAntIcon className="h-8 w-8 fill-secondary" />
|
||||
<p>
|
||||
Tinker with your smart contract using the{" "}
|
||||
<Link href="/debug" passHref className="link">
|
||||
Debug Contracts
|
||||
</Link>{" "}
|
||||
tab.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col bg-base-100 px-10 py-10 text-center items-center max-w-xs rounded-3xl">
|
||||
<MagnifyingGlassIcon className="h-8 w-8 fill-secondary" />
|
||||
<p>
|
||||
Explore your local transactions with the{" "}
|
||||
<Link href="/blockexplorer" passHref className="link">
|
||||
Block Explorer
|
||||
</Link>{" "}
|
||||
tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
42
packages/nextjs/app/staking/page.tsx
Normal file
42
packages/nextjs/app/staking/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { NextPage } from "next";
|
||||
import { BucketCountdown } from "~~/components/oracle/BucketCountdown";
|
||||
import { BuyOraWidget } from "~~/components/oracle/BuyOraWidget";
|
||||
import { NodesTable } from "~~/components/oracle/NodesTable";
|
||||
import { PriceWidget } from "~~/components/oracle/PriceWidget";
|
||||
import { TotalSlashedWidget } from "~~/components/oracle/TotalSlashedWidget";
|
||||
|
||||
const Home: NextPage = () => {
|
||||
const [selectedBucket, setSelectedBucket] = useState<bigint | "current">("current");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center flex-col flex-grow pt-2">
|
||||
<div className="w-full px-0 sm:px-2">
|
||||
<div className="flex justify-end mr-4 pt-2">
|
||||
<BuyOraWidget />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 w-full max-w-5xl mx-auto">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="w-full">
|
||||
<div className="grid w-full items-stretch grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<PriceWidget contractName="StakingOracle" />
|
||||
<BucketCountdown />
|
||||
<TotalSlashedWidget />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<NodesTable selectedBucket={selectedBucket} onBucketChange={setSelectedBucket} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
26
packages/nextjs/app/whitelist/page.tsx
Normal file
26
packages/nextjs/app/whitelist/page.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<div className="flex items-center flex-col flex-grow pt-10">
|
||||
<div className="px-5 w-full max-w-5xl mx-auto">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="w-full">
|
||||
<PriceWidget contractName="WhitelistOracle" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<WhitelistTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
80
packages/nextjs/components/Footer.tsx
Normal file
80
packages/nextjs/components/Footer.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useFetchNativeCurrencyPrice } from "@scaffold-ui/hooks";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { CurrencyDollarIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { HeartIcon } from "@heroicons/react/24/outline";
|
||||
import { SwitchTheme } from "~~/components/SwitchTheme";
|
||||
import { BuidlGuidlLogo } from "~~/components/assets/BuidlGuidlLogo";
|
||||
import { Faucet } from "~~/components/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
|
||||
/**
|
||||
* Site footer
|
||||
*/
|
||||
export const Footer = () => {
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const isLocalNetwork = targetNetwork.id === hardhat.id;
|
||||
const { price: nativeCurrencyPrice } = useFetchNativeCurrencyPrice();
|
||||
|
||||
return (
|
||||
<div className="min-h-0 py-5 px-1 mb-11 lg:mb-0">
|
||||
<div>
|
||||
<div className="fixed flex justify-between items-center w-full z-10 p-4 bottom-0 left-0 pointer-events-none">
|
||||
<div className="flex flex-col md:flex-row gap-2 pointer-events-auto">
|
||||
{nativeCurrencyPrice > 0 && (
|
||||
<div>
|
||||
<div className="btn btn-primary btn-sm font-normal gap-1 cursor-auto">
|
||||
<CurrencyDollarIcon className="h-4 w-4" />
|
||||
<span>{nativeCurrencyPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLocalNetwork && (
|
||||
<>
|
||||
<Faucet />
|
||||
<Link href="/blockexplorer" passHref className="btn btn-primary btn-sm font-normal gap-1">
|
||||
<MagnifyingGlassIcon className="h-4 w-4" />
|
||||
<span>Block Explorer</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SwitchTheme className={`pointer-events-auto ${isLocalNetwork ? "self-end md:self-auto" : ""}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<ul className="menu menu-horizontal w-full">
|
||||
<div className="flex justify-center items-center gap-2 text-sm w-full">
|
||||
<div className="text-center">
|
||||
<a href="https://github.com/scaffold-eth/se-2" target="_blank" rel="noreferrer" className="link">
|
||||
Fork me
|
||||
</a>
|
||||
</div>
|
||||
<span>·</span>
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<p className="m-0 text-center">
|
||||
Built with <HeartIcon className="inline-block h-4 w-4" /> at
|
||||
</p>
|
||||
<a
|
||||
className="flex justify-center items-center gap-1"
|
||||
href="https://buidlguidl.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<BuidlGuidlLogo className="w-3 h-5 pb-1" />
|
||||
<span className="link">BuidlGuidl</span>
|
||||
</a>
|
||||
</div>
|
||||
<span>·</span>
|
||||
<div className="text-center">
|
||||
<a href="https://t.me/joinchat/KByvmRe5wkR-8F_zz6AjpA" target="_blank" rel="noreferrer" className="link">
|
||||
Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
115
packages/nextjs/components/Header.tsx
Normal file
115
packages/nextjs/components/Header.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { Bars3Icon, BugAntIcon } from "@heroicons/react/24/outline";
|
||||
import { FaucetButton, RainbowKitCustomConnectButton } from "~~/components/scaffold-eth";
|
||||
import { useOutsideClick, useTargetNetwork } from "~~/hooks/scaffold-eth";
|
||||
|
||||
type HeaderMenuLink = {
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const menuLinks: HeaderMenuLink[] = [
|
||||
{
|
||||
label: "Home",
|
||||
href: "/",
|
||||
},
|
||||
{
|
||||
label: "Whitelist",
|
||||
href: "/whitelist",
|
||||
},
|
||||
{
|
||||
label: "Staking",
|
||||
href: "/staking",
|
||||
},
|
||||
{
|
||||
label: "Optimistic",
|
||||
href: "/optimistic",
|
||||
},
|
||||
{
|
||||
label: "Debug Contracts",
|
||||
href: "/debug",
|
||||
icon: <BugAntIcon className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
export const HeaderMenuLinks = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
{menuLinks.map(({ label, href, icon }) => {
|
||||
const isActive = pathname === href;
|
||||
return (
|
||||
<li key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
passHref
|
||||
className={`${
|
||||
isActive ? "bg-secondary shadow-md" : ""
|
||||
} hover:bg-secondary hover:shadow-md focus:!bg-secondary active:!text-neutral py-1.5 px-3 text-sm rounded-full gap-2 grid grid-flow-col`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Site header
|
||||
*/
|
||||
export const Header = () => {
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const isLocalNetwork = targetNetwork.id === hardhat.id;
|
||||
|
||||
const burgerMenuRef = useRef<HTMLDetailsElement>(null);
|
||||
useOutsideClick(burgerMenuRef, () => {
|
||||
burgerMenuRef?.current?.removeAttribute("open");
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="sticky lg:static top-0 navbar bg-base-100 min-h-0 shrink-0 justify-between z-20 shadow-md shadow-secondary px-0 sm:px-2">
|
||||
<div className="navbar-start w-auto lg:w-1/2">
|
||||
<details className="dropdown" ref={burgerMenuRef}>
|
||||
<summary className="ml-1 btn btn-ghost lg:hidden hover:bg-transparent">
|
||||
<Bars3Icon className="h-1/2" />
|
||||
</summary>
|
||||
<ul
|
||||
className="menu menu-compact dropdown-content mt-3 p-2 shadow-sm bg-base-100 rounded-box w-52"
|
||||
onClick={() => {
|
||||
burgerMenuRef?.current?.removeAttribute("open");
|
||||
}}
|
||||
>
|
||||
<HeaderMenuLinks />
|
||||
</ul>
|
||||
</details>
|
||||
<Link href="/" passHref className="hidden lg:flex items-center gap-2 ml-4 mr-6 shrink-0">
|
||||
<div className="flex relative w-10 h-10">
|
||||
<Image alt="SE2 logo" className="cursor-pointer" fill src="/logo.svg" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold leading-tight">SRE Challenges</span>
|
||||
<span className="text-xs">Oracles</span>
|
||||
</div>
|
||||
</Link>
|
||||
<ul className="hidden lg:flex lg:flex-nowrap menu menu-horizontal px-1 gap-2">
|
||||
<HeaderMenuLinks />
|
||||
</ul>
|
||||
</div>
|
||||
<div className="navbar-end grow mr-4">
|
||||
<RainbowKitCustomConnectButton />
|
||||
{isLocalNetwork && <FaucetButton />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
58
packages/nextjs/components/ScaffoldEthAppWithProviders.tsx
Normal file
58
packages/nextjs/components/ScaffoldEthAppWithProviders.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RainbowKitProvider, darkTheme, lightTheme } from "@rainbow-me/rainbowkit";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AppProgressBar as ProgressBar } from "next-nprogress-bar";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { WagmiProvider } from "wagmi";
|
||||
import { Footer } from "~~/components/Footer";
|
||||
import { Header } from "~~/components/Header";
|
||||
import { BlockieAvatar } from "~~/components/scaffold-eth";
|
||||
import { wagmiConfig } from "~~/services/web3/wagmiConfig";
|
||||
|
||||
const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<div className={`flex flex-col min-h-screen font-space-grotesk`}>
|
||||
<Header />
|
||||
<main className="relative flex flex-col flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ScaffoldEthAppWithProviders = ({ children }: { children: React.ReactNode }) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<WagmiProvider config={wagmiConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RainbowKitProvider
|
||||
avatar={BlockieAvatar}
|
||||
theme={mounted ? (isDarkMode ? darkTheme() : lightTheme()) : lightTheme()}
|
||||
>
|
||||
<ProgressBar height="3px" color="#2299dd" />
|
||||
<ScaffoldEthApp>{children}</ScaffoldEthApp>
|
||||
</RainbowKitProvider>
|
||||
</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
);
|
||||
};
|
||||
42
packages/nextjs/components/SwitchTheme.tsx
Normal file
42
packages/nextjs/components/SwitchTheme.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export const SwitchTheme = ({ className }: { className?: string }) => {
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isDarkMode) {
|
||||
setTheme("light");
|
||||
return;
|
||||
}
|
||||
setTheme("dark");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className={`flex space-x-2 h-8 items-center justify-center text-sm ${className}`}>
|
||||
<input
|
||||
id="theme-toggle"
|
||||
type="checkbox"
|
||||
className="toggle bg-secondary toggle-primary hover:bg-accent transition-all"
|
||||
onChange={handleToggle}
|
||||
checked={isDarkMode}
|
||||
/>
|
||||
<label htmlFor="theme-toggle" className={`swap swap-rotate ${!isDarkMode ? "swap-active" : ""}`}>
|
||||
<SunIcon className="swap-on h-5 w-5" />
|
||||
<MoonIcon className="swap-off h-5 w-5" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
9
packages/nextjs/components/ThemeProvider.tsx
Normal file
9
packages/nextjs/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
|
||||
export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
};
|
||||
33
packages/nextjs/components/TooltipInfo.tsx
Normal file
33
packages/nextjs/components/TooltipInfo.tsx
Normal file
@@ -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<TooltipInfoProps> = ({ top, right, infoText, className = "" }) => {
|
||||
const baseClasses = "tooltip tooltip-secondary font-normal [--radius-field:0.25rem]";
|
||||
const tooltipClasses = className ? `${baseClasses} ${className}` : `${baseClasses} tooltip-right`;
|
||||
|
||||
if (top !== undefined && right !== undefined) {
|
||||
return (
|
||||
<span className="absolute z-10" style={{ top: `${top * 0.25}rem`, right: `${right * 0.25}rem` }}>
|
||||
<div className={tooltipClasses} data-tip={infoText}>
|
||||
<QuestionMarkCircleIcon className="h-5 w-5 m-1" />
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={tooltipClasses} data-tip={infoText}>
|
||||
<QuestionMarkCircleIcon className="h-5 w-5 m-1" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TooltipInfo;
|
||||
18
packages/nextjs/components/assets/BuidlGuidlLogo.tsx
Normal file
18
packages/nextjs/components/assets/BuidlGuidlLogo.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export const BuidlGuidlLogo = ({ className }: { className: string }) => {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="53"
|
||||
height="72"
|
||||
viewBox="0 0 53 72"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M25.9 17.434v15.638h3.927v9.04h9.718v-9.04h6.745v18.08l-10.607 19.88-12.11-.182-12.11.183L.856 51.152v-18.08h6.713v9.04h9.75v-9.04h4.329V2.46a2.126 2.126 0 0 1 4.047-.914c1.074.412 2.157 1.5 3.276 2.626 1.33 1.337 2.711 2.726 4.193 3.095 1.496.373 2.605-.026 3.855-.475 1.31-.47 2.776-.997 5.005-.747 1.67.197 2.557 1.289 3.548 2.509 1.317 1.623 2.82 3.473 6.599 3.752l-.024.017c-2.42 1.709-5.726 4.043-10.86 3.587-1.605-.139-2.736-.656-3.82-1.153-1.546-.707-2.997-1.37-5.59-.832-2.809.563-4.227 1.892-5.306 2.903-.236.221-.456.427-.67.606Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
86
packages/nextjs/components/oracle/BucketCountdown.tsx
Normal file
86
packages/nextjs/components/oracle/BucketCountdown.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import TooltipInfo from "../TooltipInfo";
|
||||
import { usePublicClient } from "wagmi";
|
||||
import { useScaffoldReadContract } from "~~/hooks/scaffold-eth";
|
||||
|
||||
export const BucketCountdown = () => {
|
||||
const publicClient = usePublicClient();
|
||||
const { data: bucketWindow } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "BUCKET_WINDOW",
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
const [remainingSec, setRemainingSec] = useState<number | null>(null);
|
||||
const [currentBucketNum, setCurrentBucketNum] = useState<bigint | null>(null);
|
||||
const lastBucketCheckTime = useRef<number>(0);
|
||||
|
||||
// Poll getCurrentBucketNumber every second for accuracy
|
||||
const { data: contractBucketNum } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getCurrentBucketNumber",
|
||||
watch: true,
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
useEffect(() => {
|
||||
if (contractBucketNum !== undefined) {
|
||||
setCurrentBucketNum(contractBucketNum);
|
||||
lastBucketCheckTime.current = Date.now();
|
||||
}
|
||||
}, [contractBucketNum]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bucketWindow || !publicClient || !currentBucketNum) return;
|
||||
let mounted = true;
|
||||
const update = async () => {
|
||||
try {
|
||||
const block = await publicClient.getBlock();
|
||||
const blockNum = Number(block.number);
|
||||
const w = Number(bucketWindow);
|
||||
if (w <= 0) {
|
||||
setRemainingSec(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate blocks remaining in current bucket
|
||||
// Bucket number = (block.number / BUCKET_WINDOW) + 1
|
||||
// So current bucket started at: (currentBucketNum - 1) * BUCKET_WINDOW
|
||||
const bucketStartBlock = (Number(currentBucketNum) - 1) * w;
|
||||
const nextBucketBlock = bucketStartBlock + w;
|
||||
const blocksRemaining = nextBucketBlock - blockNum;
|
||||
|
||||
// Add 2 second offset since node is ahead of system time
|
||||
const estimatedSecondsRemaining = Math.max(0, blocksRemaining + 2);
|
||||
|
||||
if (mounted) setRemainingSec(estimatedSecondsRemaining > 24 ? 24 : estimatedSecondsRemaining);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
update();
|
||||
const id = setInterval(update, 1000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [bucketWindow, publicClient, currentBucketNum]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<h2 className="text-xl font-bold">Bucket Countdown</h2>
|
||||
<div className="bg-base-100 rounded-lg p-4 w-full flex justify-center items-center relative h-full min-h-[140px]">
|
||||
<TooltipInfo
|
||||
top={0}
|
||||
right={0}
|
||||
className="tooltip-left"
|
||||
infoText="Shows the current bucket number and countdown to the next bucket. Each bucket lasts 24 blocks."
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="text-sm text-gray-500">Bucket #{currentBucketNum?.toString() ?? "..."}</div>
|
||||
<div className="font-bold text-3xl">{remainingSec !== null ? `${remainingSec}s` : "..."}</div>
|
||||
<div className="text-xs text-gray-500">until next bucket</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
74
packages/nextjs/components/oracle/BuyOraWidget.tsx
Normal file
74
packages/nextjs/components/oracle/BuyOraWidget.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { erc20Abi, formatEther, parseEther } from "viem";
|
||||
import { useAccount, useReadContract } from "wagmi";
|
||||
import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
|
||||
|
||||
const ETH_IN = "0.5";
|
||||
const ORA_OUT = "100";
|
||||
|
||||
export const BuyOraWidget = () => {
|
||||
const { address: connectedAddress } = useAccount();
|
||||
const [isBuying, setIsBuying] = useState(false);
|
||||
|
||||
// NOTE: `deployedContracts.ts` is autogenerated from deployments. If ORA isn't listed yet,
|
||||
// the hook will show a "Target Contract is not deployed" notification until you run `yarn deploy`.
|
||||
// We keep TS compiling while deployments/ABIs are catching up.
|
||||
const { writeContractAsync: writeOraUnsafe } = useScaffoldWriteContract({ contractName: "ORA" as any });
|
||||
const writeOra = writeOraUnsafe as any;
|
||||
|
||||
// Read ORA balance using the token address wired into StakingOracle
|
||||
const { data: oracleTokenAddress } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "oracleToken",
|
||||
});
|
||||
|
||||
const { data: oraBalance, refetch: refetchOraBalance } = useReadContract({
|
||||
address: oracleTokenAddress as `0x${string}` | undefined,
|
||||
abi: erc20Abi,
|
||||
functionName: "balanceOf",
|
||||
args: connectedAddress ? [connectedAddress] : undefined,
|
||||
query: { enabled: !!oracleTokenAddress && !!connectedAddress, refetchInterval: 5000 },
|
||||
});
|
||||
|
||||
const oraBalanceFormatted = useMemo(() => {
|
||||
if (oraBalance === undefined) return "—";
|
||||
return Number(formatEther(oraBalance as bigint)).toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||
}, [oraBalance]);
|
||||
|
||||
const handleBuy = async () => {
|
||||
setIsBuying(true);
|
||||
try {
|
||||
await writeOra({
|
||||
functionName: "buy",
|
||||
value: parseEther(ETH_IN),
|
||||
});
|
||||
// Ensure the widget updates immediately after the tx confirms (instead of waiting for polling).
|
||||
await refetchOraBalance();
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsBuying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm w-full md:w-auto">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold">Buy ORA</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<span className="font-mono">{ETH_IN} ETH</span> → <span className="font-mono">{ORA_OUT} ORA</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Your ORA balance: <span className="font-mono">{oraBalanceFormatted}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleBuy} disabled={!connectedAddress || isBuying}>
|
||||
{isBuying ? "Buying..." : "Buy ORA"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
84
packages/nextjs/components/oracle/ConfigSlider.tsx
Normal file
84
packages/nextjs/components/oracle/ConfigSlider.tsx
Normal file
@@ -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<number>(0.0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [localValue, setLocalValue] = useState<number>(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 (
|
||||
<td className="relative">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={localValue}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<div className="text-xs font-medium text-neutral dark:text-neutral-content mt-1 text-center">
|
||||
{(localValue * 100).toFixed(0)}% {label}
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-white/50 dark:bg-black/50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
};
|
||||
152
packages/nextjs/components/oracle/EditableCell.tsx
Normal file
152
packages/nextjs/components/oracle/EditableCell.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { HighlightedCell } from "./HighlightedCell";
|
||||
import { parseEther } from "viem";
|
||||
import { useWriteContract } from "wagmi";
|
||||
import { ArrowPathIcon, PencilIcon } from "@heroicons/react/24/outline";
|
||||
import { SIMPLE_ORACLE_ABI } from "~~/utils/constants";
|
||||
import { notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
type EditableCellProps = {
|
||||
value: string | number;
|
||||
address: string;
|
||||
highlightColor?: string;
|
||||
};
|
||||
|
||||
export const EditableCell = ({ value, address, highlightColor = "" }: EditableCellProps) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(Number(value.toString()) || "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { writeContractAsync } = useWriteContract();
|
||||
|
||||
// Update edit value when prop value changes
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setEditValue(Number(value.toString()) || "");
|
||||
}
|
||||
}, [value, isEditing]);
|
||||
|
||||
// Focus input when editing starts
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const parsedValue = Number(editValue);
|
||||
|
||||
if (isNaN(parsedValue)) {
|
||||
notification.error("Invalid number");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await writeContractAsync({
|
||||
abi: SIMPLE_ORACLE_ABI,
|
||||
address: address,
|
||||
functionName: "setPrice",
|
||||
args: [parseEther(parsedValue.toString())],
|
||||
});
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error("Submit failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Resubmits the currently displayed value without entering edit mode
|
||||
const handleRefresh = async () => {
|
||||
const parsedValue = Number(value.toString());
|
||||
if (isNaN(parsedValue)) {
|
||||
notification.error("Invalid number");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await writeContractAsync({
|
||||
abi: SIMPLE_ORACLE_ABI,
|
||||
address: address,
|
||||
functionName: "setPrice",
|
||||
args: [parseEther(parsedValue.toString())],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Refresh failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const startEditing = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<HighlightedCell
|
||||
value={value}
|
||||
highlightColor={highlightColor}
|
||||
className={`w-[6rem] max-w-[6rem] whitespace-nowrap overflow-hidden`}
|
||||
>
|
||||
<div className="flex w-full items-start">
|
||||
{/* 70% width for value display/editing */}
|
||||
<div className="w-[70%]">
|
||||
{isEditing ? (
|
||||
<div className="relative px-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={"text"}
|
||||
value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)}
|
||||
className="w-full text-sm bg-secondary rounded-md"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 h-full items-stretch">
|
||||
<span className="truncate">{value}</span>
|
||||
<div className="flex items-stretch gap-1">
|
||||
<button
|
||||
className="px-2 text-sm bg-primary rounded cursor-pointer"
|
||||
onClick={startEditing}
|
||||
title="Edit price"
|
||||
>
|
||||
<PencilIcon className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
<button
|
||||
className="px-2 text-sm bg-secondary rounded cursor-pointer disabled:opacity-50"
|
||||
onClick={() => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
void handleRefresh();
|
||||
} catch {}
|
||||
setTimeout(() => setIsRefreshing(false), 3000);
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
title="Resubmit price"
|
||||
>
|
||||
<ArrowPathIcon className={`w-2.5 h-2.5 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 30% width for action buttons */}
|
||||
<div className="w-[30%] items-stretch justify-start pl-2">
|
||||
{isEditing && (
|
||||
<div className="flex items-stretch gap-1 w-full h-full">
|
||||
<button onClick={handleSubmit} className="px-2 text-sm bg-primary rounded cursor-pointer">
|
||||
✓
|
||||
</button>
|
||||
<button onClick={handleCancel} className="px-2 text-sm bg-secondary rounded cursor-pointer">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</HighlightedCell>
|
||||
);
|
||||
};
|
||||
41
packages/nextjs/components/oracle/HighlightedCell.tsx
Normal file
41
packages/nextjs/components/oracle/HighlightedCell.tsx
Normal file
@@ -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<string | number | undefined>(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 (
|
||||
<td
|
||||
className={`transition-colors duration-300 ${isHighlighted ? highlightColor : ""} ${className}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
};
|
||||
200
packages/nextjs/components/oracle/NodeRow.tsx
Normal file
200
packages/nextjs/components/oracle/NodeRow.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useMemo } from "react";
|
||||
import { ConfigSlider } from "./ConfigSlider";
|
||||
import { NodeRowProps } from "./types";
|
||||
import { Address } from "@scaffold-ui/components";
|
||||
import { erc20Abi, formatEther } from "viem";
|
||||
import { useReadContract } from "wagmi";
|
||||
import { HighlightedCell } from "~~/components/oracle/HighlightedCell";
|
||||
import { useScaffoldReadContract } from "~~/hooks/scaffold-eth";
|
||||
import { getHighlightColorForPrice } from "~~/utils/helpers";
|
||||
|
||||
export interface NodeRowEditRequest {
|
||||
address: string;
|
||||
buttonRect: { top: number; left: number; bottom: number; right: number };
|
||||
}
|
||||
|
||||
interface NodeRowWithEditProps extends NodeRowProps {
|
||||
onEditRequest?: (req: NodeRowEditRequest) => void;
|
||||
isEditing?: boolean;
|
||||
showInlineSettings?: boolean;
|
||||
}
|
||||
|
||||
export const NodeRow = ({ address, bucketNumber, showInlineSettings }: NodeRowWithEditProps) => {
|
||||
// Hooks and contract reads
|
||||
const { data: oracleTokenAddress } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "oracleToken",
|
||||
});
|
||||
const { data: oraBalance } = useReadContract({
|
||||
address: oracleTokenAddress as `0x${string}`,
|
||||
abi: erc20Abi,
|
||||
functionName: "balanceOf",
|
||||
args: [address],
|
||||
query: { enabled: !!oracleTokenAddress, refetchInterval: 5000 },
|
||||
});
|
||||
const { data: minimumStake } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "MINIMUM_STAKE",
|
||||
args: undefined,
|
||||
});
|
||||
const { data: currentBucket } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getCurrentBucketNumber",
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
const previousBucket = useMemo(
|
||||
() => (currentBucket && currentBucket > 0n ? currentBucket - 1n : 0n),
|
||||
[currentBucket],
|
||||
);
|
||||
|
||||
const shouldFetchPrevMedian = currentBucket !== undefined && previousBucket > 0n;
|
||||
|
||||
const { data: prevBucketMedian } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getPastPrice",
|
||||
args: [previousBucket] as any,
|
||||
query: { enabled: shouldFetchPrevMedian },
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
const { data: effectiveStake } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getEffectiveStake",
|
||||
args: [address],
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
// Get current bucket price
|
||||
const { data: currentBucketPrice } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getSlashedStatus",
|
||||
args: [address, currentBucket ?? 0n] as const,
|
||||
watch: true,
|
||||
}) as { data?: [bigint, boolean] };
|
||||
|
||||
const reportedPriceInCurrentBucket = currentBucketPrice?.[0];
|
||||
|
||||
// Past bucket data (always call hook; gate via enabled)
|
||||
const isCurrentView = bucketNumber === null || bucketNumber === undefined;
|
||||
|
||||
const { data: addressDataAtBucket } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getSlashedStatus",
|
||||
args: [address, (bucketNumber ?? 0n) as any],
|
||||
query: { enabled: !isCurrentView },
|
||||
}) as { data?: [bigint, boolean] };
|
||||
|
||||
const pastReportedPrice = !isCurrentView && addressDataAtBucket ? addressDataAtBucket[0] : undefined;
|
||||
const pastSlashed = !isCurrentView && addressDataAtBucket ? addressDataAtBucket[1] : undefined;
|
||||
|
||||
const { data: selectedBucketMedian } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getPastPrice",
|
||||
args: [bucketNumber ?? 0n] as any,
|
||||
query: {
|
||||
enabled: !isCurrentView && bucketNumber !== null && bucketNumber !== undefined && (bucketNumber as bigint) > 0n,
|
||||
},
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
// Formatting
|
||||
const stakedAmountFormatted =
|
||||
effectiveStake !== undefined
|
||||
? Number(formatEther(effectiveStake)).toLocaleString(undefined, { maximumFractionDigits: 2 })
|
||||
: "Loading...";
|
||||
const lastReportedPriceFormatted =
|
||||
reportedPriceInCurrentBucket !== undefined && reportedPriceInCurrentBucket !== 0n
|
||||
? `$${Number(parseFloat(formatEther(reportedPriceInCurrentBucket)).toFixed(2))}`
|
||||
: "Not reported";
|
||||
const oraBalanceFormatted =
|
||||
oraBalance !== undefined
|
||||
? Number(formatEther(oraBalance as bigint)).toLocaleString(undefined, { maximumFractionDigits: 2 })
|
||||
: "Loading...";
|
||||
const isInsufficientStake =
|
||||
effectiveStake !== undefined && minimumStake !== undefined && effectiveStake < (minimumStake as bigint);
|
||||
|
||||
// Calculate deviation for past buckets
|
||||
const deviationText = useMemo(() => {
|
||||
if (isCurrentView) return "—";
|
||||
if (!pastReportedPrice || pastReportedPrice === 0n) return "—";
|
||||
if (!selectedBucketMedian || selectedBucketMedian === 0n) return "—";
|
||||
const median = Number(formatEther(selectedBucketMedian));
|
||||
const price = Number(formatEther(pastReportedPrice));
|
||||
if (!Number.isFinite(median) || median === 0) return "—";
|
||||
const pct = ((price - median) / median) * 100;
|
||||
const sign = pct > 0 ? "+" : "";
|
||||
return `${sign}${pct.toFixed(2)}%`;
|
||||
}, [isCurrentView, pastReportedPrice, selectedBucketMedian]);
|
||||
|
||||
// Deviation for current bucket vs previous bucket average
|
||||
const currentDeviationText = useMemo(() => {
|
||||
if (!isCurrentView) return "—";
|
||||
if (!reportedPriceInCurrentBucket || reportedPriceInCurrentBucket === 0n) return "—";
|
||||
if (!prevBucketMedian || prevBucketMedian === 0n) return "—";
|
||||
const avg = Number(formatEther(prevBucketMedian));
|
||||
const price = Number(formatEther(reportedPriceInCurrentBucket));
|
||||
if (!Number.isFinite(avg) || avg === 0) return "—";
|
||||
const pct = ((price - avg) / avg) * 100;
|
||||
const sign = pct > 0 ? "+" : "";
|
||||
return `${sign}${pct.toFixed(2)}%`;
|
||||
}, [isCurrentView, reportedPriceInCurrentBucket, prevBucketMedian]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className={isInsufficientStake ? "opacity-40" : ""}>
|
||||
<td>
|
||||
<div className="flex flex-col">
|
||||
<Address address={address} size="sm" format="short" onlyEnsOrAddress={true} />
|
||||
<span className="text-xs opacity-70">{oraBalanceFormatted} ORA</span>
|
||||
</div>
|
||||
</td>
|
||||
{showInlineSettings ? (
|
||||
// Inline settings mode: only show the settings sliders column
|
||||
<td className="whitespace-nowrap">
|
||||
<div className="flex flex-col gap-2 min-w-[220px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<ConfigSlider nodeAddress={address.toLowerCase()} endpoint="skip-probability" label="skip rate" />
|
||||
<ConfigSlider nodeAddress={address.toLowerCase()} endpoint="price-variance" label="price deviation" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
) : isCurrentView ? (
|
||||
<>
|
||||
<HighlightedCell value={stakedAmountFormatted} highlightColor="bg-error">
|
||||
{stakedAmountFormatted}
|
||||
</HighlightedCell>
|
||||
<HighlightedCell value={oraBalanceFormatted} highlightColor="bg-success">
|
||||
{oraBalanceFormatted}
|
||||
</HighlightedCell>
|
||||
<HighlightedCell
|
||||
value={lastReportedPriceFormatted}
|
||||
highlightColor={getHighlightColorForPrice(reportedPriceInCurrentBucket, prevBucketMedian)}
|
||||
className={""}
|
||||
>
|
||||
{lastReportedPriceFormatted}
|
||||
</HighlightedCell>
|
||||
<td>{currentDeviationText}</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HighlightedCell
|
||||
value={
|
||||
pastReportedPrice !== undefined && pastReportedPrice !== 0n
|
||||
? `$${Number(parseFloat(formatEther(pastReportedPrice)).toFixed(2))}`
|
||||
: "Not reported"
|
||||
}
|
||||
highlightColor={
|
||||
pastSlashed ? "bg-error" : getHighlightColorForPrice(pastReportedPrice, selectedBucketMedian)
|
||||
}
|
||||
className={pastSlashed ? "border-2 border-error" : ""}
|
||||
>
|
||||
{pastReportedPrice !== undefined && pastReportedPrice !== 0n
|
||||
? `$${Number(parseFloat(formatEther(pastReportedPrice)).toFixed(2))}`
|
||||
: "Not reported"}
|
||||
{pastSlashed && <span className="ml-2 text-xs text-error">Slashed</span>}
|
||||
</HighlightedCell>
|
||||
<td>{deviationText}</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
{/* No inline editor row; editor is rendered by parent as floating panel */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
665
packages/nextjs/components/oracle/NodesTable.tsx
Normal file
665
packages/nextjs/components/oracle/NodesTable.tsx
Normal file
@@ -0,0 +1,665 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import TooltipInfo from "../TooltipInfo";
|
||||
import { ConfigSlider } from "./ConfigSlider";
|
||||
import { NodeRow, NodeRowEditRequest } from "./NodeRow";
|
||||
import { SelfNodeRow } from "./SelfNodeRow";
|
||||
import { erc20Abi, formatEther, parseEther } from "viem";
|
||||
import { useAccount, usePublicClient, useReadContract, useWriteContract } from "wagmi";
|
||||
import { Cog6ToothIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
useDeployedContractInfo,
|
||||
useScaffoldEventHistory,
|
||||
useScaffoldReadContract,
|
||||
useScaffoldWriteContract,
|
||||
} from "~~/hooks/scaffold-eth";
|
||||
import { notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
const LoadingRow = ({ colCount = 5 }: { colCount?: number }) => (
|
||||
<tr>
|
||||
<td colSpan={colCount} className="animate-pulse">
|
||||
<div className="h-8 bg-secondary rounded w-full" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
const NoNodesRow = ({ colSpan = 5 }: { colSpan?: number }) => (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="text-center">
|
||||
No nodes found
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const SlashAllButton = ({ selectedBucket }: { selectedBucket: bigint }) => {
|
||||
const publicClient = usePublicClient();
|
||||
const { data: stakingDeployment } = useDeployedContractInfo({ contractName: "StakingOracle" });
|
||||
const { writeContractAsync: writeStakingOracle } = useScaffoldWriteContract({ contractName: "StakingOracle" });
|
||||
const { data: outliers } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getOutlierNodes",
|
||||
args: [selectedBucket] as any,
|
||||
watch: true,
|
||||
}) as { data: string[] | undefined };
|
||||
const { data: nodeAddresses } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getNodeAddresses",
|
||||
watch: true,
|
||||
}) as { data: string[] | undefined };
|
||||
|
||||
const [unslashed, setUnslashed] = React.useState<string[]>([]);
|
||||
|
||||
const { data: priceEvents } = useScaffoldEventHistory({
|
||||
contractName: "StakingOracle",
|
||||
eventName: "PriceReported",
|
||||
watch: true,
|
||||
});
|
||||
|
||||
const bucketReports = React.useMemo(() => {
|
||||
if (!priceEvents) return [];
|
||||
const filtered = priceEvents.filter(ev => {
|
||||
const bucket = ev?.args?.bucketNumber as bigint | undefined;
|
||||
return bucket !== undefined && bucket === selectedBucket;
|
||||
});
|
||||
// IMPORTANT: `slashNode` expects `reportIndex` to match the on-chain `timeBuckets[bucket].reporters[]` index,
|
||||
// which follows the order reports were submitted (tx order). Event history may be returned newest-first,
|
||||
// so we sort by (blockNumber, logIndex) ascending to match insertion order.
|
||||
return [...filtered].sort((a: any, b: any) => {
|
||||
const aBlock = BigInt(a?.blockNumber ?? 0);
|
||||
const bBlock = BigInt(b?.blockNumber ?? 0);
|
||||
if (aBlock !== bBlock) return aBlock < bBlock ? -1 : 1;
|
||||
const aLog = Number(a?.logIndex ?? 0);
|
||||
const bLog = Number(b?.logIndex ?? 0);
|
||||
return aLog - bLog;
|
||||
});
|
||||
}, [priceEvents, selectedBucket]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const check = async () => {
|
||||
if (!outliers || !publicClient || !stakingDeployment) {
|
||||
setUnslashed([]);
|
||||
return;
|
||||
}
|
||||
const list: string[] = [];
|
||||
for (const addr of outliers) {
|
||||
try {
|
||||
const [, isSlashed] = (await publicClient.readContract({
|
||||
address: stakingDeployment.address as `0x${string}`,
|
||||
abi: stakingDeployment.abi as any,
|
||||
functionName: "getSlashedStatus",
|
||||
args: [addr, selectedBucket],
|
||||
})) as [bigint, boolean];
|
||||
if (!isSlashed) list.push(addr);
|
||||
} catch {
|
||||
// assume not slashed on read error
|
||||
list.push(addr);
|
||||
}
|
||||
}
|
||||
setUnslashed(list);
|
||||
};
|
||||
check();
|
||||
const id = setInterval(check, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, [outliers, selectedBucket, publicClient, stakingDeployment]);
|
||||
|
||||
const handleSlashAll = async () => {
|
||||
if (!unslashed.length || !nodeAddresses) return;
|
||||
try {
|
||||
for (const addr of unslashed) {
|
||||
const idx = nodeAddresses.findIndex(a => a?.toLowerCase() === addr.toLowerCase());
|
||||
if (idx === -1) continue;
|
||||
const reportIndex = bucketReports.findIndex(ev => {
|
||||
const reporter = (ev?.args?.node as string | undefined) || "";
|
||||
return reporter.toLowerCase() === addr.toLowerCase();
|
||||
});
|
||||
if (reportIndex === -1) {
|
||||
console.warn(`Report index not found for node ${addr}, skipping slashing.`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await writeStakingOracle({
|
||||
functionName: "slashNode",
|
||||
args: [addr as `0x${string}`, selectedBucket, BigInt(reportIndex), BigInt(idx)],
|
||||
});
|
||||
} catch {
|
||||
// continue slashing the rest
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn btn-error btn-sm mr-2"
|
||||
onClick={handleSlashAll}
|
||||
disabled={unslashed.length === 0}
|
||||
title={unslashed.length ? `Slash ${unslashed.length} outlier node(s)` : "No slashable nodes"}
|
||||
>
|
||||
Slash{unslashed.length ? ` (${unslashed.length})` : ""}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const NodesTable = ({
|
||||
selectedBucket: externalSelectedBucket,
|
||||
onBucketChange,
|
||||
}: {
|
||||
selectedBucket?: bigint | "current";
|
||||
onBucketChange?: (bucket: bigint | "current") => void;
|
||||
} = {}) => {
|
||||
const [editingNode, setEditingNode] = useState<{ address: string; pos: { top: number; left: number } } | null>(null);
|
||||
const [showInlineSettings, setShowInlineSettings] = useState(false);
|
||||
const handleEditRequest = (req: NodeRowEditRequest) => {
|
||||
setEditingNode({ address: req.address, pos: { top: req.buttonRect.bottom + 8, left: req.buttonRect.left } });
|
||||
};
|
||||
const handleCloseEditor = () => setEditingNode(null);
|
||||
const { address: connectedAddress } = useAccount();
|
||||
const publicClient = usePublicClient();
|
||||
const { data: currentBucketData } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getCurrentBucketNumber",
|
||||
}) as { data: bigint | undefined };
|
||||
const currentBucket = currentBucketData ?? undefined;
|
||||
const [isRecordingMedian, setIsRecordingMedian] = useState(false);
|
||||
const [isMedianRecorded, setIsMedianRecorded] = useState<boolean | null>(null);
|
||||
const [internalSelectedBucket, setInternalSelectedBucket] = useState<bigint | "current">("current");
|
||||
const selectedBucket = externalSelectedBucket ?? internalSelectedBucket;
|
||||
const isViewingCurrentBucket = selectedBucket === "current";
|
||||
const targetBucket = useMemo<bigint | null>(() => {
|
||||
// When viewing "current", we actually want to record the *last completed* bucket (current - 1),
|
||||
// since the current bucket is still in progress and cannot be finalized.
|
||||
if (selectedBucket === "current") {
|
||||
if (currentBucket === undefined) return null;
|
||||
if (currentBucket <= 1n) return null;
|
||||
return currentBucket - 1n;
|
||||
}
|
||||
return selectedBucket ?? null;
|
||||
}, [selectedBucket, currentBucket]);
|
||||
const setSelectedBucket = (bucket: bigint | "current") => {
|
||||
setInternalSelectedBucket(bucket);
|
||||
onBucketChange?.(bucket);
|
||||
};
|
||||
const [animateDir, setAnimateDir] = useState<"left" | "right" | null>(null);
|
||||
const [animateKey, setAnimateKey] = useState(0);
|
||||
const [entering, setEntering] = useState(true);
|
||||
const lastCurrentBucketRef = useRef<bigint | null>(null);
|
||||
const { data: registeredEvents, isLoading: isLoadingRegistered } = useScaffoldEventHistory({
|
||||
contractName: "StakingOracle",
|
||||
eventName: "NodeRegistered",
|
||||
watch: true,
|
||||
});
|
||||
const { data: exitedEvents, isLoading: isLoadingExited } = useScaffoldEventHistory({
|
||||
contractName: "StakingOracle",
|
||||
eventName: "NodeExited",
|
||||
watch: true,
|
||||
});
|
||||
const eventDerivedNodeAddresses: string[] = (() => {
|
||||
const set = new Set<string>();
|
||||
(registeredEvents || []).forEach(ev => {
|
||||
const addr = (ev?.args?.node as string | undefined)?.toLowerCase();
|
||||
if (addr) set.add(addr);
|
||||
});
|
||||
(exitedEvents || []).forEach(ev => {
|
||||
const addr = (ev?.args?.node as string | undefined)?.toLowerCase();
|
||||
if (addr) set.delete(addr);
|
||||
});
|
||||
return Array.from(set.values());
|
||||
})();
|
||||
const hasEverRegisteredSelf = useMemo(() => {
|
||||
if (!connectedAddress) return false;
|
||||
const lower = connectedAddress.toLowerCase();
|
||||
return (registeredEvents || []).some(ev => {
|
||||
const addr = (ev?.args?.node as string | undefined)?.toLowerCase();
|
||||
return addr === lower;
|
||||
});
|
||||
}, [registeredEvents, connectedAddress]);
|
||||
useEffect(() => {
|
||||
if (currentBucket === undefined) return;
|
||||
const last = lastCurrentBucketRef.current;
|
||||
// In inline settings mode, keep the UI stable (no animation on bucket changes)
|
||||
if (showInlineSettings) {
|
||||
lastCurrentBucketRef.current = currentBucket;
|
||||
return;
|
||||
}
|
||||
if (last !== null && currentBucket > last) {
|
||||
if (selectedBucket === "current") {
|
||||
setAnimateDir("left");
|
||||
setAnimateKey(k => k + 1);
|
||||
setEntering(false);
|
||||
setTimeout(() => setEntering(true), 20);
|
||||
}
|
||||
}
|
||||
lastCurrentBucketRef.current = currentBucket;
|
||||
}, [currentBucket, selectedBucket, showInlineSettings]);
|
||||
const changeBucketWithAnimation = (newBucket: bigint | "current", dir: "left" | "right") => {
|
||||
setAnimateDir(dir);
|
||||
setAnimateKey(k => k + 1);
|
||||
setEntering(false);
|
||||
setSelectedBucket(newBucket);
|
||||
setTimeout(() => setEntering(true), 20);
|
||||
};
|
||||
const triggerSlide = (dir: "left" | "right") => {
|
||||
setAnimateDir(dir);
|
||||
setAnimateKey(k => k + 1);
|
||||
setEntering(false);
|
||||
setTimeout(() => setEntering(true), 20);
|
||||
};
|
||||
const { writeContractAsync: writeStakingOracle } = useScaffoldWriteContract({ contractName: "StakingOracle" });
|
||||
const { data: nodeAddresses } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getNodeAddresses",
|
||||
watch: true,
|
||||
});
|
||||
const { data: stakingDeployment } = useDeployedContractInfo({ contractName: "StakingOracle" });
|
||||
const { writeContractAsync: writeErc20 } = useWriteContract();
|
||||
const { data: oracleTokenAddress } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "oracleToken",
|
||||
});
|
||||
const { data: oraBalance } = useReadContract({
|
||||
address: oracleTokenAddress as `0x${string}` | undefined,
|
||||
abi: erc20Abi,
|
||||
functionName: "balanceOf",
|
||||
args: connectedAddress ? [connectedAddress] : undefined,
|
||||
query: { enabled: !!oracleTokenAddress && !!connectedAddress },
|
||||
});
|
||||
const { data: minimumStake } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "MINIMUM_STAKE",
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
const minimumStakeFormatted = minimumStake !== undefined ? Number(formatEther(minimumStake)).toLocaleString() : "...";
|
||||
const tooltipText = `This table displays registered oracle nodes that provide price data to the system. Rows are dimmed when the node's effective ORA stake falls below the minimum (${minimumStakeFormatted} ORA). You can edit the skip probability and price variance of an oracle node with the slider.`;
|
||||
const registerButtonLabel = "Register Node";
|
||||
const readMedianValue = useCallback(async (): Promise<boolean | null> => {
|
||||
if (!targetBucket) {
|
||||
return null;
|
||||
}
|
||||
if (targetBucket <= 0n) {
|
||||
return false;
|
||||
}
|
||||
if (!publicClient || !stakingDeployment?.address) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const median = await publicClient.readContract({
|
||||
address: stakingDeployment.address as `0x${string}`,
|
||||
abi: stakingDeployment.abi as any,
|
||||
functionName: "getPastPrice",
|
||||
args: [targetBucket],
|
||||
});
|
||||
return BigInt(String(median)) > 0n;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [publicClient, stakingDeployment, targetBucket]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
const result = await readMedianValue();
|
||||
if (!cancelled) {
|
||||
setIsMedianRecorded(result);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
const interval = setInterval(() => {
|
||||
void run();
|
||||
}, 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [readMedianValue]);
|
||||
|
||||
const canRecordMedian = Boolean(
|
||||
targetBucket && targetBucket > 0n && isMedianRecorded === false && !isRecordingMedian,
|
||||
);
|
||||
const recordMedianButtonLabel = isRecordingMedian
|
||||
? "Recording..."
|
||||
: isViewingCurrentBucket
|
||||
? "Record last Bucket Median"
|
||||
: "Record Median";
|
||||
|
||||
const handleRecordMedian = async () => {
|
||||
if (!stakingDeployment?.address || !targetBucket || targetBucket <= 0n) {
|
||||
return;
|
||||
}
|
||||
setIsRecordingMedian(true);
|
||||
try {
|
||||
await writeStakingOracle({ functionName: "recordBucketMedian", args: [targetBucket] });
|
||||
const refreshed = await readMedianValue();
|
||||
setIsMedianRecorded(refreshed);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsRecordingMedian(false);
|
||||
}
|
||||
};
|
||||
const isSelfRegistered = Boolean(
|
||||
(nodeAddresses as string[] | undefined)?.some(
|
||||
addr => addr?.toLowerCase() === (connectedAddress || "").toLowerCase(),
|
||||
),
|
||||
);
|
||||
const handleRegisterSelf = async () => {
|
||||
if (!connectedAddress) return;
|
||||
if (!stakingDeployment?.address || !oracleTokenAddress) return;
|
||||
if (!publicClient) return;
|
||||
const stakeAmount = minimumStake ?? parseEther("100");
|
||||
try {
|
||||
const currentBalance = (oraBalance as bigint | undefined) ?? 0n;
|
||||
if (currentBalance < stakeAmount) {
|
||||
notification.error(
|
||||
`Insufficient ORA to register. Need ${formatEther(stakeAmount)} ORA to stake (you have ${formatEther(
|
||||
currentBalance,
|
||||
)}). Use “Buy ORA” first.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for approval to be mined before registering.
|
||||
// (writeContractAsync returns the tx hash)
|
||||
const approveHash = await writeErc20({
|
||||
address: oracleTokenAddress as `0x${string}`,
|
||||
abi: erc20Abi,
|
||||
functionName: "approve",
|
||||
args: [stakingDeployment.address as `0x${string}`, stakeAmount],
|
||||
});
|
||||
await publicClient.waitForTransactionReceipt({ hash: approveHash });
|
||||
|
||||
const registerHash = await writeStakingOracle({ functionName: "registerNode", args: [stakeAmount] });
|
||||
if (registerHash) {
|
||||
await publicClient.waitForTransactionReceipt({ hash: registerHash as `0x${string}` });
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
const handleClaimRewards = async () => {
|
||||
if (!connectedAddress) return;
|
||||
try {
|
||||
await writeStakingOracle({ functionName: "claimReward" });
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
const handleExitNode = async () => {
|
||||
if (!connectedAddress) return;
|
||||
if (!isSelfRegistered) return;
|
||||
if (!nodeAddresses) return;
|
||||
const list = nodeAddresses as string[];
|
||||
const idx = list.findIndex(addr => addr?.toLowerCase() === connectedAddress.toLowerCase());
|
||||
if (idx === -1) return;
|
||||
try {
|
||||
await writeStakingOracle({ functionName: "exitNode", args: [BigInt(idx)] });
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
const filteredNodeAddresses = (eventDerivedNodeAddresses || []).filter(
|
||||
(addr: string) => addr?.toLowerCase() !== (connectedAddress || "").toLowerCase(),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold">Oracle Nodes</h2>
|
||||
<span>
|
||||
<TooltipInfo infoText={tooltipText} />
|
||||
</span>
|
||||
<span className="text-xs bg-base-100 px-2 py-1 rounded-full opacity-70">
|
||||
Min Stake: {minimumStakeFormatted} ORA
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleRecordMedian}
|
||||
disabled={!canRecordMedian}
|
||||
title={
|
||||
targetBucket && targetBucket > 0n
|
||||
? isMedianRecorded === true
|
||||
? isViewingCurrentBucket
|
||||
? "Last bucket median already recorded"
|
||||
: "Median already recorded for this bucket"
|
||||
: isViewingCurrentBucket
|
||||
? "Record the median for the last completed bucket"
|
||||
: "Record the median for the selected bucket"
|
||||
: isViewingCurrentBucket
|
||||
? "No completed bucket available yet"
|
||||
: "Median can only be recorded for completed buckets"
|
||||
}
|
||||
>
|
||||
{recordMedianButtonLabel}
|
||||
</button>
|
||||
{/* Slash button near navigation (left of left arrow) */}
|
||||
{selectedBucket !== "current" && <SlashAllButton selectedBucket={selectedBucket as bigint} />}
|
||||
{/* Previous (<) */}
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
if (selectedBucket === "current" && currentBucket && currentBucket > 1n) {
|
||||
changeBucketWithAnimation(currentBucket - 1n, "right");
|
||||
} else if (typeof selectedBucket === "bigint" && selectedBucket > 1n) {
|
||||
changeBucketWithAnimation(selectedBucket - 1n, "right");
|
||||
}
|
||||
}}
|
||||
disabled={selectedBucket === "current" ? !currentBucket || currentBucket <= 1n : selectedBucket <= 1n}
|
||||
title="Previous bucket"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
|
||||
{/* Current selected bucket label (non-clickable) */}
|
||||
<span className="px-2 text-sm tabular-nums select-none">
|
||||
{selectedBucket === "current"
|
||||
? currentBucket !== undefined
|
||||
? currentBucket.toString()
|
||||
: "..."
|
||||
: (selectedBucket as bigint).toString()}
|
||||
</span>
|
||||
|
||||
{/* Next (>) */}
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
if (selectedBucket === "current") return;
|
||||
if (typeof selectedBucket === "bigint" && currentBucket && selectedBucket < currentBucket - 1n) {
|
||||
changeBucketWithAnimation(selectedBucket + 1n, "left");
|
||||
} else if (
|
||||
typeof selectedBucket === "bigint" &&
|
||||
currentBucket &&
|
||||
selectedBucket === currentBucket - 1n
|
||||
) {
|
||||
changeBucketWithAnimation("current", "left");
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
selectedBucket === "current" ||
|
||||
currentBucket === undefined ||
|
||||
(typeof selectedBucket === "bigint" && selectedBucket >= currentBucket)
|
||||
}
|
||||
title="Next bucket"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
|
||||
{/* Go to Current button */}
|
||||
<button
|
||||
className="btn btn-ghost btn-sm ml-2"
|
||||
onClick={() => {
|
||||
const dir: "left" | "right" = showInlineSettings ? "right" : "left";
|
||||
if (showInlineSettings) setShowInlineSettings(false);
|
||||
changeBucketWithAnimation("current", dir);
|
||||
}}
|
||||
disabled={showInlineSettings ? false : selectedBucket === "current"}
|
||||
title="Go to current bucket"
|
||||
>
|
||||
Go to Current
|
||||
</button>
|
||||
|
||||
{/* Inline settings toggle */}
|
||||
<button
|
||||
className={`btn btn-sm ml-1 px-3 ${showInlineSettings ? "btn-primary" : "btn-secondary"}`}
|
||||
style={{ display: "inline-flex" }}
|
||||
onClick={() => {
|
||||
if (!showInlineSettings) {
|
||||
// Opening settings: slide left
|
||||
triggerSlide("left");
|
||||
} else {
|
||||
// Closing settings: slide right for a natural return
|
||||
triggerSlide("right");
|
||||
}
|
||||
setShowInlineSettings(v => !v);
|
||||
}}
|
||||
title={showInlineSettings ? "Hide inline settings" : "Show inline settings"}
|
||||
>
|
||||
<Cog6ToothIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{connectedAddress && !isSelfRegistered ? (
|
||||
<button
|
||||
className="btn btn-primary btn-sm font-normal"
|
||||
onClick={handleRegisterSelf}
|
||||
disabled={!oracleTokenAddress || !stakingDeployment?.address}
|
||||
>
|
||||
{registerButtonLabel}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-primary btn-sm font-normal"
|
||||
onClick={handleClaimRewards}
|
||||
disabled={!isSelfRegistered}
|
||||
>
|
||||
Claim Rewards
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-error btn-sm font-normal"
|
||||
onClick={handleExitNode}
|
||||
disabled={!isSelfRegistered}
|
||||
>
|
||||
Exit Node
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-base-100 rounded-lg p-4 relative">
|
||||
<div className="overflow-x-auto">
|
||||
<div
|
||||
key={animateKey}
|
||||
className={`transform transition-transform duration-300 ${
|
||||
entering ? "translate-x-0" : animateDir === "left" ? "translate-x-full" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<table className="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
{showInlineSettings ? (
|
||||
<>
|
||||
<th>Node Address</th>
|
||||
<th>Node Settings</th>
|
||||
</>
|
||||
) : selectedBucket === "current" ? (
|
||||
<>
|
||||
<th>Node Address</th>
|
||||
<th>Stake</th>
|
||||
<th>Rewards</th>
|
||||
<th>Reported Price</th>
|
||||
<th>
|
||||
<div className="flex items-center gap-1">
|
||||
Deviation
|
||||
<TooltipInfo
|
||||
className="tooltip-left"
|
||||
infoText="Percentage difference versus the previous bucket median"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th>Node Address</th>
|
||||
<th>Reported Price</th>
|
||||
<th>
|
||||
<div className="flex items-center gap-1">
|
||||
Deviation
|
||||
<TooltipInfo
|
||||
className="tooltip-left"
|
||||
infoText="Percentage difference from the recorded bucket median"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!showInlineSettings && (
|
||||
<>
|
||||
{selectedBucket === "current" ? (
|
||||
isSelfRegistered || hasEverRegisteredSelf ? (
|
||||
<SelfNodeRow isStale={false} bucketNumber={null} />
|
||||
) : null
|
||||
) : isSelfRegistered || hasEverRegisteredSelf ? (
|
||||
<SelfNodeRow isStale={false} bucketNumber={selectedBucket as bigint} />
|
||||
) : null}
|
||||
{isSelfRegistered && (
|
||||
<tr>
|
||||
<td colSpan={9} className="py-2">
|
||||
<div className="text-center text-xs uppercase tracking-wider">Simulation Script Nodes</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isLoadingRegistered || isLoadingExited ? (
|
||||
<LoadingRow colCount={showInlineSettings ? 2 : selectedBucket === "current" ? 5 : 4} />
|
||||
) : filteredNodeAddresses.length === 0 ? (
|
||||
<NoNodesRow colSpan={showInlineSettings ? 2 : selectedBucket === "current" ? 5 : 4} />
|
||||
) : (
|
||||
filteredNodeAddresses.map((address: string, index: number) => (
|
||||
<NodeRow
|
||||
key={index}
|
||||
index={index}
|
||||
address={address}
|
||||
bucketNumber={selectedBucket === "current" ? null : (selectedBucket as bigint)}
|
||||
onEditRequest={
|
||||
!showInlineSettings && selectedBucket === "current" ? handleEditRequest : undefined
|
||||
}
|
||||
showInlineSettings={showInlineSettings}
|
||||
isEditing={editingNode?.address === address}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{editingNode && (
|
||||
<div
|
||||
style={{ position: "fixed", top: editingNode.pos.top, left: editingNode.pos.left, zIndex: 60, minWidth: 220 }}
|
||||
className="mt-2 p-3 bg-base-200 rounded shadow-lg border"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ConfigSlider
|
||||
nodeAddress={editingNode.address.toLowerCase()}
|
||||
endpoint="skip-probability"
|
||||
label="skip rate"
|
||||
/>
|
||||
<ConfigSlider nodeAddress={editingNode.address.toLowerCase()} endpoint="price-variance" label="variance" />
|
||||
<div className="flex justify-end">
|
||||
<button className="btn btn-sm btn-ghost" onClick={handleCloseEditor}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
103
packages/nextjs/components/oracle/PriceWidget.tsx
Normal file
103
packages/nextjs/components/oracle/PriceWidget.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
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<bigint | undefined>(undefined);
|
||||
const prevBucket = useRef<bigint | null>(null);
|
||||
const [showBucketLoading, setShowBucketLoading] = useState(false);
|
||||
|
||||
// Poll getCurrentBucketNumber to detect bucket changes
|
||||
const { data: contractBucketNum } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getCurrentBucketNumber",
|
||||
watch: true,
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
useEffect(() => {
|
||||
if (contractBucketNum !== undefined) {
|
||||
// Check if bucket changed
|
||||
if (prevBucket.current !== null && contractBucketNum !== prevBucket.current) {
|
||||
setShowBucketLoading(true);
|
||||
setTimeout(() => setShowBucketLoading(false), 2000); // Show loading for 2 seconds after bucket change
|
||||
}
|
||||
prevBucket.current = contractBucketNum;
|
||||
}
|
||||
}, [contractBucketNum]);
|
||||
|
||||
const isStaking = contractName === "StakingOracle";
|
||||
|
||||
// For WhitelistOracle, check if there are any active oracles (reported within staleness window)
|
||||
const { data: activeOracles } = useScaffoldReadContract({
|
||||
contractName: "WhitelistOracle",
|
||||
functionName: "getActiveOracleNodes",
|
||||
watch: true,
|
||||
}) as { data: readonly `0x${string}`[] | undefined };
|
||||
|
||||
const { data: currentPrice, isError } = useScaffoldReadContract({
|
||||
contractName,
|
||||
functionName: isStaking ? ("getLatestPrice" as any) : ("getPrice" as any),
|
||||
watch: true,
|
||||
}) as { data: bigint | undefined; isError: boolean; isLoading: boolean };
|
||||
|
||||
// For WhitelistOracle: no active oracles means no fresh price
|
||||
// For StakingOracle: rely on error state
|
||||
const noActiveOracles = !isStaking && activeOracles !== undefined && activeOracles.length === 0;
|
||||
const hasValidPrice = !isError && !noActiveOracles && currentPrice !== undefined && currentPrice !== 0n;
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<h2 className="text-xl font-bold">Current Price</h2>
|
||||
<div className="bg-base-100 rounded-lg p-4 w-full flex justify-center items-center relative h-full min-h-[140px]">
|
||||
<TooltipInfo
|
||||
top={0}
|
||||
right={0}
|
||||
className="tooltip-left"
|
||||
infoText="Displays the median price. If no oracle nodes have reported prices in the last 24 seconds, it will display 'No fresh price'. Color highlighting indicates how big of a change there was in the price."
|
||||
/>
|
||||
<div className={`rounded-lg transition-colors duration-1000 ${highlight ? highlightColor : ""}`}>
|
||||
<div className="font-bold h-10 text-4xl flex items-center justify-center gap-4">
|
||||
{showBucketLoading ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-10 bg-secondary rounded-md w-32"></div>
|
||||
</div>
|
||||
) : hasValidPrice ? (
|
||||
<span>{`$${parseFloat(formatEther(currentPrice)).toFixed(2)}`}</span>
|
||||
) : (
|
||||
<div className="text-error text-xl">No fresh price</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
214
packages/nextjs/components/oracle/SelfNodeReporter.tsx
Normal file
214
packages/nextjs/components/oracle/SelfNodeReporter.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { erc20Abi, formatEther, parseEther } from "viem";
|
||||
import { useAccount, usePublicClient, useReadContract, useWriteContract } from "wagmi";
|
||||
import TooltipInfo from "~~/components/TooltipInfo";
|
||||
import { useDeployedContractInfo, useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
|
||||
import { notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
export const SelfNodeReporter = () => {
|
||||
const { address: connectedAddress } = useAccount();
|
||||
const publicClient = usePublicClient();
|
||||
const [stakeAmount, setStakeAmount] = useState<string>("1000");
|
||||
const [newPrice, setNewPrice] = useState<string>("");
|
||||
// Helper to get node index for connected address
|
||||
const { data: nodeAddresses } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getNodeAddresses",
|
||||
watch: true,
|
||||
});
|
||||
|
||||
const { data: oracleTokenAddress } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "oracleToken",
|
||||
});
|
||||
|
||||
const { data: oraBalance } = useReadContract({
|
||||
address: oracleTokenAddress as `0x${string}` | undefined,
|
||||
abi: erc20Abi,
|
||||
functionName: "balanceOf",
|
||||
args: connectedAddress ? [connectedAddress] : undefined,
|
||||
query: { enabled: !!oracleTokenAddress && !!connectedAddress, refetchInterval: 5000 },
|
||||
});
|
||||
// Add exit node handler
|
||||
const handleExitNode = async () => {
|
||||
if (!isRegistered) {
|
||||
return;
|
||||
}
|
||||
if (!nodeAddresses || !connectedAddress) {
|
||||
return;
|
||||
}
|
||||
// Find index of connected address in nodeAddresses
|
||||
const index = nodeAddresses.findIndex((addr: string) => addr.toLowerCase() === connectedAddress.toLowerCase());
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await writeStaking({ functionName: "exitNode", args: [BigInt(index)] });
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const { data: nodeData } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "nodes",
|
||||
args: [connectedAddress ?? "0x0000000000000000000000000000000000000000"] as any,
|
||||
watch: true,
|
||||
});
|
||||
|
||||
// firstBucket is at index 4 of OracleNode struct
|
||||
const firstBucket = (nodeData?.[4] as bigint | undefined) ?? undefined;
|
||||
const lastReportedBucket = (nodeData?.[1] as bigint | undefined) ?? undefined;
|
||||
const stakedAmountRaw = (nodeData?.[0] as bigint | undefined) ?? undefined;
|
||||
|
||||
const { writeContractAsync: writeStaking } = useScaffoldWriteContract({ contractName: "StakingOracle" });
|
||||
const { data: stakingDeployment } = useDeployedContractInfo({ contractName: "StakingOracle" });
|
||||
const stakingAddress = stakingDeployment?.address as `0x${string}` | undefined;
|
||||
const { writeContractAsync: writeErc20 } = useWriteContract();
|
||||
|
||||
const isRegistered = useMemo(() => {
|
||||
return Boolean(firstBucket && firstBucket > 0n);
|
||||
}, [firstBucket]);
|
||||
|
||||
// Fetch last reported price using helper view: getSlashedStatus(address, bucket)
|
||||
const { data: addressDataAtBucket } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getSlashedStatus",
|
||||
args: [connectedAddress ?? "0x0000000000000000000000000000000000000000", lastReportedBucket ?? 0n] as any,
|
||||
watch: true,
|
||||
});
|
||||
const lastReportedPrice = (addressDataAtBucket?.[0] as bigint | undefined) ?? undefined;
|
||||
|
||||
const stakedOraFormatted =
|
||||
stakedAmountRaw !== undefined
|
||||
? Number(formatEther(stakedAmountRaw)).toLocaleString(undefined, { maximumFractionDigits: 2 })
|
||||
: "—";
|
||||
const lastReportedPriceFormatted =
|
||||
lastReportedPrice !== undefined
|
||||
? Number(formatEther(lastReportedPrice)).toLocaleString(undefined, { maximumFractionDigits: 2 })
|
||||
: "—";
|
||||
const oraBalanceFormatted =
|
||||
oraBalance !== undefined
|
||||
? Number(formatEther(oraBalance as bigint)).toLocaleString(undefined, { maximumFractionDigits: 2 })
|
||||
: "—";
|
||||
|
||||
const handleStake = async () => {
|
||||
if (!connectedAddress) {
|
||||
notification.error("Connect a wallet to register a node");
|
||||
return;
|
||||
}
|
||||
if (!publicClient) {
|
||||
notification.error("RPC client not ready yet. Please try again in a moment.");
|
||||
return;
|
||||
}
|
||||
if (!stakingAddress || !oracleTokenAddress) {
|
||||
notification.error("Staking contracts not yet loaded");
|
||||
return;
|
||||
}
|
||||
const numericAmount = Number(stakeAmount);
|
||||
if (isNaN(numericAmount) || numericAmount <= 0) {
|
||||
notification.error("Enter a valid ORA stake amount");
|
||||
return;
|
||||
}
|
||||
const stakeAmountWei = parseEther(stakeAmount);
|
||||
try {
|
||||
const approveHash = await writeErc20({
|
||||
address: oracleTokenAddress as `0x${string}`,
|
||||
abi: erc20Abi,
|
||||
functionName: "approve",
|
||||
args: [stakingAddress, stakeAmountWei],
|
||||
});
|
||||
await publicClient.waitForTransactionReceipt({ hash: approveHash });
|
||||
const registerHash = await writeStaking({
|
||||
functionName: "registerNode",
|
||||
args: [stakeAmountWei],
|
||||
});
|
||||
if (registerHash) {
|
||||
await publicClient.waitForTransactionReceipt({ hash: registerHash as `0x${string}` });
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReport = async () => {
|
||||
const price = Number(newPrice);
|
||||
if (isNaN(price)) {
|
||||
notification.error("Enter a valid price");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await writeStaking({ functionName: "reportPrice", args: [parseEther(price.toString())] });
|
||||
setNewPrice("");
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-base-100 rounded-lg p-4 relative">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold">My Node</h2>
|
||||
<TooltipInfo infoText="Manage your own node with the connected wallet: stake to register, then report prices." />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm text-gray-500">Node Address</div>
|
||||
<div className="font-mono break-all">{connectedAddress ?? "Not connected"}</div>
|
||||
<div className="text-sm text-gray-500">Staked ORA</div>
|
||||
<div className="font-semibold">{stakedOraFormatted}</div>
|
||||
<div className="text-sm text-gray-500">Last Reported Price (USD)</div>
|
||||
<div className="font-semibold">{lastReportedPriceFormatted}</div>
|
||||
<div className="text-sm text-gray-500">ORA Balance</div>
|
||||
<div className="font-semibold">{oraBalanceFormatted}</div>
|
||||
{/* Claim rewards and Exit Node buttons (shown if registered) */}
|
||||
{isRegistered && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleExitNode} disabled={!connectedAddress}>
|
||||
Exit Node
|
||||
</button>
|
||||
{/* Placeholder for Claim Rewards button if/when implemented */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{!isRegistered ? (
|
||||
<div className="flex items-end gap-2">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Stake Amount (ORA)</div>
|
||||
<input
|
||||
className="input input-bordered input-sm w-40"
|
||||
type="text"
|
||||
value={stakeAmount}
|
||||
onChange={e => setStakeAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleStake} disabled={!connectedAddress}>
|
||||
Stake & Register
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-end gap-2">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Report Price (USD)</div>
|
||||
<input
|
||||
className="input input-bordered input-sm w-40"
|
||||
type="text"
|
||||
value={newPrice}
|
||||
onChange={e => setNewPrice(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleReport} disabled={!connectedAddress}>
|
||||
Report
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
279
packages/nextjs/components/oracle/SelfNodeRow.tsx
Normal file
279
packages/nextjs/components/oracle/SelfNodeRow.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Address } from "@scaffold-ui/components";
|
||||
import { erc20Abi, formatEther, maxUint256, parseEther } from "viem";
|
||||
import { useAccount, usePublicClient, useReadContract, useWriteContract } from "wagmi";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { HighlightedCell } from "~~/components/oracle/HighlightedCell";
|
||||
import { StakingEditableCell } from "~~/components/oracle/StakingEditableCell";
|
||||
import { useDeployedContractInfo, useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
|
||||
import { getHighlightColorForPrice } from "~~/utils/helpers";
|
||||
|
||||
type SelfNodeRowProps = {
|
||||
isStale: boolean;
|
||||
bucketNumber?: bigint | null;
|
||||
};
|
||||
|
||||
export const SelfNodeRow = ({ isStale, bucketNumber }: SelfNodeRowProps) => {
|
||||
const { address: connectedAddress } = useAccount();
|
||||
const publicClient = usePublicClient();
|
||||
|
||||
const { data: nodeData } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "nodes",
|
||||
args: [connectedAddress as any],
|
||||
watch: true,
|
||||
});
|
||||
// OracleNode struct layout: [0]=stakedAmount, [1]=lastReportedBucket, [2]=reportCount, [3]=claimedReportCount, [4]=firstBucket
|
||||
const stakedAmount = nodeData?.[0] as bigint | undefined;
|
||||
const claimedReportCount = nodeData?.[3] as bigint | undefined;
|
||||
|
||||
const { data: currentBucket } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getCurrentBucketNumber",
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
const previousBucket = currentBucket && currentBucket > 0n ? currentBucket - 1n : 0n;
|
||||
const shouldFetchPreviousMedian = currentBucket !== undefined && previousBucket > 0n;
|
||||
|
||||
const { data: previousMedian } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getPastPrice",
|
||||
args: [previousBucket] as any,
|
||||
query: { enabled: shouldFetchPreviousMedian },
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
const { data: oracleTokenAddress } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "oracleToken",
|
||||
});
|
||||
|
||||
// Registered addresses array; authoritative for current membership
|
||||
const { data: allNodeAddresses } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getNodeAddresses",
|
||||
watch: true,
|
||||
}) as { data: string[] | undefined };
|
||||
|
||||
const { data: rewardPerReport } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "REWARD_PER_REPORT",
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
const { data: oraBalance } = useReadContract({
|
||||
address: oracleTokenAddress as `0x${string}` | undefined,
|
||||
abi: erc20Abi,
|
||||
functionName: "balanceOf",
|
||||
args: connectedAddress ? [connectedAddress] : undefined,
|
||||
query: { enabled: !!oracleTokenAddress && !!connectedAddress, refetchInterval: 5000 },
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
const oraBalanceFormatted = useMemo(() => {
|
||||
if (oraBalance === undefined) return "—";
|
||||
return Number(formatEther(oraBalance)).toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||
}, [oraBalance]);
|
||||
|
||||
const { writeContractAsync: writeStaking } = useScaffoldWriteContract({ contractName: "StakingOracle" });
|
||||
const { data: stakingDeployment } = useDeployedContractInfo({ contractName: "StakingOracle" });
|
||||
const { writeContractAsync: writeErc20 } = useWriteContract();
|
||||
const stakingAddress = stakingDeployment?.address as `0x${string}` | undefined;
|
||||
|
||||
const isRegistered = useMemo(() => {
|
||||
if (!connectedAddress) return false;
|
||||
if (!allNodeAddresses) return false;
|
||||
return allNodeAddresses.some(a => a?.toLowerCase() === connectedAddress.toLowerCase());
|
||||
}, [allNodeAddresses, connectedAddress]);
|
||||
|
||||
// Use wagmi's useReadContract for enabled gating to avoid reverts when not registered
|
||||
const { data: effectiveStake } = useReadContract({
|
||||
address: (stakingDeployment?.address as `0x${string}`) || undefined,
|
||||
abi: (stakingDeployment?.abi as any) || undefined,
|
||||
functionName: "getEffectiveStake",
|
||||
args: connectedAddress ? [connectedAddress] : undefined,
|
||||
query: { enabled: !!stakingDeployment?.address && !!connectedAddress && isRegistered, refetchInterval: 5000 },
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
const stakedAmountFormatted =
|
||||
effectiveStake !== undefined
|
||||
? Number(formatEther(effectiveStake)).toLocaleString(undefined, { maximumFractionDigits: 2 })
|
||||
: "Loading...";
|
||||
// Current bucket reported price from contract (align with NodeRow)
|
||||
const { data: currentBucketPrice } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getSlashedStatus",
|
||||
args: [connectedAddress || "0x0000000000000000000000000000000000000000", currentBucket ?? 0n] as const,
|
||||
watch: true,
|
||||
}) as { data?: [bigint, boolean] };
|
||||
const reportedPriceInCurrentBucket = currentBucketPrice?.[0];
|
||||
const hasReportedThisBucket = reportedPriceInCurrentBucket !== undefined && reportedPriceInCurrentBucket !== 0n;
|
||||
const lastReportedPriceFormatted =
|
||||
reportedPriceInCurrentBucket !== undefined && reportedPriceInCurrentBucket !== 0n
|
||||
? `$${Number(parseFloat(formatEther(reportedPriceInCurrentBucket)).toFixed(2))}`
|
||||
: "Not reported";
|
||||
|
||||
const claimedRewardsFormatted = useMemo(() => {
|
||||
const rpr = rewardPerReport ?? parseEther("1");
|
||||
const claimed = (claimedReportCount ?? 0n) * rpr;
|
||||
const wholeOra = claimed / 10n ** 18n;
|
||||
return new Intl.NumberFormat("en-US").format(wholeOra);
|
||||
}, [claimedReportCount, rewardPerReport]);
|
||||
|
||||
// Track previous staked amount to determine up/down changes for highlight
|
||||
const prevStakedAmountRef = useRef<bigint | undefined>(undefined);
|
||||
const prevStakedAmount = prevStakedAmountRef.current;
|
||||
let stakeHighlightColor = "";
|
||||
if (prevStakedAmount !== undefined && stakedAmount !== undefined && stakedAmount !== prevStakedAmount) {
|
||||
stakeHighlightColor = stakedAmount > prevStakedAmount ? "bg-success" : "bg-error";
|
||||
}
|
||||
useEffect(() => {
|
||||
prevStakedAmountRef.current = stakedAmount;
|
||||
}, [stakedAmount]);
|
||||
|
||||
// Deviation for current bucket vs previous bucket average
|
||||
const currentDeviationText = useMemo(() => {
|
||||
if (!reportedPriceInCurrentBucket || reportedPriceInCurrentBucket === 0n) return "—";
|
||||
if (!previousMedian || previousMedian === 0n) return "—";
|
||||
const avg = Number(formatEther(previousMedian));
|
||||
const price = Number(formatEther(reportedPriceInCurrentBucket));
|
||||
if (!Number.isFinite(avg) || avg === 0) return "—";
|
||||
const pct = ((price - avg) / avg) * 100;
|
||||
const sign = pct > 0 ? "+" : "";
|
||||
return `${sign}${pct.toFixed(2)}%`;
|
||||
}, [reportedPriceInCurrentBucket, previousMedian]);
|
||||
|
||||
const isCurrentView = bucketNumber === null || bucketNumber === undefined;
|
||||
|
||||
// For past buckets, fetch the reported price at that bucket
|
||||
const { data: selectedBucketMedian } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getPastPrice",
|
||||
args: [bucketNumber ?? 0n] as any,
|
||||
query: {
|
||||
enabled: !isCurrentView && bucketNumber !== null && bucketNumber !== undefined && (bucketNumber as bigint) > 0n,
|
||||
},
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
const { data: pastBucketPrice } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getSlashedStatus",
|
||||
args: [
|
||||
connectedAddress || "0x0000000000000000000000000000000000000000",
|
||||
!isCurrentView && bucketNumber ? bucketNumber : 0n,
|
||||
] as const,
|
||||
watch: true,
|
||||
}) as { data?: [bigint, boolean] };
|
||||
|
||||
const pastReportedPrice = !isCurrentView && pastBucketPrice ? pastBucketPrice[0] : undefined;
|
||||
const pastSlashed = !isCurrentView && pastBucketPrice ? pastBucketPrice[1] : undefined;
|
||||
|
||||
// Calculate deviation for past bucket
|
||||
const pastDeviationText = useMemo(() => {
|
||||
if (isCurrentView) return "—";
|
||||
if (!pastReportedPrice || pastReportedPrice === 0n || !bucketNumber) return "—";
|
||||
if (!selectedBucketMedian || selectedBucketMedian === 0n) return "—";
|
||||
const avg = Number(formatEther(selectedBucketMedian));
|
||||
const price = Number(formatEther(pastReportedPrice));
|
||||
if (!Number.isFinite(avg) || avg === 0) return "—";
|
||||
const pct = ((price - avg) / avg) * 100;
|
||||
const sign = pct > 0 ? "+" : "";
|
||||
return `${sign}${pct.toFixed(2)}%`;
|
||||
}, [isCurrentView, pastReportedPrice, selectedBucketMedian, bucketNumber]);
|
||||
|
||||
const handleAddStake = async () => {
|
||||
if (!connectedAddress || !oracleTokenAddress || !stakingAddress || !publicClient) return;
|
||||
const additionalStake = parseEther("100");
|
||||
try {
|
||||
// Approve max so user doesn't need to re-approve each time
|
||||
const approveHash = await writeErc20({
|
||||
address: oracleTokenAddress as `0x${string}`,
|
||||
abi: erc20Abi,
|
||||
functionName: "approve",
|
||||
args: [stakingAddress, maxUint256],
|
||||
});
|
||||
// Wait for approval to be mined before calling addStake
|
||||
await publicClient.waitForTransactionReceipt({ hash: approveHash });
|
||||
await writeStaking({ functionName: "addStake", args: [additionalStake] });
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className={isStale ? "opacity-40" : ""}>
|
||||
<td>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{connectedAddress ? <Address address={connectedAddress} size="sm" format="short" onlyEnsOrAddress /> : "—"}
|
||||
<span className="text-xs opacity-70" title="Your ORA wallet balance">
|
||||
{oraBalanceFormatted} ORA
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
{isCurrentView ? (
|
||||
isRegistered ? (
|
||||
<>
|
||||
<HighlightedCell value={stakedAmountFormatted} highlightColor={stakeHighlightColor}>
|
||||
<div className="flex items-center gap-2 h-full items-stretch">
|
||||
<span>{stakedAmountFormatted}</span>
|
||||
<button
|
||||
className="px-2 text-sm bg-primary rounded cursor-pointer"
|
||||
onClick={handleAddStake}
|
||||
title="Add 1000 ORA"
|
||||
>
|
||||
<PlusIcon className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
</HighlightedCell>
|
||||
<HighlightedCell value={claimedRewardsFormatted} highlightColor="bg-success">
|
||||
{claimedRewardsFormatted}
|
||||
</HighlightedCell>
|
||||
<StakingEditableCell
|
||||
value={lastReportedPriceFormatted}
|
||||
nodeAddress={connectedAddress || "0x0000000000000000000000000000000000000000"}
|
||||
highlightColor={getHighlightColorForPrice(reportedPriceInCurrentBucket, previousMedian)}
|
||||
className={""}
|
||||
canEdit={isRegistered}
|
||||
disabled={hasReportedThisBucket}
|
||||
/>
|
||||
<td>{currentDeviationText}</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HighlightedCell value={"—"} highlightColor="">
|
||||
—
|
||||
</HighlightedCell>
|
||||
<HighlightedCell value={claimedRewardsFormatted} highlightColor="bg-success">
|
||||
{claimedRewardsFormatted}
|
||||
</HighlightedCell>
|
||||
<StakingEditableCell
|
||||
value={"Must re-register"}
|
||||
nodeAddress={connectedAddress || "0x0000000000000000000000000000000000000000"}
|
||||
highlightColor={""}
|
||||
className={""}
|
||||
canEdit={false}
|
||||
/>
|
||||
<td>—</td>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<HighlightedCell
|
||||
value={
|
||||
pastReportedPrice !== undefined && pastReportedPrice !== 0n
|
||||
? `$${Number(parseFloat(formatEther(pastReportedPrice)).toFixed(2))}`
|
||||
: "Not reported"
|
||||
}
|
||||
highlightColor={
|
||||
pastSlashed ? "bg-error" : getHighlightColorForPrice(pastReportedPrice, selectedBucketMedian)
|
||||
}
|
||||
className={pastSlashed ? "border-2 border-error" : ""}
|
||||
>
|
||||
{pastReportedPrice !== undefined && pastReportedPrice !== 0n
|
||||
? `$${Number(parseFloat(formatEther(pastReportedPrice)).toFixed(2))}`
|
||||
: "Not reported"}
|
||||
{pastSlashed && <span className="ml-2 text-xs text-error">Slashed</span>}
|
||||
</HighlightedCell>
|
||||
<td>{pastDeviationText}</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user