Initial commit with 🏗️ create-eth @ 2.0.4
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
177
packages/nextjs/components/oracle/StakingEditableCell.tsx
Normal file
177
packages/nextjs/components/oracle/StakingEditableCell.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { HighlightedCell } from "./HighlightedCell";
|
||||
import { formatEther, parseEther } from "viem";
|
||||
import { ArrowPathIcon, PencilIcon } from "@heroicons/react/24/outline";
|
||||
import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
|
||||
import { notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
type StakingEditableCellProps = {
|
||||
value: string | number;
|
||||
nodeAddress: string;
|
||||
highlightColor?: string;
|
||||
className?: string;
|
||||
canEdit?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const StakingEditableCell = ({
|
||||
value,
|
||||
nodeAddress,
|
||||
highlightColor = "",
|
||||
className = "",
|
||||
canEdit = true,
|
||||
disabled = false,
|
||||
}: StakingEditableCellProps) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const coerceToNumber = (val: string | number) => {
|
||||
if (typeof val === "number") return val;
|
||||
const numeric = Number(String(val).replace(/[^0-9.\-]/g, ""));
|
||||
return Number.isFinite(numeric) ? numeric : NaN;
|
||||
};
|
||||
const [editValue, setEditValue] = useState<number | string>(coerceToNumber(value) || "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { writeContractAsync: writeStakingOracle } = useScaffoldWriteContract({ contractName: "StakingOracle" });
|
||||
|
||||
// Read current bucket and previous bucket average for refresh
|
||||
const { data: currentBucket } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getCurrentBucketNumber",
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
const previousBucket = useMemo(
|
||||
() => (currentBucket && currentBucket > 0n ? currentBucket - 1n : 0n),
|
||||
[currentBucket],
|
||||
);
|
||||
|
||||
const { data: prevBucketAverage } = useScaffoldReadContract({
|
||||
contractName: "StakingOracle",
|
||||
functionName: "getPastPrice",
|
||||
args: [previousBucket] as any,
|
||||
}) as { data: bigint | undefined };
|
||||
|
||||
const hasPrevAvg = typeof prevBucketAverage === "bigint" && prevBucketAverage > 0n;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setEditValue(coerceToNumber(value) || "");
|
||||
}
|
||||
}, [value, isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const parsedValue = Number(editValue);
|
||||
if (isNaN(parsedValue)) {
|
||||
notification.error("Invalid number");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await writeStakingOracle({
|
||||
functionName: "reportPrice",
|
||||
args: [parseEther(parsedValue.toString())],
|
||||
account: nodeAddress as `0x${string}`,
|
||||
});
|
||||
setIsEditing(false);
|
||||
} catch (error: any) {
|
||||
console.error(error?.shortMessage || "Failed to update price");
|
||||
}
|
||||
};
|
||||
|
||||
// Resubmits the average price from the previous bucket
|
||||
const handleRefresh = async () => {
|
||||
if (!prevBucketAverage || prevBucketAverage === 0n) {
|
||||
notification.error("No previous bucket average available");
|
||||
return;
|
||||
}
|
||||
const avgPrice = Number(formatEther(prevBucketAverage));
|
||||
try {
|
||||
await writeStakingOracle({
|
||||
functionName: "reportPrice",
|
||||
args: [parseEther(avgPrice.toString())],
|
||||
account: nodeAddress as `0x${string}`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => setIsEditing(false);
|
||||
const startEditing = () => {
|
||||
if (!canEdit || disabled) return;
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<HighlightedCell
|
||||
value={value}
|
||||
highlightColor={highlightColor}
|
||||
className={`min-w-[14rem] w-[16rem] whitespace-nowrap overflow-visible ${className}`}
|
||||
>
|
||||
<div className="flex w-full items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
{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>
|
||||
{canEdit && (
|
||||
<div className="flex items-stretch gap-1">
|
||||
<button
|
||||
className="px-2 text-sm bg-primary rounded disabled:opacity-50 cursor-pointer"
|
||||
onClick={startEditing}
|
||||
disabled={!canEdit || disabled}
|
||||
title="Edit price"
|
||||
>
|
||||
<PencilIcon className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
<button
|
||||
className="px-2 text-sm bg-secondary rounded disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
onClick={() => {
|
||||
if (isRefreshing || !hasPrevAvg || disabled) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
void handleRefresh();
|
||||
} catch {}
|
||||
setTimeout(() => setIsRefreshing(false), 3000);
|
||||
}}
|
||||
disabled={!canEdit || disabled || isRefreshing || !hasPrevAvg}
|
||||
title={hasPrevAvg ? "Report previous bucket average" : "No past price available"}
|
||||
>
|
||||
<ArrowPathIcon className={`w-2.5 h-2.5 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 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>
|
||||
);
|
||||
};
|
||||
55
packages/nextjs/components/oracle/TimeAgo.tsx
Normal file
55
packages/nextjs/components/oracle/TimeAgo.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useChallengeState } from "~~/services/store/challengeStore";
|
||||
|
||||
type TimeAgoProps = {
|
||||
timestamp?: bigint;
|
||||
staleWindow?: bigint;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const formatTimeAgo = (tsSec: number | undefined, nowSec: number): string => {
|
||||
if (tsSec === undefined) return "—";
|
||||
if (tsSec === 0) return "never";
|
||||
// Clamp to avoid negative display in rare race conditions
|
||||
const diffSec = Math.max(0, nowSec - tsSec);
|
||||
if (diffSec < 60) return `${diffSec}s ago`;
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
return `${diffHr}h ago`;
|
||||
};
|
||||
|
||||
export const TimeAgo = ({ timestamp, staleWindow, className = "" }: TimeAgoProps) => {
|
||||
const { timestamp: networkTimestamp } = useChallengeState();
|
||||
const [currentTime, setCurrentTime] = useState<number>(() =>
|
||||
networkTimestamp ? Number(networkTimestamp) : Math.floor(Date.now() / 1000),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(prev => prev + 1);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const tsSec = typeof timestamp === "bigint" ? Number(timestamp) : timestamp;
|
||||
const displayNow = currentTime;
|
||||
const text = formatTimeAgo(tsSec, displayNow);
|
||||
|
||||
// Determine staleness coloring
|
||||
let colorClass = "";
|
||||
if (tsSec === undefined) {
|
||||
colorClass = "";
|
||||
} else if (tsSec === 0) {
|
||||
colorClass = "text-error";
|
||||
} else if (typeof staleWindow === "bigint") {
|
||||
const isStale = tsSec === undefined ? false : displayNow - tsSec > Number(staleWindow);
|
||||
colorClass = isStale ? "text-error" : "text-success";
|
||||
}
|
||||
|
||||
return <span className={`whitespace-nowrap ${colorClass} ${className}`}>{text}</span>;
|
||||
};
|
||||
|
||||
export default TimeAgo;
|
||||
43
packages/nextjs/components/oracle/TotalSlashedWidget.tsx
Normal file
43
packages/nextjs/components/oracle/TotalSlashedWidget.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from "react";
|
||||
import TooltipInfo from "~~/components/TooltipInfo";
|
||||
import { useScaffoldEventHistory } from "~~/hooks/scaffold-eth";
|
||||
|
||||
export const TotalSlashedWidget = () => {
|
||||
const { data: slashedEvents, isLoading } = useScaffoldEventHistory({
|
||||
contractName: "StakingOracle",
|
||||
eventName: "NodeSlashed",
|
||||
watch: true,
|
||||
});
|
||||
|
||||
const totalSlashedWei = useMemo(() => {
|
||||
if (!slashedEvents) return 0n;
|
||||
return slashedEvents.reduce((acc: bigint, current) => {
|
||||
const amount = (current?.args?.amount as bigint | undefined) ?? 0n;
|
||||
return acc + amount;
|
||||
}, 0n);
|
||||
}, [slashedEvents]);
|
||||
|
||||
const totalSlashedOraFormatted = useMemo(() => {
|
||||
// ORA uses 18 decimals (same as ETH), but we intentionally display whole tokens only.
|
||||
const wholeOra = totalSlashedWei / 10n ** 18n;
|
||||
return new Intl.NumberFormat("en-US").format(wholeOra);
|
||||
}, [totalSlashedWei]);
|
||||
|
||||
const tooltipText = "Aggregated ORA slashed across all nodes. Sums the amount from every NodeSlashed event.";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<h2 className="text-xl font-bold">Total Slashed</h2>
|
||||
<div className="bg-base-100 rounded-lg p-4 relative w-full h-full min-h-[140px]">
|
||||
<TooltipInfo top={0} right={0} infoText={tooltipText} className="tooltip-left" />
|
||||
<div className="flex flex-col gap-1 h-full items-center justify-center">
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse h-10 bg-secondary rounded-md w-32" />
|
||||
) : (
|
||||
<div className="font-bold text-4xl">{totalSlashedOraFormatted} ORA</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
50
packages/nextjs/components/oracle/optimistic/AssertedRow.tsx
Normal file
50
packages/nextjs/components/oracle/optimistic/AssertedRow.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { TimeLeft } from "./TimeLeft";
|
||||
import { formatEther } from "viem";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import { useScaffoldReadContract } from "~~/hooks/scaffold-eth";
|
||||
import { useChallengeState } from "~~/services/store/challengeStore";
|
||||
|
||||
export const AssertedRow = ({ assertionId, state }: { assertionId: number; state: number }) => {
|
||||
const { openAssertionModal } = useChallengeState();
|
||||
|
||||
const { data: assertionData } = useScaffoldReadContract({
|
||||
contractName: "OptimisticOracle",
|
||||
functionName: "getAssertion",
|
||||
args: [BigInt(assertionId)],
|
||||
});
|
||||
|
||||
if (!assertionData) return null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={assertionId}
|
||||
onClick={() => {
|
||||
openAssertionModal({ ...assertionData, assertionId, state });
|
||||
}}
|
||||
className={`group border-b border-base-300 cursor-pointer`}
|
||||
>
|
||||
{/* Description Column */}
|
||||
<td>
|
||||
<div className="group-hover:text-error">{assertionData.description}</div>
|
||||
</td>
|
||||
|
||||
{/* Bond Column */}
|
||||
<td>{formatEther(assertionData.bond)} ETH</td>
|
||||
|
||||
{/* Reward Column */}
|
||||
<td>{formatEther(assertionData.reward)} ETH</td>
|
||||
|
||||
{/* Time Left Column */}
|
||||
<td>
|
||||
<TimeLeft startTime={assertionData.startTime} endTime={assertionData.endTime} />
|
||||
</td>
|
||||
|
||||
{/* Chevron Column */}
|
||||
<td>
|
||||
<div className="w-6 h-6 rounded-full border-error border flex items-center justify-center hover:bg-base-200 group-hover:bg-error transition-colors mx-auto">
|
||||
<ChevronRightIcon className="w-4 h-4 text-error group-hover:text-white stroke-2 transition-colors" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { OOTableProps } from "../types";
|
||||
import { AssertedRow } from "./AssertedRow";
|
||||
import { EmptyRow } from "./EmptyRow";
|
||||
|
||||
export const AssertedTable = ({ assertions }: OOTableProps) => {
|
||||
return (
|
||||
<div className="bg-base-100 rounded-lg shadow-lg overflow-x-auto">
|
||||
<table className="w-full table-auto [&_th]:px-6 [&_th]:py-4 [&_td]:px-6 [&_td]:py-4">
|
||||
{/* Header */}
|
||||
<thead>
|
||||
<tr className="bg-base-300">
|
||||
<th className="text-left font-semibold w-5/12">Description</th>
|
||||
<th className="text-left font-semibold w-2/12">Bond</th>
|
||||
<th className="text-left font-semibold w-2/12">Reward</th>
|
||||
<th className="text-left font-semibold w-2/12">Time Left</th>
|
||||
<th className="text-left font-semibold w-1/12">{/* Icon column */}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{assertions.length > 0 ? (
|
||||
assertions.map(assertion => (
|
||||
<AssertedRow key={assertion.assertionId} assertionId={assertion.assertionId} state={assertion.state} />
|
||||
))
|
||||
) : (
|
||||
<EmptyRow colspan={5} />
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
260
packages/nextjs/components/oracle/optimistic/AssertionModal.tsx
Normal file
260
packages/nextjs/components/oracle/optimistic/AssertionModal.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AssertionWithIdAndState } from "../types";
|
||||
import { Address } from "@scaffold-ui/components";
|
||||
import { formatEther } from "viem";
|
||||
import { useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
|
||||
import { useChallengeState } from "~~/services/store/challengeStore";
|
||||
import { ZERO_ADDRESS } from "~~/utils/scaffold-eth/common";
|
||||
|
||||
const getStateName = (state: number) => {
|
||||
switch (state) {
|
||||
case 0:
|
||||
return "Invalid";
|
||||
case 1:
|
||||
return "Asserted";
|
||||
case 2:
|
||||
return "Proposed";
|
||||
case 3:
|
||||
return "Disputed";
|
||||
case 4:
|
||||
return "Settled";
|
||||
case 5:
|
||||
return "Expired";
|
||||
default:
|
||||
return "Invalid";
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to format timestamp to UTC
|
||||
const formatTimestamp = (timestamp: bigint | string | number) => {
|
||||
const timestampNumber = Number(timestamp);
|
||||
const date = new Date(timestampNumber * 1000); // Convert from seconds to milliseconds
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const Description = ({ assertion }: { assertion: AssertionWithIdAndState }) => {
|
||||
return (
|
||||
<div className="bg-base-200 p-4 rounded-lg space-y-2 mb-4">
|
||||
<div>
|
||||
<span className="font-bold">AssertionId:</span> {assertion.assertionId}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-bold">Description:</span> {assertion.description}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-bold">Bond:</span> {formatEther(assertion.bond)} ETH
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-bold">Reward:</span> {formatEther(assertion.reward)} ETH
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-bold">Start Time:</span>
|
||||
<span className="text-sm"> UTC: {formatTimestamp(assertion.startTime)}</span>
|
||||
<span className="text-sm"> Timestamp: {assertion.startTime}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-bold">End Time:</span>
|
||||
<span className="text-sm"> UTC: {formatTimestamp(assertion.endTime)}</span>
|
||||
<span className="text-sm"> Timestamp: {assertion.endTime}</span>
|
||||
</div>
|
||||
|
||||
{assertion.proposer !== ZERO_ADDRESS && (
|
||||
<div>
|
||||
<span className="font-bold">Proposed Outcome:</span> {assertion.proposedOutcome ? "True" : "False"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assertion.proposer !== ZERO_ADDRESS && (
|
||||
<div>
|
||||
<span className="font-bold">Proposer:</span>{" "}
|
||||
<Address address={assertion.proposer} format="short" onlyEnsOrAddress disableAddressLink size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assertion.disputer !== ZERO_ADDRESS && (
|
||||
<div>
|
||||
<span className="font-bold">Disputer:</span>{" "}
|
||||
<Address address={assertion.disputer} format="short" onlyEnsOrAddress disableAddressLink size="sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AssertionModal = () => {
|
||||
const [isActionPending, setIsActionPending] = useState(false);
|
||||
const { refetchAssertionStates, openAssertion, closeAssertionModal } = useChallengeState();
|
||||
|
||||
const isOpen = !!openAssertion;
|
||||
|
||||
const { writeContractAsync: writeOOContractAsync } = useScaffoldWriteContract({
|
||||
contractName: "OptimisticOracle",
|
||||
});
|
||||
|
||||
const { writeContractAsync: writeDeciderContractAsync } = useScaffoldWriteContract({
|
||||
contractName: "Decider",
|
||||
});
|
||||
|
||||
const handleAction = async (args: any) => {
|
||||
if (!openAssertion) return;
|
||||
|
||||
try {
|
||||
setIsActionPending(true);
|
||||
if (args.functionName === "settleDispute") {
|
||||
await writeDeciderContractAsync(args);
|
||||
} else {
|
||||
await writeOOContractAsync(args);
|
||||
}
|
||||
refetchAssertionStates();
|
||||
closeAssertionModal();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsActionPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!openAssertion) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<input type="checkbox" id="challenge-modal" className="modal-toggle" checked={isOpen} readOnly />
|
||||
<label htmlFor="challenge-modal" className="modal cursor-pointer" onClick={closeAssertionModal}>
|
||||
<label
|
||||
className="modal-box relative max-w-2xl w-full bg-base-100"
|
||||
htmlFor=""
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* dummy input to capture event onclick on modal box */}
|
||||
<input className="h-0 w-0 absolute top-0 left-0" />
|
||||
|
||||
{/* Close button */}
|
||||
<button onClick={closeAssertionModal} className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="">
|
||||
{/* Header with Current State */}
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-lg">
|
||||
Current State: <span className="font-bold">{getStateName(openAssertion.state)}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Description assertion={openAssertion} />
|
||||
|
||||
{openAssertion.state === 1 && (
|
||||
<>
|
||||
{/* Proposed Outcome Section */}
|
||||
<div className="rounded-lg p-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<span className="font-medium">Propose Outcome</span>
|
||||
</div>
|
||||
{isActionPending && <span className="loading loading-spinner loading-xs"></span>}
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
className="btn btn-primary flex-1"
|
||||
onClick={() =>
|
||||
handleAction({
|
||||
functionName: "proposeOutcome",
|
||||
args: [BigInt(openAssertion.assertionId), true],
|
||||
value: openAssertion.bond,
|
||||
})
|
||||
}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
True
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary flex-1"
|
||||
onClick={() =>
|
||||
handleAction({
|
||||
functionName: "proposeOutcome",
|
||||
args: [BigInt(openAssertion.assertionId), false],
|
||||
value: openAssertion.bond,
|
||||
})
|
||||
}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
False
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{openAssertion.state === 2 && (
|
||||
<div className="rounded-lg p-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<span className="font-medium">Submit Dispute</span>
|
||||
</div>
|
||||
|
||||
{isActionPending && <span className="loading loading-spinner loading-xs"></span>}
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
className="btn btn-primary flex-1"
|
||||
onClick={() =>
|
||||
handleAction({
|
||||
functionName: "disputeOutcome",
|
||||
args: [BigInt(openAssertion.assertionId)],
|
||||
value: openAssertion.bond,
|
||||
})
|
||||
}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
{!openAssertion.proposedOutcome ? "True" : "False"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{openAssertion.state === 3 && (
|
||||
<div className="rounded-lg p-4">
|
||||
<div className="flex flex-col items-center gap-2 mb-4">
|
||||
<span className="text-2xl font-medium">Impersonate Decider</span>
|
||||
<span className="font-medium">Resolve Answer to</span>
|
||||
</div>
|
||||
{isActionPending && <span className="loading loading-spinner loading-xs"></span>}
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
className="btn btn-primary flex-1"
|
||||
onClick={() =>
|
||||
handleAction({
|
||||
functionName: "settleDispute",
|
||||
args: [BigInt(openAssertion.assertionId), true],
|
||||
})
|
||||
}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
True
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary flex-1"
|
||||
onClick={() =>
|
||||
handleAction({
|
||||
functionName: "settleDispute",
|
||||
args: [BigInt(openAssertion.assertionId), false],
|
||||
})
|
||||
}
|
||||
disabled={isActionPending}
|
||||
>
|
||||
False
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
};
|
||||
48
packages/nextjs/components/oracle/optimistic/DisputedRow.tsx
Normal file
48
packages/nextjs/components/oracle/optimistic/DisputedRow.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Address } from "@scaffold-ui/components";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import { useScaffoldReadContract } from "~~/hooks/scaffold-eth";
|
||||
import { useChallengeState } from "~~/services/store/challengeStore";
|
||||
|
||||
export const DisputedRow = ({ assertionId, state }: { assertionId: number; state: number }) => {
|
||||
const { openAssertionModal } = useChallengeState();
|
||||
|
||||
const { data: assertionData } = useScaffoldReadContract({
|
||||
contractName: "OptimisticOracle",
|
||||
functionName: "getAssertion",
|
||||
args: [BigInt(assertionId)],
|
||||
});
|
||||
|
||||
if (!assertionData) return null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={assertionId}
|
||||
onClick={() => {
|
||||
openAssertionModal({ ...assertionData, assertionId, state });
|
||||
}}
|
||||
className={`group border-b border-base-300 cursor-pointer`}
|
||||
>
|
||||
{/* Description Column */}
|
||||
<td>
|
||||
<div className="group-hover:text-error">{assertionData.description}</div>
|
||||
</td>
|
||||
|
||||
{/* Proposer Column */}
|
||||
<td>
|
||||
<Address address={assertionData.proposer} format="short" onlyEnsOrAddress disableAddressLink size="sm" />
|
||||
</td>
|
||||
|
||||
{/* Disputer Column */}
|
||||
<td>
|
||||
<Address address={assertionData.disputer} format="short" onlyEnsOrAddress disableAddressLink size="sm" />
|
||||
</td>
|
||||
|
||||
{/* Chevron Column */}
|
||||
<td className="">
|
||||
<div className="w-6 h-6 rounded-full border-error border flex items-center justify-center hover:bg-base-200 group-hover:bg-error transition-colors mx-auto">
|
||||
<ChevronRightIcon className="w-4 h-4 text-error group-hover:text-white stroke-2 transition-colors" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { OOTableProps } from "../types";
|
||||
import { DisputedRow } from "./DisputedRow";
|
||||
import { EmptyRow } from "./EmptyRow";
|
||||
|
||||
export const DisputedTable = ({ assertions }: OOTableProps) => {
|
||||
return (
|
||||
<div className="bg-base-100 rounded-lg shadow-lg overflow-x-auto">
|
||||
<table className="w-full table-auto [&_th]:px-6 [&_th]:py-4 [&_td]:px-6 [&_td]:py-4">
|
||||
{/* Header */}
|
||||
<thead>
|
||||
<tr className="bg-base-300">
|
||||
<th className="text-left font-semibold w-5/12">Description</th>
|
||||
<th className="text-left font-semibold w-3/12">Proposer</th>
|
||||
<th className="text-left font-semibold w-3/12">Disputer</th>
|
||||
<th className="text-left font-semibold w-1/12">{/* Icon column */}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{assertions.length > 0 ? (
|
||||
assertions.map(assertion => (
|
||||
<DisputedRow key={assertion.assertionId} assertionId={assertion.assertionId} state={assertion.state} />
|
||||
))
|
||||
) : (
|
||||
<EmptyRow colspan={4} />
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
15
packages/nextjs/components/oracle/optimistic/EmptyRow.tsx
Normal file
15
packages/nextjs/components/oracle/optimistic/EmptyRow.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const EmptyRow = ({
|
||||
message = "No assertions match this state.",
|
||||
colspan = 4,
|
||||
}: {
|
||||
message?: string;
|
||||
colspan?: number;
|
||||
}) => {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colspan} className="text-center">
|
||||
{message}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
62
packages/nextjs/components/oracle/optimistic/ExpiredRow.tsx
Normal file
62
packages/nextjs/components/oracle/optimistic/ExpiredRow.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { Address } from "@scaffold-ui/components";
|
||||
import { formatEther } from "viem";
|
||||
import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
|
||||
|
||||
export const ExpiredRow = ({ assertionId }: { assertionId: number }) => {
|
||||
const [isClaiming, setIsClaiming] = useState(false);
|
||||
|
||||
const { data: assertionData } = useScaffoldReadContract({
|
||||
contractName: "OptimisticOracle",
|
||||
functionName: "getAssertion",
|
||||
args: [BigInt(assertionId)],
|
||||
});
|
||||
|
||||
const { writeContractAsync } = useScaffoldWriteContract({
|
||||
contractName: "OptimisticOracle",
|
||||
});
|
||||
|
||||
const handleClaim = async () => {
|
||||
setIsClaiming(true);
|
||||
try {
|
||||
await writeContractAsync({
|
||||
functionName: "claimRefund",
|
||||
args: [BigInt(assertionId)],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsClaiming(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!assertionData) return null;
|
||||
|
||||
return (
|
||||
<tr key={assertionId} className={`border-b border-base-300`}>
|
||||
{/* Description Column */}
|
||||
<td>{assertionData.description}</td>
|
||||
|
||||
{/* Asserter Column */}
|
||||
<td>
|
||||
<Address address={assertionData.asserter} format="short" onlyEnsOrAddress disableAddressLink size="sm" />
|
||||
</td>
|
||||
|
||||
{/* Reward Column */}
|
||||
<td>{formatEther(assertionData.reward)} ETH</td>
|
||||
|
||||
{/* Claimed Column */}
|
||||
<td>
|
||||
{assertionData?.claimed ? (
|
||||
<button className="btn btn-primary btn-xs" disabled>
|
||||
Claimed
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary btn-xs" onClick={handleClaim} disabled={isClaiming}>
|
||||
Claim
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { OOTableProps } from "../types";
|
||||
import { EmptyRow } from "./EmptyRow";
|
||||
import { ExpiredRow } from "./ExpiredRow";
|
||||
|
||||
export const ExpiredTable = ({ assertions }: OOTableProps) => {
|
||||
return (
|
||||
<div className="bg-base-100 rounded-lg shadow-lg overflow-x-auto">
|
||||
<table className="w-full table-auto [&_th]:px-6 [&_th]:py-4 [&_td]:px-6 [&_td]:py-4">
|
||||
{/* Header */}
|
||||
<thead>
|
||||
<tr className="bg-base-300">
|
||||
<th className="text-left font-semibold w-5/12">Description</th>
|
||||
<th className="text-left font-semibold w-3/12">Asserter</th>
|
||||
<th className="text-left font-semibold w-2/12">Reward</th>
|
||||
<th className="text-left font-semibold w-2/12">Claim Refund</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{assertions.length > 0 ? (
|
||||
assertions.map(assertion => <ExpiredRow key={assertion.assertionId} assertionId={assertion.assertionId} />)
|
||||
) : (
|
||||
<EmptyRow colspan={4} />
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
21
packages/nextjs/components/oracle/optimistic/LoadingRow.tsx
Normal file
21
packages/nextjs/components/oracle/optimistic/LoadingRow.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export const LoadingRow = () => {
|
||||
return (
|
||||
<tr className="border-b border-base-300">
|
||||
<td>
|
||||
<div className="h-5 bg-base-300 rounded animate-pulse"></div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div className="h-5 w-24 bg-base-300 rounded animate-pulse"></div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div className="h-5 w-24 bg-base-300 rounded animate-pulse"></div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div className="w-6 h-6 rounded-full bg-base-300 animate-pulse mx-auto"></div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
52
packages/nextjs/components/oracle/optimistic/ProposedRow.tsx
Normal file
52
packages/nextjs/components/oracle/optimistic/ProposedRow.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { OORowProps } from "../types";
|
||||
import { TimeLeft } from "./TimeLeft";
|
||||
import { formatEther } from "viem";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import { useScaffoldReadContract } from "~~/hooks/scaffold-eth";
|
||||
import { useChallengeState } from "~~/services/store/challengeStore";
|
||||
|
||||
export const ProposedRow = ({ assertionId, state }: OORowProps) => {
|
||||
const { openAssertionModal } = useChallengeState();
|
||||
const { data: assertionData } = useScaffoldReadContract({
|
||||
contractName: "OptimisticOracle",
|
||||
functionName: "getAssertion",
|
||||
args: [BigInt(assertionId)],
|
||||
});
|
||||
|
||||
if (!assertionData) return null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={assertionId}
|
||||
className={`group border-b border-base-300 cursor-pointer`}
|
||||
onClick={() => {
|
||||
openAssertionModal({ ...assertionData, assertionId, state });
|
||||
}}
|
||||
>
|
||||
{/* Query Column */}
|
||||
<td>
|
||||
<div className="group-hover:text-error">{assertionData?.description}</div>
|
||||
</td>
|
||||
|
||||
{/* Bond Column */}
|
||||
<td>{formatEther(assertionData?.bond)} ETH</td>
|
||||
|
||||
{/* Proposal Column */}
|
||||
<td>{assertionData?.proposedOutcome ? "True" : "False"}</td>
|
||||
|
||||
{/* Challenge Period Column */}
|
||||
<td>
|
||||
<TimeLeft startTime={assertionData?.startTime} endTime={assertionData?.endTime} />
|
||||
</td>
|
||||
|
||||
{/* Chevron Column */}
|
||||
<td>
|
||||
<div className="w-6 h-6 rounded-full border-error border flex items-center justify-center hover:bg-base-200 group-hover:bg-error transition-colors mx-auto">
|
||||
<ChevronRightIcon className="w-4 h-4 text-error group-hover:text-white stroke-2 transition-colors" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { OOTableProps } from "../types";
|
||||
import { EmptyRow } from "./EmptyRow";
|
||||
import { ProposedRow } from "./ProposedRow";
|
||||
|
||||
export const ProposedTable = ({ assertions }: OOTableProps) => {
|
||||
return (
|
||||
<div className="bg-base-100 rounded-lg shadow-lg overflow-x-auto">
|
||||
<table className="w-full table-auto [&_th]:px-6 [&_th]:py-4 [&_td]:px-6 [&_td]:py-4">
|
||||
{/* Header */}
|
||||
<thead>
|
||||
<tr className="bg-base-300">
|
||||
<th className="text-left font-semibold w-5/12">Description</th>
|
||||
<th className="text-left font-semibold w-2/12">Bond</th>
|
||||
<th className="text-left font-semibold w-2/12">Proposal</th>
|
||||
<th className="text-left font-semibold w-2/12">Time Left</th>
|
||||
<th className="text-left font-semibold w-1/12">{/* Icon column */}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{assertions.length > 0 ? (
|
||||
assertions.map(assertion => (
|
||||
<ProposedRow key={assertion.assertionId} assertionId={assertion.assertionId} state={assertion.state} />
|
||||
))
|
||||
) : (
|
||||
<EmptyRow colspan={5} />
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user