Initial commit with 🏗️ create-eth @ 2.0.4

This commit is contained in:
han
2026-01-23 20:20:58 +07:00
commit b330aba2b4
185 changed files with 36981 additions and 0 deletions

View 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
View 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

View 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
}
}
]
}

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

View 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) {}
}

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

View 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) {}
}

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

View 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) {}
}

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

View 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"];

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

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

View 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",
},
],
},
},
]);

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

View 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"
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,2 @@
# Write tests for your smart contract in this directory
# Example: YourContract.ts

View 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",
);
});
});
});
});

View 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, its 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);
});
});
});

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

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}