diff --git a/contracts/src/StakeTable.sol b/contracts/src/StakeTable.sol index 54b3a6cdc5..e48e450d09 100644 --- a/contracts/src/StakeTable.sol +++ b/contracts/src/StakeTable.sol @@ -6,11 +6,12 @@ import { BLSSig } from "./libraries/BLSSig.sol"; import { AbstractStakeTable } from "./interfaces/AbstractStakeTable.sol"; import { LightClient } from "../src/LightClient.sol"; import { EdOnBN254 } from "./libraries/EdOnBn254.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; using EdOnBN254 for EdOnBN254.EdOnBN254Point; /// @title Implementation of the Stake Table interface -contract StakeTable is AbstractStakeTable { +contract StakeTable is AbstractStakeTable, Ownable { /// Error to notify restaking is not implemented yet. error RestakingNotImplemented(); @@ -64,6 +65,15 @@ contract StakeTable is AbstractStakeTable { // Error raised when zero point keys are provided error NoKeyChange(); + /// Error raised when the caller is not the owner + error Unauthorized(); + + /// Error raised when the light client address is invalid + error InvalidAddress(); + + /// Error raised when the value is invalid + error InvalidValue(); + /// Mapping from a hash of a BLS key to a node struct defined in the abstract contract. mapping(address account => Node node) public nodes; @@ -92,7 +102,16 @@ contract StakeTable is AbstractStakeTable { uint64 public maxChurnRate; - constructor(address _tokenAddress, address _lightClientAddress, uint64 churnRate) { + uint256 public minStakeAmount; + + /// TODO change constructor to initialize function when we make the contract upgradeable + constructor( + address _tokenAddress, + address _lightClientAddress, + uint64 churnRate, + uint256 _minStakeAmount, + address initialOwner + ) Ownable(initialOwner) { tokenAddress = _tokenAddress; lightClient = LightClient(_lightClientAddress); @@ -105,6 +124,8 @@ contract StakeTable is AbstractStakeTable { // It is not possible to exit during the first epoch. firstAvailableExitEpoch = 1; _numPendingExits = 0; + + minStakeAmount = _minStakeAmount; } /// @dev Computes a hash value of some G2 point. @@ -269,10 +290,8 @@ contract StakeTable is AbstractStakeTable { BN254.G1Point memory blsSig, uint64 validUntilEpoch ) external override { - uint256 fixedStakeAmount = minStakeAmount(); - // Verify that the sender amount is the minStakeAmount - if (amount < fixedStakeAmount) { + if (amount < minStakeAmount) { revert InsufficientStakeAmount(amount); } @@ -285,13 +304,13 @@ contract StakeTable is AbstractStakeTable { // Verify that this contract has permissions to access the validator's stake token. uint256 allowance = ERC20(tokenAddress).allowance(msg.sender, address(this)); - if (allowance < fixedStakeAmount) { - revert InsufficientAllowance(allowance, fixedStakeAmount); + if (allowance < amount) { + revert InsufficientAllowance(allowance, amount); } // Verify that the validator has the balance for this stake token. uint256 balance = ERC20(tokenAddress).balanceOf(msg.sender); - if (balance < fixedStakeAmount) { + if (balance < amount) { revert InsufficientBalance(balance); } @@ -330,23 +349,21 @@ contract StakeTable is AbstractStakeTable { appendRegistrationQueue(registerEpoch, queueSize); // Transfer the stake amount of ERC20 tokens from the sender to this contract. - SafeTransferLib.safeTransferFrom( - ERC20(tokenAddress), msg.sender, address(this), fixedStakeAmount - ); + SafeTransferLib.safeTransferFrom(ERC20(tokenAddress), msg.sender, address(this), amount); // Update the total staked amount - totalStake += fixedStakeAmount; + totalStake += amount; // Create an entry for the node. node.account = msg.sender; - node.balance = fixedStakeAmount; + node.balance = amount; node.blsVK = blsVK; node.schnorrVK = schnorrVK; node.registerEpoch = registerEpoch; nodes[msg.sender] = node; - emit Registered(msg.sender, registerEpoch, fixedStakeAmount); + emit Registered(msg.sender, registerEpoch, amount); } /// @notice Deposit more stakes to registered keys @@ -524,10 +541,30 @@ contract StakeTable is AbstractStakeTable { emit UpdatedConsensusKeys(msg.sender, node.blsVK, node.schnorrVK); } - /// @notice Minimum stake amount - /// @return Minimum stake amount - /// TODO: This value should be a variable modifiable by admin - function minStakeAmount() public pure returns (uint256) { - return 10 ether; + /// @notice Update the min stake amount + /// @dev The min stake amount cannot be set to zero + /// @param _minStakeAmount The new min stake amount + function updateMinStakeAmount(uint256 _minStakeAmount) external onlyOwner { + if (_minStakeAmount == 0) revert InvalidValue(); + minStakeAmount = _minStakeAmount; + emit MinStakeAmountUpdated(minStakeAmount); + } + + /// @notice Update the max churn rate + /// @dev The max churn rate cannot be set to zero + /// @param _maxChurnRate The new max churn rate + function updateMaxChurnRate(uint64 _maxChurnRate) external onlyOwner { + if (_maxChurnRate == 0) revert InvalidValue(); + maxChurnRate = _maxChurnRate; + emit MaxChurnRateUpdated(maxChurnRate); + } + + /// @notice Update the light client address + /// @dev The light client address cannot be set to the zero address + /// @param _lightClientAddress The new light client address + function updateLightClientAddress(address _lightClientAddress) external onlyOwner { + if (_lightClientAddress == address(0)) revert InvalidAddress(); + lightClient = LightClient(_lightClientAddress); + emit LightClientAddressUpdated(_lightClientAddress); } } diff --git a/contracts/src/interfaces/AbstractStakeTable.sol b/contracts/src/interfaces/AbstractStakeTable.sol index a73f285a2a..a5563202d9 100644 --- a/contracts/src/interfaces/AbstractStakeTable.sol +++ b/contracts/src/interfaces/AbstractStakeTable.sol @@ -47,6 +47,18 @@ abstract contract AbstractStakeTable { address account, BN254.G2Point newBlsVK, EdOnBN254.EdOnBN254Point newSchnorrVK ); + /// @notice Signals the min stake amount has been updated + /// @param minStakeAmount the new min stake amount + event MinStakeAmountUpdated(uint256 minStakeAmount); + + /// @notice Signals the max churn rate has been updated + /// @param maxChurnRate the new max churn rate + event MaxChurnRateUpdated(uint256 maxChurnRate); + + /// @notice Signals the light client address has been updated + /// @param lightClientAddress the new light client address + event LightClientAddressUpdated(address lightClientAddress); + /// @dev (sadly, Solidity doesn't support type alias on non-primitive types) // We avoid declaring another struct even if the type info helps with readability, // extra layer of struct introduces overhead and more gas cost. diff --git a/contracts/test/StakeTable.t.sol b/contracts/test/StakeTable.t.sol index 414b86c22e..434a31f683 100644 --- a/contracts/test/StakeTable.t.sol +++ b/contracts/test/StakeTable.t.sol @@ -17,6 +17,7 @@ import { EdOnBN254 } from "../src/libraries/EdOnBn254.sol"; import { AbstractStakeTable } from "../src/interfaces/AbstractStakeTable.sol"; import { LightClient } from "../src/LightClient.sol"; import { LightClientMock } from "../test/mocks/LightClientMock.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; // Token contract import { ExampleToken } from "../src/ExampleToken.sol"; @@ -29,6 +30,7 @@ contract StakeTable_register_Test is Test { ExampleToken public token; LightClientMock public lcMock; uint256 public constant INITIAL_BALANCE = 10 ether; + uint256 public constant MIN_STAKE_AMOUNT = 10 ether; address public exampleTokenCreator; function genClientWallet(address sender, string memory seed) @@ -77,7 +79,8 @@ contract StakeTable_register_Test is Test { lcMock = new LightClientMock(genesis, genesisStakeTableState, 864000); address lightClientAddress = address(lcMock); - stakeTable = new S(address(token), lightClientAddress, 10); + stakeTable = + new S(address(token), lightClientAddress, 10, MIN_STAKE_AMOUNT, exampleTokenCreator); } function testFuzz_RevertWhen_InvalidBLSSig(uint256 scalar) external { @@ -203,8 +206,8 @@ contract StakeTable_register_Test is Test { vm.stopPrank(); } - function test_RevertWhen_WrongStakeAmount() external { - uint64 depositAmount = 5 ether; + function test_RevertWhen_InsufficientStakeAmount() external { + uint64 depositAmount = uint64(stakeTable.minStakeAmount()) - 1; uint64 validUntilEpoch = 10; string memory seed = "123"; @@ -745,4 +748,104 @@ contract StakeTable_register_Test is Test { stakeTable.withdrawFunds(); vm.stopPrank(); } + + // test set admin succeeds + function test_setAdmin_succeeds() public { + vm.prank(exampleTokenCreator); + vm.expectEmit(false, false, false, true, address(stakeTable)); + emit Ownable.OwnershipTransferred(exampleTokenCreator, makeAddr("admin")); + stakeTable.transferOwnership(makeAddr("admin")); + assertEq(stakeTable.owner(), makeAddr("admin")); + } + + // test set admin fails if not admin or invalid admin address + function test_revertWhen_setAdmin_NotAdminOrInvalidAdminAddress() public { + vm.startPrank(makeAddr("randomUser")); + vm.expectRevert( + abi.encodeWithSelector( + Ownable.OwnableUnauthorizedAccount.selector, makeAddr("randomUser") + ) + ); + stakeTable.transferOwnership(makeAddr("admin")); + vm.stopPrank(); + + vm.prank(exampleTokenCreator); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0))); + stakeTable.transferOwnership(address(0)); + } + + // test update min stake amount succeeds + function test_updateMinStakeAmount_succeeds() public { + vm.prank(exampleTokenCreator); + vm.expectEmit(false, false, false, true, address(stakeTable)); + emit AbstractStakeTable.MinStakeAmountUpdated(10 ether); + stakeTable.updateMinStakeAmount(10 ether); + assertEq(stakeTable.minStakeAmount(), 10 ether); + } + + // test update min stake amount fails if not admin or invalid stake amount + function test_revertWhen_updateMinStakeAmount_NotAdminOrInvalidStakeAmount() public { + vm.startPrank(makeAddr("randomUser")); + vm.expectRevert( + abi.encodeWithSelector( + Ownable.OwnableUnauthorizedAccount.selector, makeAddr("randomUser") + ) + ); + stakeTable.updateMinStakeAmount(10 ether); + vm.stopPrank(); + + vm.prank(exampleTokenCreator); + vm.expectRevert(S.InvalidValue.selector); + stakeTable.updateMinStakeAmount(0); + } + + // test update max churn rate succeeds + function test_updateMaxChurnRate_succeeds() public { + vm.prank(exampleTokenCreator); + vm.expectEmit(false, false, false, true, address(stakeTable)); + emit AbstractStakeTable.MaxChurnRateUpdated(10); + stakeTable.updateMaxChurnRate(10); + assertEq(stakeTable.maxChurnRate(), 10); + } + + // test update max churn rate fails if not admin or invalid churn amount + function test_revertWhen_updateMaxChurnRate_NotAdminOrInvalidChurnAmount() public { + vm.startPrank(makeAddr("randomUser")); + vm.expectRevert( + abi.encodeWithSelector( + Ownable.OwnableUnauthorizedAccount.selector, makeAddr("randomUser") + ) + ); + stakeTable.updateMaxChurnRate(10); + vm.stopPrank(); + + vm.prank(exampleTokenCreator); + vm.expectRevert(S.InvalidValue.selector); + stakeTable.updateMaxChurnRate(0); + } + + // test update light client address succeeds + function test_updateLightClientAddress_succeeds() public { + vm.prank(exampleTokenCreator); + vm.expectEmit(false, false, false, true, address(stakeTable)); + emit AbstractStakeTable.LightClientAddressUpdated(makeAddr("lightClient")); + stakeTable.updateLightClientAddress(makeAddr("lightClient")); + assertEq(address(stakeTable.lightClient()), makeAddr("lightClient")); + } + + // test update light client address fails if not admin or bad address + function test_revertWhen_updateLightClientAddress_NotAdminOrBadAddress() public { + vm.startPrank(makeAddr("randomUser")); + vm.expectRevert( + abi.encodeWithSelector( + Ownable.OwnableUnauthorizedAccount.selector, makeAddr("randomUser") + ) + ); + stakeTable.updateLightClientAddress(makeAddr("lightClient")); + vm.stopPrank(); + + vm.prank(exampleTokenCreator); + vm.expectRevert(S.InvalidAddress.selector); + stakeTable.updateLightClientAddress(address(0)); + } }