diff --git a/contracts/Chaosnet.sol b/contracts/Chaosnet.sol new file mode 100644 index 0000000..bdf5523 --- /dev/null +++ b/contracts/Chaosnet.sol @@ -0,0 +1,78 @@ +pragma solidity 0.8.9; + +/// @title Chaosnet +/// @notice This is a beta staker program for stakers willing to go the extra +/// mile with monitoring, share their logs with the dev team, and allow to more +/// carefully monitor the bootstrapping network. As the network matures, the +/// beta program will be ended. +contract Chaosnet { + /// @notice Indicates if the chaosnet is active. The chaosnet is active + /// after the contract deployment and can be ended with a call to + /// `deactivateChaosnet()`. Once deactivated chaosnet can not be activated + /// again. + bool public isChaosnetActive; + + /// @notice Indicates if the given operator is a beta operator for chaosnet. + mapping(address => bool) public isBetaOperator; + + /// @notice Address controlling chaosnet status and beta operator addresses. + address public chaosnetMaestro; + + event BetaOperatorsAdded(address[] operators); + + event ChaosnetMaestroRoleTransferred( + address oldChaosnetMaestro, + address newChaosnetMaestro + ); + + event ChaosnetDeactivated(); + + constructor() { + _transferChaosnetMaestro(msg.sender); + isChaosnetActive = true; + } + + modifier onlyChaosnetMaestro() { + require(msg.sender == chaosnetMaestro, "Not the chaosnet maestro"); + _; + } + + /// @notice Adds beta operator to chaosnet. Can be called only by the + /// chaosnet maestro. + function addBetaOperators(address[] calldata operators) + public + onlyChaosnetMaestro + { + for (uint256 i = 0; i < operators.length; i++) { + isBetaOperator[operators[i]] = true; + } + + emit BetaOperatorsAdded(operators); + } + + /// @notice Deactivates the chaosnet. Can be called only by the chaosnet + /// maestro. Once deactivated chaosnet can not be activated again. + function deactivateChaosnet() public onlyChaosnetMaestro { + require(isChaosnetActive, "Chaosnet is not active"); + isChaosnetActive = false; + emit ChaosnetDeactivated(); + } + + /// @notice Transfers the chaosnet maestro role to another non-zero address. + function transferChaosnetMaestroRole(address newChaosnetMaestro) + public + onlyChaosnetMaestro + { + require( + newChaosnetMaestro != address(0), + "New chaosnet maestro must not be zero address" + ); + _transferChaosnetMaestro(newChaosnetMaestro); + } + + function _transferChaosnetMaestro(address newChaosnetMaestro) internal { + address oldChaosnetMaestro = chaosnetMaestro; + chaosnetMaestro = newChaosnetMaestro; + emit ChaosnetMaestroRoleTransferred(oldChaosnetMaestro, newChaosnetMaestro); + } +} diff --git a/contracts/SortitionPool.sol b/contracts/SortitionPool.sol index e7139c5..1ffd107 100644 --- a/contracts/SortitionPool.sol +++ b/contracts/SortitionPool.sol @@ -8,12 +8,19 @@ import "@openzeppelin/contracts/access/Ownable.sol"; import "./RNG.sol"; import "./SortitionTree.sol"; import "./Rewards.sol"; +import "./Chaosnet.sol"; /// @title Sortition Pool /// @notice A logarithmic data structure used to store the pool of eligible /// operators weighted by their stakes. It allows to select a group of operators /// based on the provided pseudo-random seed. -contract SortitionPool is SortitionTree, Rewards, Ownable, IReceiveApproval { +contract SortitionPool is + SortitionTree, + Rewards, + Ownable, + Chaosnet, + IReceiveApproval +{ using Branch for uint256; using Leaf for uint256; using Position for uint256; @@ -98,7 +105,9 @@ contract SortitionPool is SortitionTree, Rewards, Ownable, IReceiveApproval { } /// @notice Inserts an operator to the pool. Reverts if the operator is - /// already present. + /// already present. Reverts if the operator is not eligible because of their + /// authorized stake. Reverts if the chaosnet is active and the operator is + /// not a beta operator. /// @dev Can be called only by the contract owner. /// @param operator Address of the inserted operator. /// @param authorizedStake Inserted operator's authorized stake for the application. @@ -110,6 +119,10 @@ contract SortitionPool is SortitionTree, Rewards, Ownable, IReceiveApproval { uint256 weight = getWeight(authorizedStake); require(weight > 0, "Operator not eligible"); + if (isChaosnetActive) { + require(isBetaOperator[operator], "Not beta operator for chaosnet"); + } + _insertOperator(operator, weight); uint32 id = getOperatorID(operator); Rewards.updateOperatorRewards(id, uint32(weight)); diff --git a/test/sortitionPoolTest.js b/test/sortitionPoolTest.js index c6ce250..177a9f2 100644 --- a/test/sortitionPoolTest.js +++ b/test/sortitionPoolTest.js @@ -2,6 +2,8 @@ const chai = require("chai") const expect = chai.expect const { ethers, helpers } = require("hardhat") +const { ZERO_ADDRESS } = require("@openzeppelin/test-helpers/src/constants") + describe("SortitionPool", () => { const seed = "0xff39d6cca87853892d2854566e883008bc000000000000000000000000000000" @@ -19,8 +21,17 @@ describe("SortitionPool", () => { let bobBeneficiary beforeEach(async () => { - ;[deployer, owner, alice, bob, carol, aliceBeneficiary, bobBeneficiary] = - await ethers.getSigners() + ;[ + deployer, + owner, + chaosnetMaestro, + alice, + bob, + carol, + aliceBeneficiary, + bobBeneficiary, + thirdParty, + ] = await ethers.getSigners() const TokenStub = await ethers.getContractFactory("TokenStub") token = await TokenStub.deploy() @@ -31,6 +42,15 @@ describe("SortitionPool", () => { await pool.deployed() await pool.connect(deployer).transferOwnership(owner.address) + await pool + .connect(deployer) + .transferChaosnetMaestroRole(chaosnetMaestro.address) + }) + + describe("constructor", () => { + it("should activate chaosnet", async () => { + expect(await pool.isChaosnetActive()).to.be.true + }) }) describe("lock", () => { @@ -82,25 +102,88 @@ describe("SortitionPool", () => { describe("insertOperator", () => { context("when sortition pool is unlocked", () => { context("when called by the owner", () => { - context("when operator is eligible", () => { - beforeEach(async () => { - await pool - .connect(owner) - .insertOperator(alice.address, poolWeightDivisor) + context("when chaosnet is active", () => { + context("when operator is eligible", () => { + context("when operator is not beta operator", () => { + it("should revert", async () => { + await expect( + pool + .connect(owner) + .insertOperator(alice.address, poolWeightDivisor), + ).to.be.revertedWith("Not beta operator for chaosnet") + }) + }) + + context("when operator is beta operator", () => { + beforeEach(async () => { + await pool + .connect(chaosnetMaestro) + .addBetaOperators([alice.address]) + await pool + .connect(owner) + .insertOperator(alice.address, poolWeightDivisor) + }) + + it("should insert the operator to the pool", async () => { + expect(await pool.isOperatorInPool(alice.address)).to.be.true + }) + }) }) - it("should insert the operator to the pool", async () => { - expect(await pool.isOperatorInPool(alice.address)).to.be.true + context("when operator is not eligible", () => { + context("when operator is not beta operator", () => { + it("should revert", async () => { + await expect( + pool + .connect(owner) + .insertOperator(alice.address, poolWeightDivisor - 1), + ).to.be.revertedWith("Operator not eligible") + }) + }) + + context("when operator is beta operator", () => { + beforeEach(async () => { + await pool + .connect(chaosnetMaestro) + .addBetaOperators([alice.address]) + }) + + it("should revert", async () => { + await expect( + pool + .connect(owner) + .insertOperator(alice.address, poolWeightDivisor - 1), + ).to.be.revertedWith("Operator not eligible") + }) + }) }) }) - context("when operator is not eligible", () => { - it("should revert", async () => { - await expect( - pool + context("when chaosnet is not active", () => { + beforeEach(async () => { + await pool.connect(chaosnetMaestro).deactivateChaosnet() + }) + + context("when operator is eligible", () => { + beforeEach(async () => { + await pool .connect(owner) - .insertOperator(alice.address, poolWeightDivisor - 1), - ).to.be.revertedWith("Operator not eligible") + .insertOperator(alice.address, poolWeightDivisor) + }) + + it("should insert the operator to the pool", async () => { + expect(await pool.isOperatorInPool(alice.address)).to.be.true + }) + }) + + context("when operator is not eligible", () => { + it("should revert", async () => { + await expect( + pool + .connect(owner) + .insertOperator(alice.address, poolWeightDivisor - 1), + ).to.be.revertedWith("Operator not eligible") + }) }) }) }) @@ -131,6 +214,7 @@ describe("SortitionPool", () => { describe("updateOperatorStatus", () => { beforeEach(async () => { + await pool.connect(chaosnetMaestro).deactivateChaosnet() await pool.connect(owner).insertOperator(alice.address, 2000) }) @@ -185,6 +269,10 @@ describe("SortitionPool", () => { }) describe("selectGroup", async () => { + beforeEach(async () => { + await pool.connect(chaosnetMaestro).deactivateChaosnet() + }) + context("when sortition pool is locked", () => { beforeEach(async () => {}) @@ -259,6 +347,10 @@ describe("SortitionPool", () => { }) describe("pool rewards", async () => { + beforeEach(async () => { + await pool.connect(chaosnetMaestro).deactivateChaosnet() + }) + async function withdrawRewards( pool, owner, @@ -436,4 +528,128 @@ describe("SortitionPool", () => { expect(group.length).to.equal(100) }) }) + + describe("addBetaOperators", () => { + context("when called by third party", () => { + it("should revert", async () => { + await expect( + pool.connect(thirdParty).addBetaOperators([alice.address]), + ).to.be.revertedWith("Not the chaosnet maestro") + }) + }) + + context("when called by the operator", () => { + it("should revert", async () => { + await expect( + pool.connect(alice).addBetaOperators([alice.address]), + ).to.be.revertedWith("Not the chaosnet maestro") + }) + }) + + context("when called by the chaosnet maestro", () => { + let tx + + beforeEach(async () => { + tx = await pool + .connect(chaosnetMaestro) + .addBetaOperators([alice.address, bob.address]) + }) + + it("should set selected operators as beta operators", async () => { + expect(await pool.isBetaOperator(alice.address)).to.be.true + expect(await pool.isBetaOperator(bob.address)).to.be.true + expect(await pool.isBetaOperator(carol.address)).to.be.false + }) + + it("should emit BetaOperatorsAdded event", async () => { + await expect(tx) + .to.emit(pool, "BetaOperatorsAdded") + .withArgs([alice.address, bob.address]) + }) + }) + }) + + describe("transferChaosnetMaestroRole", () => { + context("when called by third party", () => { + it("should revert", async () => { + await expect( + pool + .connect(thirdParty) + .transferChaosnetMaestroRole(thirdParty.address), + ).to.be.revertedWith("Not the chaosnet maestro") + }) + }) + + context("when called by the current chaosnet maestro", () => { + context("when called with the new address set to zero", () => { + it("should revert", async () => { + await expect( + pool + .connect(chaosnetMaestro) + .transferChaosnetMaestroRole(ZERO_ADDRESS), + ).to.be.revertedWith("New chaosnet maestro must not be zero address") + }) + }) + + context("when called with non-zero new address", () => { + let tx + + beforeEach(async () => { + tx = await pool + .connect(chaosnetMaestro) + .transferChaosnetMaestroRole(alice.address) + }) + + it("should transfer the role", async () => { + expect(await pool.chaosnetMaestro()).to.equal(alice.address) + }) + + it("should emit ChaosnetMaestroRoleTransferred event", async () => { + await expect(tx) + .to.emit(pool, "ChaosnetMaestroRoleTransferred") + .withArgs(chaosnetMaestro.address, alice.address) + }) + }) + }) + }) + + describe("deactivate chaosnet", () => { + context("when called by third party", () => { + it("should revert", async () => { + await expect( + pool.connect(thirdParty).deactivateChaosnet(), + ).to.be.revertedWith("Not the chaosnet maestro") + }) + }) + + context("when called by the chaosnet maestro", () => { + context("when chaosnet is not active", () => { + beforeEach(async () => { + await pool.connect(chaosnetMaestro).deactivateChaosnet() + }) + + it("should revert", async () => { + await expect( + pool.connect(chaosnetMaestro).deactivateChaosnet(), + ).to.be.revertedWith("Chaosnet is not active") + }) + }) + + context("when chaosnet is active", () => { + let tx + + beforeEach(async () => { + tx = await pool.connect(chaosnetMaestro).deactivateChaosnet() + }) + + it("should deactivate chaosnet", async () => { + expect(await pool.isChaosnetActive()).to.be.false + }) + + it("should emit ChaosnetDeactivated event", async () => { + await expect(tx).to.emit(pool, "ChaosnetDeactivated") + }) + }) + }) + }) })