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