From 945acdb9794dab40f130e3a814bdfe628be8d883 Mon Sep 17 00:00:00 2001 From: LHerskind Date: Fri, 29 Nov 2024 17:29:01 +0000 Subject: [PATCH 1/3] feat: standalone ssd --- l1-contracts/src/core/Rollup.sol | 13 +- l1-contracts/src/core/interfaces/IStaking.sol | 49 +++++ l1-contracts/src/core/libraries/Errors.sol | 13 ++ l1-contracts/src/core/staking/Staking.sol | 177 ++++++++++++++++++ l1-contracts/test/staking/StakingCheater.sol | 27 +++ l1-contracts/test/staking/base.t.sol | 25 +++ l1-contracts/test/staking/deposit.t.sol | 167 +++++++++++++++++ l1-contracts/test/staking/deposit.tree | 20 ++ .../test/staking/finaliseWithdraw.t.sol | 85 +++++++++ .../test/staking/finaliseWithdraw.tree | 11 ++ l1-contracts/test/staking/getters.t.sol | 51 +++++ .../test/staking/initiateWithdraw.t.sol | 154 +++++++++++++++ .../test/staking/initiateWithdraw.tree | 21 +++ l1-contracts/test/staking/slash.t.sol | 174 +++++++++++++++++ l1-contracts/test/staking/slash.tree | 24 +++ 15 files changed, 1006 insertions(+), 5 deletions(-) create mode 100644 l1-contracts/src/core/interfaces/IStaking.sol create mode 100644 l1-contracts/src/core/staking/Staking.sol create mode 100644 l1-contracts/test/staking/StakingCheater.sol create mode 100644 l1-contracts/test/staking/base.t.sol create mode 100644 l1-contracts/test/staking/deposit.t.sol create mode 100644 l1-contracts/test/staking/deposit.tree create mode 100644 l1-contracts/test/staking/finaliseWithdraw.t.sol create mode 100644 l1-contracts/test/staking/finaliseWithdraw.tree create mode 100644 l1-contracts/test/staking/getters.t.sol create mode 100644 l1-contracts/test/staking/initiateWithdraw.t.sol create mode 100644 l1-contracts/test/staking/initiateWithdraw.tree create mode 100644 l1-contracts/test/staking/slash.t.sol create mode 100644 l1-contracts/test/staking/slash.tree diff --git a/l1-contracts/src/core/Rollup.sol b/l1-contracts/src/core/Rollup.sol index d1082047a185..ef98e18d6cc2 100644 --- a/l1-contracts/src/core/Rollup.sol +++ b/l1-contracts/src/core/Rollup.sol @@ -57,6 +57,8 @@ struct SubmitEpochRootProofInterimValues { uint256 endBlockNumber; Epoch epochToProve; Epoch startEpoch; + bool isFeeCanonical; + bool isRewardDistributorCanonical; } /** @@ -319,20 +321,21 @@ contract Rollup is EIP712("Aztec Rollup", "1"), Leonidas, IRollup, ITestRollup { // @note Only if the rollup is the canonical will it be able to meaningfully claim fees // Otherwise, the fees are unbacked #7938. - bool isFeeCanonical = address(this) == FEE_JUICE_PORTAL.canonicalRollup(); - bool isRewardDistributorCanonical = address(this) == REWARD_DISTRIBUTOR.canonicalRollup(); + interimValues.isFeeCanonical = address(this) == FEE_JUICE_PORTAL.canonicalRollup(); + interimValues.isRewardDistributorCanonical = + address(this) == REWARD_DISTRIBUTOR.canonicalRollup(); uint256 totalProverReward = 0; uint256 totalBurn = 0; - if (isFeeCanonical || isRewardDistributorCanonical) { + if (interimValues.isFeeCanonical || interimValues.isRewardDistributorCanonical) { for (uint256 i = 0; i < _args.epochSize; i++) { address coinbase = address(uint160(uint256(publicInputs[9 + i * 2]))); uint256 reward = 0; uint256 toProver = 0; uint256 burn = 0; - if (isFeeCanonical) { + if (interimValues.isFeeCanonical) { uint256 fees = uint256(publicInputs[10 + i * 2]); if (fees > 0) { // This is insanely expensive, and will be fixed as part of the general storage cost reduction. @@ -346,7 +349,7 @@ contract Rollup is EIP712("Aztec Rollup", "1"), Leonidas, IRollup, ITestRollup { } } - if (isRewardDistributorCanonical) { + if (interimValues.isRewardDistributorCanonical) { reward += REWARD_DISTRIBUTOR.claim(address(this)); } diff --git a/l1-contracts/src/core/interfaces/IStaking.sol b/l1-contracts/src/core/interfaces/IStaking.sol new file mode 100644 index 000000000000..ec6c78f74c7f --- /dev/null +++ b/l1-contracts/src/core/interfaces/IStaking.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + +// None -> Does not exist in our setup +// Validating -> Participating as validator +// Living -> Not participating as validator, but have funds in setup, +// hit if slashes and going below the minimum +// Exiting -> In the process of exiting the system +enum Status { + NONE, + VALIDATING, + LIVING, + EXITING +} + +struct ValidatorInfo { + uint256 stake; + address withdrawer; + address proposer; + Status status; +} + +struct OperatorInfo { + address proposer; + address attester; +} + +struct Exit { + Timestamp exitableAt; + address recipient; +} + +interface IStaking { + function deposit(address _attester, address _proposer, address _withdrawer, uint256 _amount) + external; + function initiateWithdraw(address _attester, address _recipient) external returns (bool); + function finaliseWithdraw(address _attester) external; + function slash(address _attester, uint256 _amount) external; + + function getInfo(address _attester) external view returns (ValidatorInfo memory); + function getExit(address _attester) external view returns (Exit memory); + function getActiveAttesterCount() external view returns (uint256); + function getAttesterAt(uint256 _index) external view returns (address); + function getProposerAt(uint256 _index) external view returns (address); + function getOperatorAt(uint256 _index) external view returns (OperatorInfo memory); +} diff --git a/l1-contracts/src/core/libraries/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index 85d684b77b4e..32d6e3a65ba2 100644 --- a/l1-contracts/src/core/libraries/Errors.sol +++ b/l1-contracts/src/core/libraries/Errors.sol @@ -101,6 +101,19 @@ library Errors { error Leonidas__InsufficientAttestations(uint256 minimumNeeded, uint256 provided); // 0xbf1ca4cb error Leonidas__InsufficientAttestationsProvided(uint256 minimumNeeded, uint256 provided); // 0xb3a697c2 + // Staking + error Staking__AlreadyActive(address attester); // 0x5e206fa4 + error Staking__AlreadyRegistered(address); // 0x18047699 + error Staking__CannotSlashExitedStake(address); // 0x45bf4940 + error Staking__FailedToRemove(address); // 0xa7d7baab + error Staking__InsufficientStake(uint256, uint256); // 0x903aee24 + error Staking__NoOneToSlash(address); // 0x7e2f7f1c + error Staking__NotExiting(address); // 0xef566ee0 + error Staking__NotSlasher(address, address); // 0x23a6f432 + error Staking__NotWithdrawer(address, address); // 0x8e668e5d + error Staking__NothingToExit(address); // 0xd2aac9b6 + error Staking__WithdrawalNotUnlockedYet(Timestamp, Timestamp); // 0x88e1826c + // Fee Juice Portal error FeeJuicePortal__AlreadyInitialized(); // 0xc7a172fe error FeeJuicePortal__InvalidInitialization(); // 0xfd9b3208 diff --git a/l1-contracts/src/core/staking/Staking.sol b/l1-contracts/src/core/staking/Staking.sol new file mode 100644 index 000000000000..07d96a8dc78b --- /dev/null +++ b/l1-contracts/src/core/staking/Staking.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import { + IStaking, ValidatorInfo, Exit, Status, OperatorInfo +} from "@aztec/core/interfaces/IStaking.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@oz/token/ERC20/utils/SafeERC20.sol"; +import {EnumerableSet} from "@oz/utils/structs/EnumerableSet.sol"; + +contract Staking is IStaking { + using SafeERC20 for IERC20; + using EnumerableSet for EnumerableSet.AddressSet; + + // Constant pulled out of the ass + Timestamp public constant EXIT_DELAY = Timestamp.wrap(60 * 60 * 24); + + address public immutable SLASHER; + IERC20 public immutable STAKING_ASSET; + uint256 public immutable MINIMUM_STAKE; + + // address <=> index + EnumerableSet.AddressSet internal attesters; + + mapping(address attester => ValidatorInfo) internal info; + mapping(address attester => Exit) internal exits; + + event Deposit( + address indexed attester, address indexed proposer, address indexed withdrawer, uint256 amount + ); + event WithdrawInitiated(address indexed attester, address indexed recipient, uint256 amount); + event WithdrawFinalised(address indexed attester, address indexed recipient, uint256 amount); + event Slashed(address indexed attester, uint256 amount); + + constructor(address _slasher, IERC20 _stakingAsset, uint256 _minimumStake) { + SLASHER = _slasher; + STAKING_ASSET = _stakingAsset; + MINIMUM_STAKE = _minimumStake; + } + + function deposit(address _attester, address _proposer, address _withdrawer, uint256 _amount) + external + override(IStaking) + { + require(_amount >= MINIMUM_STAKE, Errors.Staking__InsufficientStake(_amount, MINIMUM_STAKE)); + STAKING_ASSET.transferFrom(msg.sender, address(this), _amount); + require(info[_attester].status == Status.NONE, Errors.Staking__AlreadyRegistered(_attester)); + require(attesters.add(_attester), Errors.Staking__AlreadyActive(_attester)); + + // If BLS, need to check possession of private key to avoid attacks. + + info[_attester] = ValidatorInfo({ + stake: _amount, + withdrawer: _withdrawer, + proposer: _proposer, + status: Status.VALIDATING + }); + + emit Deposit(_attester, _proposer, _withdrawer, _amount); + } + + function initiateWithdraw(address _attester, address _recipient) + external + override(IStaking) + returns (bool) + { + ValidatorInfo storage validator = info[_attester]; + + require( + msg.sender == validator.withdrawer, + Errors.Staking__NotWithdrawer(validator.withdrawer, msg.sender) + ); + require( + validator.status == Status.VALIDATING || validator.status == Status.LIVING, + Errors.Staking__NothingToExit(_attester) + ); + if (validator.status == Status.VALIDATING) { + require(attesters.remove(_attester), Errors.Staking__FailedToRemove(_attester)); + } + + // Note that the "amount" is not stored here, but reusing the `validators` + // We always exit fully. + exits[_attester] = + Exit({exitableAt: Timestamp.wrap(block.timestamp) + EXIT_DELAY, recipient: _recipient}); + validator.status = Status.EXITING; + + emit WithdrawInitiated(_attester, _recipient, validator.stake); + + return true; + } + + function finaliseWithdraw(address _attester) external override(IStaking) { + ValidatorInfo storage validator = info[_attester]; + require(validator.status == Status.EXITING, Errors.Staking__NotExiting(_attester)); + + Exit storage exit = exits[_attester]; + require( + exit.exitableAt <= Timestamp.wrap(block.timestamp), + Errors.Staking__WithdrawalNotUnlockedYet(Timestamp.wrap(block.timestamp), exit.exitableAt) + ); + + uint256 amount = validator.stake; + address recipient = exit.recipient; + + delete exits[_attester]; + delete info[_attester]; + + STAKING_ASSET.transfer(recipient, amount); + + emit WithdrawFinalised(_attester, recipient, amount); + } + + function slash(address _attester, uint256 _amount) external override(IStaking) { + require(msg.sender == SLASHER, Errors.Staking__NotSlasher(SLASHER, msg.sender)); + + ValidatorInfo storage validator = info[_attester]; + require(validator.status != Status.NONE, Errors.Staking__NoOneToSlash(_attester)); + + // There is a special, case, if exiting and past the limit, it is untouchable! + require( + !( + validator.status == Status.EXITING + && exits[_attester].exitableAt <= Timestamp.wrap(block.timestamp) + ), + Errors.Staking__CannotSlashExitedStake(_attester) + ); + validator.stake -= _amount; + + // If the attester was validating AND is slashed below the MINIMUM_STAKE we update him to LIVING + // When LIVING, he can only start exiting, we don't "really" exit him, because that cost + // gas and cost edge cases around recipient, so lets just avoid that. + if (validator.status == Status.VALIDATING && validator.stake < MINIMUM_STAKE) { + require(attesters.remove(_attester), Errors.Staking__FailedToRemove(_attester)); + validator.status = Status.LIVING; + } + + emit Slashed(_attester, _amount); + } + + function getInfo(address _attester) + external + view + override(IStaking) + returns (ValidatorInfo memory) + { + return info[_attester]; + } + + function getExit(address _attester) external view override(IStaking) returns (Exit memory) { + return exits[_attester]; + } + + function getActiveAttesterCount() external view override(IStaking) returns (uint256) { + return attesters.length(); + } + + function getAttesterAt(uint256 _index) external view override(IStaking) returns (address) { + return attesters.at(_index); + } + + function getProposerAt(uint256 _index) external view override(IStaking) returns (address) { + return info[attesters.at(_index)].proposer; + } + + function getOperatorAt(uint256 _index) + external + view + override(IStaking) + returns (OperatorInfo memory) + { + address attester = attesters.at(_index); + return OperatorInfo({proposer: info[attester].proposer, attester: attester}); + } +} diff --git a/l1-contracts/test/staking/StakingCheater.sol b/l1-contracts/test/staking/StakingCheater.sol new file mode 100644 index 000000000000..224c732c6c98 --- /dev/null +++ b/l1-contracts/test/staking/StakingCheater.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {Staking, Status} from "@aztec/core/staking/Staking.sol"; +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; +import {EnumerableSet} from "@oz/utils/structs/EnumerableSet.sol"; + +contract StakingCheater is Staking { + using EnumerableSet for EnumerableSet.AddressSet; + + constructor(address _slasher, IERC20 _stakingAsset, uint256 _minimumStake) + Staking(_slasher, _stakingAsset, _minimumStake) + {} + + function cheat__SetStatus(address _attester, Status _status) external { + info[_attester].status = _status; + } + + function cheat__AddAttester(address _attester) external { + attesters.add(_attester); + } + + function cheat__RemoveAttester(address _attester) external { + attesters.remove(_attester); + } +} diff --git a/l1-contracts/test/staking/base.t.sol b/l1-contracts/test/staking/base.t.sol new file mode 100644 index 000000000000..e47b6e8d24ae --- /dev/null +++ b/l1-contracts/test/staking/base.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {TestBase} from "@test/base/Base.sol"; + +import {StakingCheater} from "./StakingCheater.sol"; +import {TestERC20} from "@aztec/mock/TestERC20.sol"; + +contract StakingBase is TestBase { + StakingCheater internal staking; + TestERC20 internal stakingAsset; + + uint256 internal constant MINIMUM_STAKE = 100e18; + + address internal constant PROPOSER = address(bytes20("PROPOSER")); + address internal constant ATTESTER = address(bytes20("ATTESTER")); + address internal constant WITHDRAWER = address(bytes20("WITHDRAWER")); + address internal constant RECIPIENT = address(bytes20("RECIPIENT")); + address internal constant SLASHER = address(bytes20("SLASHER")); + + function setUp() public virtual { + stakingAsset = new TestERC20(); + staking = new StakingCheater(SLASHER, stakingAsset, MINIMUM_STAKE); + } +} diff --git a/l1-contracts/test/staking/deposit.t.sol b/l1-contracts/test/staking/deposit.t.sol new file mode 100644 index 000000000000..84f035fafb39 --- /dev/null +++ b/l1-contracts/test/staking/deposit.t.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {StakingBase} from "./base.t.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {IERC20Errors} from "@oz/interfaces/draft-IERC6093.sol"; +import {Staking, Status, ValidatorInfo} from "@aztec/core/staking/Staking.sol"; + +contract DepositTest is StakingBase { + uint256 internal depositAmount; + + function test_WhenAmountLtMinimumStake() external { + // it reverts + + vm.expectRevert( + abi.encodeWithSelector( + Errors.Staking__InsufficientStake.selector, depositAmount, MINIMUM_STAKE + ) + ); + + staking.deposit({ + _attester: ATTESTER, + _proposer: PROPOSER, + _withdrawer: WITHDRAWER, + _amount: depositAmount + }); + } + + modifier whenAmountGtMinimumStake(uint256 _depositAmount) { + depositAmount = bound(_depositAmount, MINIMUM_STAKE, type(uint96).max); + _; + } + + function test_GivenCallerHasInsufficientAllowance(uint256 _depositAmount) + external + whenAmountGtMinimumStake(_depositAmount) + { + // it reverts + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, address(staking), 0, depositAmount + ) + ); + + staking.deposit({ + _attester: ATTESTER, + _proposer: PROPOSER, + _withdrawer: WITHDRAWER, + _amount: depositAmount + }); + } + + modifier givenCallerHasSufficientAllowance() { + stakingAsset.approve(address(staking), depositAmount); + _; + } + + function test_GivenCallerHasInsufficientFunds(uint256 _depositAmount) + external + whenAmountGtMinimumStake(_depositAmount) + givenCallerHasSufficientAllowance + { + // it reverts + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientBalance.selector, address(this), 0, depositAmount + ) + ); + + staking.deposit({ + _attester: ATTESTER, + _proposer: PROPOSER, + _withdrawer: WITHDRAWER, + _amount: depositAmount + }); + } + + modifier givenCallerHasSufficientFunds() { + stakingAsset.mint(address(this), depositAmount); + _; + } + + function test_GivenAttesterIsAlreadyRegistered(uint256 _depositAmount) + external + whenAmountGtMinimumStake(_depositAmount) + givenCallerHasSufficientAllowance + givenCallerHasSufficientFunds + { + // it reverts + + // Show that everything else than the none status is rejected + for (uint256 i = 1; i < 4; i++) { + staking.cheat__SetStatus(ATTESTER, Status(i)); + + // Try to register the attester again + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__AlreadyRegistered.selector, ATTESTER)); + staking.deposit({ + _attester: ATTESTER, + _proposer: PROPOSER, + _withdrawer: WITHDRAWER, + _amount: depositAmount + }); + } + } + + modifier givenAttesterIsNotRegistered() { + _; + } + + function test_GivenAttesterIsAlreadyActive(uint256 _depositAmount) + external + whenAmountGtMinimumStake(_depositAmount) + givenCallerHasSufficientAllowance + givenCallerHasSufficientFunds + givenAttesterIsNotRegistered + { + // it reverts + + // This should not be possible to get to as the attester is registered until exit + // and to exit it must already have been removed from the active set. + + staking.cheat__AddAttester(ATTESTER); + + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__AlreadyActive.selector, ATTESTER)); + staking.deposit({ + _attester: ATTESTER, + _proposer: PROPOSER, + _withdrawer: WITHDRAWER, + _amount: depositAmount + }); + } + + function test_GivenAttesterIsNotActive(uint256 _depositAmount) + external + whenAmountGtMinimumStake(_depositAmount) + givenCallerHasSufficientAllowance + givenCallerHasSufficientFunds + givenAttesterIsNotRegistered + { + // it transfer funds from the caller + // it adds attester to the set + // it updates the operator info + // it emits a {Deposit} event + + assertEq(stakingAsset.balanceOf(address(staking)), 0); + + vm.expectEmit(true, true, true, true, address(staking)); + emit Staking.Deposit(ATTESTER, PROPOSER, WITHDRAWER, depositAmount); + + staking.deposit({ + _attester: ATTESTER, + _proposer: PROPOSER, + _withdrawer: WITHDRAWER, + _amount: depositAmount + }); + + assertEq(stakingAsset.balanceOf(address(staking)), depositAmount); + + ValidatorInfo memory info = staking.getInfo(ATTESTER); + assertEq(info.stake, depositAmount); + assertEq(info.withdrawer, WITHDRAWER); + assertEq(info.proposer, PROPOSER); + assertTrue(info.status == Status.VALIDATING); + } +} diff --git a/l1-contracts/test/staking/deposit.tree b/l1-contracts/test/staking/deposit.tree new file mode 100644 index 000000000000..beb1a2569c98 --- /dev/null +++ b/l1-contracts/test/staking/deposit.tree @@ -0,0 +1,20 @@ +DepositTest +├── when amount lt minimum stake +│ └── it reverts +└── when amount gt minimum stake + ├── given caller has insufficient allowance + │ └── it reverts + └── given caller has sufficient allowance + ├── given caller has insufficient funds + │ └── it reverts + └── given caller has sufficient funds + ├── given attester is already registered + │ └── it reverts + └── given attester is not registered + ├── given attester is already active + │ └── it reverts + └── given attester is not active + ├── it transfer funds from the caller + ├── it adds attester to the set + ├── it updates the operator info + └── it emits a {Deposit} event \ No newline at end of file diff --git a/l1-contracts/test/staking/finaliseWithdraw.t.sol b/l1-contracts/test/staking/finaliseWithdraw.t.sol new file mode 100644 index 000000000000..90d82059db5f --- /dev/null +++ b/l1-contracts/test/staking/finaliseWithdraw.t.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {StakingBase} from "./base.t.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {IERC20Errors} from "@oz/interfaces/draft-IERC6093.sol"; +import {Staking, Status, ValidatorInfo, Exit} from "@aztec/core/staking/Staking.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + +contract FinaliseWithdrawTest is StakingBase { + function test_GivenStatusIsNotExiting() external { + // it revert + + for (uint256 i = 0; i < 3; i++) { + staking.cheat__SetStatus(ATTESTER, Status(i)); + + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__NotExiting.selector, ATTESTER)); + staking.finaliseWithdraw(ATTESTER); + } + } + + modifier givenStatusIsExiting() { + // We deposit and initiate a withdraw + + stakingAsset.mint(address(this), MINIMUM_STAKE); + stakingAsset.approve(address(staking), MINIMUM_STAKE); + + staking.deposit({ + _attester: ATTESTER, + _proposer: PROPOSER, + _withdrawer: WITHDRAWER, + _amount: MINIMUM_STAKE + }); + + vm.prank(WITHDRAWER); + staking.initiateWithdraw(ATTESTER, RECIPIENT); + + _; + } + + function test_GivenTimeIsBeforeUnlock() external givenStatusIsExiting { + // it revert + + vm.expectRevert( + abi.encodeWithSelector( + Errors.Staking__WithdrawalNotUnlockedYet.selector, + Timestamp.wrap(block.timestamp), + Timestamp.wrap(block.timestamp) + staking.EXIT_DELAY() + ) + ); + staking.finaliseWithdraw(ATTESTER); + } + + function test_GivenTimeIsAfterUnlock() external givenStatusIsExiting { + // it deletes the exit + // it deletes the operator info + // it transfer funds to recipient + // it emits a {WithdrawFinalised} event + + Exit memory exit = staking.getExit(ATTESTER); + assertEq(exit.recipient, RECIPIENT); + assertEq(exit.exitableAt, Timestamp.wrap(block.timestamp) + staking.EXIT_DELAY()); + ValidatorInfo memory info = staking.getInfo(ATTESTER); + assertTrue(info.status == Status.EXITING); + + assertEq(stakingAsset.balanceOf(address(staking)), MINIMUM_STAKE); + assertEq(stakingAsset.balanceOf(RECIPIENT), 0); + + vm.warp(Timestamp.unwrap(exit.exitableAt)); + + vm.expectEmit(true, true, true, true, address(staking)); + emit Staking.WithdrawFinalised(ATTESTER, RECIPIENT, MINIMUM_STAKE); + staking.finaliseWithdraw(ATTESTER); + + exit = staking.getExit(ATTESTER); + assertEq(exit.recipient, address(0)); + assertEq(exit.exitableAt, Timestamp.wrap(0)); + + info = staking.getInfo(ATTESTER); + assertTrue(info.status == Status.NONE); + + assertEq(stakingAsset.balanceOf(address(staking)), 0); + assertEq(stakingAsset.balanceOf(RECIPIENT), MINIMUM_STAKE); + } +} diff --git a/l1-contracts/test/staking/finaliseWithdraw.tree b/l1-contracts/test/staking/finaliseWithdraw.tree new file mode 100644 index 000000000000..4e5df8311467 --- /dev/null +++ b/l1-contracts/test/staking/finaliseWithdraw.tree @@ -0,0 +1,11 @@ +FinaliseWithdrawTest +├── given status is not exiting +│ └── it revert +└── given status is exiting + ├── given time is before unlock + │ └── it revert + └── given time is after unlock + ├── it deletes the exit + ├── it deletes the operator info + ├── it transfer funds to recipient + └── it emits a {WithdrawFinalised} event \ No newline at end of file diff --git a/l1-contracts/test/staking/getters.t.sol b/l1-contracts/test/staking/getters.t.sol new file mode 100644 index 000000000000..9022cdad5dc1 --- /dev/null +++ b/l1-contracts/test/staking/getters.t.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {StakingBase} from "./base.t.sol"; +import {OperatorInfo} from "@aztec/core/staking/Staking.sol"; + +contract GettersTest is StakingBase { + function setUp() public override { + super.setUp(); + + stakingAsset.mint(address(this), MINIMUM_STAKE); + stakingAsset.approve(address(staking), MINIMUM_STAKE); + staking.deposit({ + _attester: ATTESTER, + _proposer: PROPOSER, + _withdrawer: WITHDRAWER, + _amount: MINIMUM_STAKE + }); + } + + function test_getAttesterAt() external view { + address attester = staking.getAttesterAt(0); + assertEq(attester, ATTESTER); + } + + function test_getAttesterOutOfBounds() external { + vm.expectRevert(); + staking.getAttesterAt(1); + } + + function test_getProposerAt() external view { + address proposer = staking.getProposerAt(0); + assertEq(proposer, PROPOSER); + } + + function test_getProposerOutOfBounds() external { + vm.expectRevert(); + staking.getProposerAt(1); + } + + function test_getOperatorAt() external view { + OperatorInfo memory operator = staking.getOperatorAt(0); + assertEq(operator.attester, ATTESTER); + assertEq(operator.proposer, PROPOSER); + } + + function test_getOperatorOutOfBounds() external { + vm.expectRevert(); + staking.getOperatorAt(1); + } +} diff --git a/l1-contracts/test/staking/initiateWithdraw.t.sol b/l1-contracts/test/staking/initiateWithdraw.t.sol new file mode 100644 index 000000000000..9db93c6c1371 --- /dev/null +++ b/l1-contracts/test/staking/initiateWithdraw.t.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {StakingBase} from "./base.t.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {Staking, Status, ValidatorInfo, Exit} from "@aztec/core/staking/Staking.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + +contract InitiateWithdrawTest is StakingBase { + function test_WhenAttesterIsNotRegistered() external { + // it revert + + vm.expectRevert( + abi.encodeWithSelector(Errors.Staking__NotWithdrawer.selector, address(0), address(this)) + ); + staking.initiateWithdraw(ATTESTER, RECIPIENT); + } + + modifier whenAttesterIsRegistered() { + stakingAsset.mint(address(this), MINIMUM_STAKE); + stakingAsset.approve(address(staking), MINIMUM_STAKE); + staking.deposit({ + _attester: ATTESTER, + _proposer: PROPOSER, + _withdrawer: WITHDRAWER, + _amount: MINIMUM_STAKE + }); + + _; + } + + function test_WhenCallerIsNotTheWithdrawer(address _caller) external whenAttesterIsRegistered { + // it revert + + vm.assume(_caller != WITHDRAWER); + + vm.expectRevert( + abi.encodeWithSelector(Errors.Staking__NotWithdrawer.selector, WITHDRAWER, _caller) + ); + vm.prank(_caller); + staking.initiateWithdraw(ATTESTER, RECIPIENT); + } + + modifier whenCallerIsTheWithdrawer() { + _; + } + + function test_GivenAttesterIsNotValidatingOrLiving() + external + whenAttesterIsRegistered + whenCallerIsTheWithdrawer + { + // it revert + + staking.cheat__SetStatus(ATTESTER, Status.EXITING); + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__NothingToExit.selector, ATTESTER)); + vm.prank(WITHDRAWER); + staking.initiateWithdraw(ATTESTER, RECIPIENT); + + // Should not be possible to hit this, as you should have failed with withdrawer being address(0) + staking.cheat__SetStatus(ATTESTER, Status.NONE); + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__NothingToExit.selector, ATTESTER)); + vm.prank(WITHDRAWER); + staking.initiateWithdraw(ATTESTER, RECIPIENT); + } + + modifier givenAttesterIsValidating() { + _; + } + + function test_GivenAttesterIsNotInTheActiveSet() + external + whenAttesterIsRegistered + whenCallerIsTheWithdrawer + givenAttesterIsValidating + { + // it revert + + // Again, this should not be possible to hit + staking.cheat__RemoveAttester(ATTESTER); + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__FailedToRemove.selector, ATTESTER)); + vm.prank(WITHDRAWER); + staking.initiateWithdraw(ATTESTER, RECIPIENT); + } + + function test_GivenAttesterIsInTheActiveSet() + external + whenAttesterIsRegistered + whenCallerIsTheWithdrawer + givenAttesterIsValidating + { + // it removes the attester from the active set + // it creates an exit struct + // it updates the operator status to exiting + // it emits a {WithdrawInitiated} event + + assertEq(stakingAsset.balanceOf(address(staking)), MINIMUM_STAKE); + assertEq(stakingAsset.balanceOf(RECIPIENT), 0); + Exit memory exit = staking.getExit(ATTESTER); + assertEq(exit.exitableAt, Timestamp.wrap(0)); + assertEq(exit.recipient, address(0)); + ValidatorInfo memory info = staking.getInfo(ATTESTER); + assertTrue(info.status == Status.VALIDATING); + assertEq(staking.getActiveAttesterCount(), 1); + + vm.expectEmit(true, true, true, true, address(staking)); + emit Staking.WithdrawInitiated(ATTESTER, RECIPIENT, MINIMUM_STAKE); + + vm.prank(WITHDRAWER); + staking.initiateWithdraw(ATTESTER, RECIPIENT); + + assertEq(stakingAsset.balanceOf(address(staking)), MINIMUM_STAKE); + assertEq(stakingAsset.balanceOf(RECIPIENT), 0); + exit = staking.getExit(ATTESTER); + assertEq(exit.exitableAt, Timestamp.wrap(block.timestamp) + staking.EXIT_DELAY()); + assertEq(exit.recipient, RECIPIENT); + info = staking.getInfo(ATTESTER); + assertTrue(info.status == Status.EXITING); + assertEq(staking.getActiveAttesterCount(), 0); + } + + function test_GivenAttesterIsLiving() external whenAttesterIsRegistered whenCallerIsTheWithdrawer { + // it creates an exit struct + // it updates the operator status to exiting + // it emits a {WithdrawInitiated} event + + staking.cheat__SetStatus(ATTESTER, Status.LIVING); + staking.cheat__RemoveAttester(ATTESTER); + + assertEq(stakingAsset.balanceOf(address(staking)), MINIMUM_STAKE); + assertEq(stakingAsset.balanceOf(RECIPIENT), 0); + Exit memory exit = staking.getExit(ATTESTER); + assertEq(exit.exitableAt, Timestamp.wrap(0)); + assertEq(exit.recipient, address(0)); + ValidatorInfo memory info = staking.getInfo(ATTESTER); + assertTrue(info.status == Status.LIVING); + assertEq(staking.getActiveAttesterCount(), 0); + + vm.expectEmit(true, true, true, true, address(staking)); + emit Staking.WithdrawInitiated(ATTESTER, RECIPIENT, MINIMUM_STAKE); + + vm.prank(WITHDRAWER); + staking.initiateWithdraw(ATTESTER, RECIPIENT); + + assertEq(stakingAsset.balanceOf(address(staking)), MINIMUM_STAKE); + assertEq(stakingAsset.balanceOf(RECIPIENT), 0); + exit = staking.getExit(ATTESTER); + assertEq(exit.exitableAt, Timestamp.wrap(block.timestamp) + staking.EXIT_DELAY()); + assertEq(exit.recipient, RECIPIENT); + info = staking.getInfo(ATTESTER); + assertTrue(info.status == Status.EXITING); + assertEq(staking.getActiveAttesterCount(), 0); + } +} diff --git a/l1-contracts/test/staking/initiateWithdraw.tree b/l1-contracts/test/staking/initiateWithdraw.tree new file mode 100644 index 000000000000..2fdf14609bdc --- /dev/null +++ b/l1-contracts/test/staking/initiateWithdraw.tree @@ -0,0 +1,21 @@ +InitiateWithdrawTest +├── when attester is not registered +│ └── it revert +└── when attester is registered + ├── when caller is not the withdrawer + │ └── it revert + └── when caller is the withdrawer + ├── given attester is not validating or living + │ └── it revert + ├── given attester is validating + │ ├── given attester is not in the active set + │ │ └── it revert + │ └── given attester is in the active set + │ ├── it removes the attester from the active set + │ ├── it creates an exit struct + │ ├── it updates the operator status to exiting + │ └── it emits a {WithdrawInitiated} event + └── given attester is living + ├── it creates an exit struct + ├── it updates the operator status to exiting + └── it emits a {WithdrawInitiated} event \ No newline at end of file diff --git a/l1-contracts/test/staking/slash.t.sol b/l1-contracts/test/staking/slash.t.sol new file mode 100644 index 000000000000..a7132dd7edcc --- /dev/null +++ b/l1-contracts/test/staking/slash.t.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {StakingBase} from "./base.t.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {Staking, Status, ValidatorInfo, Exit} from "@aztec/core/staking/Staking.sol"; +import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; + +contract SlashTest is StakingBase { + uint256 internal constant DEPOSIT_AMOUNT = MINIMUM_STAKE + 2; + uint256 internal slashingAmount = 1; + + function test_WhenCallerIsNotTheSlasher() external { + // it reverts + vm.expectRevert( + abi.encodeWithSelector(Errors.Staking__NotSlasher.selector, SLASHER, address(this)) + ); + staking.slash(ATTESTER, 1); + } + + modifier whenCallerIsTheSlasher() { + _; + } + + function test_WhenAttesterIsNotRegistered() external whenCallerIsTheSlasher { + // it reverts + + vm.prank(SLASHER); + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__NoOneToSlash.selector, ATTESTER)); + staking.slash(ATTESTER, 1); + } + + modifier whenAttesterIsRegistered() { + stakingAsset.mint(address(this), DEPOSIT_AMOUNT); + stakingAsset.approve(address(staking), DEPOSIT_AMOUNT); + + staking.deposit({ + _attester: ATTESTER, + _proposer: PROPOSER, + _withdrawer: WITHDRAWER, + _amount: DEPOSIT_AMOUNT + }); + _; + } + + modifier whenAttesterIsExiting() { + vm.prank(WITHDRAWER); + staking.initiateWithdraw(ATTESTER, RECIPIENT); + + _; + } + + function test_GivenTimeIsAfterUnlock() + external + whenCallerIsTheSlasher + whenAttesterIsRegistered + whenAttesterIsExiting + { + // it reverts + + Exit memory exit = staking.getExit(ATTESTER); + vm.warp(Timestamp.unwrap(exit.exitableAt)); + + vm.expectRevert( + abi.encodeWithSelector(Errors.Staking__CannotSlashExitedStake.selector, ATTESTER) + ); + vm.prank(SLASHER); + staking.slash(ATTESTER, 1); + } + + function test_GivenTimeIsBeforeUnlock() + external + whenCallerIsTheSlasher + whenAttesterIsRegistered + whenAttesterIsExiting + { + // it reduce stake by amount + // it emits {Slashed} event + + ValidatorInfo memory info = staking.getInfo(ATTESTER); + assertEq(info.stake, DEPOSIT_AMOUNT); + assertTrue(info.status == Status.EXITING); + + vm.expectEmit(true, true, true, true, address(staking)); + emit Staking.Slashed(ATTESTER, 1); + vm.prank(SLASHER); + staking.slash(ATTESTER, 1); + + info = staking.getInfo(ATTESTER); + assertEq(info.stake, DEPOSIT_AMOUNT - 1); + assertTrue(info.status == Status.EXITING); + } + + function test_WhenAttesterIsNotExiting() external whenCallerIsTheSlasher whenAttesterIsRegistered { + // it reduce stake by amount + // it emits {Slashed} event + + Status[] memory cases = new Status[](2); + cases[0] = Status.VALIDATING; + cases[1] = Status.LIVING; + + for (uint256 i = 0; i < cases.length; i++) { + // Prepare the status and state + staking.cheat__SetStatus(ATTESTER, cases[i]); + if (cases[i] == Status.LIVING) { + staking.cheat__RemoveAttester(ATTESTER); + } + + ValidatorInfo memory info = staking.getInfo(ATTESTER); + assertTrue(info.status == cases[i]); + uint256 activeAttesterCount = staking.getActiveAttesterCount(); + uint256 balance = info.stake; + + vm.expectEmit(true, true, true, true, address(staking)); + emit Staking.Slashed(ATTESTER, 1); + vm.prank(SLASHER); + staking.slash(ATTESTER, 1); + + info = staking.getInfo(ATTESTER); + assertEq(info.stake, balance - 1); + assertTrue(info.status == cases[i]); + assertEq(staking.getActiveAttesterCount(), activeAttesterCount); + } + } + + modifier whenAttesterIsValidatingAndStakeIsBelowMinimumStake() { + ValidatorInfo memory info = staking.getInfo(ATTESTER); + slashingAmount = info.stake - MINIMUM_STAKE + 1; + _; + } + + function test_GivenAttesterIsNotActive() + external + whenCallerIsTheSlasher + whenAttesterIsRegistered + whenAttesterIsValidatingAndStakeIsBelowMinimumStake + { + // it reverts + + // This should be impossible to trigger in practice as the only case where attester is removed already + // is if the status is none. + staking.cheat__RemoveAttester(ATTESTER); + vm.expectRevert(abi.encodeWithSelector(Errors.Staking__FailedToRemove.selector, ATTESTER)); + vm.prank(SLASHER); + staking.slash(ATTESTER, slashingAmount); + } + + function test_GivenAttesterIsActive() + external + whenCallerIsTheSlasher + whenAttesterIsRegistered + whenAttesterIsValidatingAndStakeIsBelowMinimumStake + { + // it reduce stake by amount + // it remove from active attesters + // it set status to living + // it emits {Slashed} event + + ValidatorInfo memory info = staking.getInfo(ATTESTER); + assertTrue(info.status == Status.VALIDATING); + uint256 activeAttesterCount = staking.getActiveAttesterCount(); + uint256 balance = info.stake; + + vm.expectEmit(true, true, true, true, address(staking)); + emit Staking.Slashed(ATTESTER, slashingAmount); + vm.prank(SLASHER); + staking.slash(ATTESTER, slashingAmount); + + info = staking.getInfo(ATTESTER); + assertEq(info.stake, balance - slashingAmount); + assertTrue(info.status == Status.LIVING); + assertEq(staking.getActiveAttesterCount(), activeAttesterCount - 1); + } +} diff --git a/l1-contracts/test/staking/slash.tree b/l1-contracts/test/staking/slash.tree new file mode 100644 index 000000000000..5cc36fe9542c --- /dev/null +++ b/l1-contracts/test/staking/slash.tree @@ -0,0 +1,24 @@ +SlashTest +├── when caller is not the slasher +│ └── it reverts +└── when caller is the slasher + ├── when attester is not registered + │ └── it reverts + └── when attester is registered + ├── when attester is exiting + │ ├── given time is after unlock + │ │ └── it reverts + │ └── given time is before unlock + │ ├── it reduce stake by amount + │ └── it emits {Slashed} event + ├── when attester is not exiting + │ ├── it reduce stake by amount + │ └── it emits {Slashed} event + └── when attester is validating and stake is below minimum stake + ├── given attester is not active + │ └── it reverts + └── given attester is active + ├── it reduce stake by amount + ├── it remove from active attesters + ├── it set status to living + └── it emits {Slashed} event \ No newline at end of file From 01cf9fef96ccb6f75576238543830ab68716c8bc Mon Sep 17 00:00:00 2001 From: LHerskind Date: Mon, 2 Dec 2024 10:59:10 +0000 Subject: [PATCH 2/3] chore: more specific naming --- l1-contracts/src/core/interfaces/IStaking.sol | 6 +++--- l1-contracts/src/core/staking/Staking.sol | 6 +++--- l1-contracts/test/staking/getters.t.sol | 18 +++++++++--------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/l1-contracts/src/core/interfaces/IStaking.sol b/l1-contracts/src/core/interfaces/IStaking.sol index ec6c78f74c7f..59e2792ffab6 100644 --- a/l1-contracts/src/core/interfaces/IStaking.sol +++ b/l1-contracts/src/core/interfaces/IStaking.sol @@ -43,7 +43,7 @@ interface IStaking { function getInfo(address _attester) external view returns (ValidatorInfo memory); function getExit(address _attester) external view returns (Exit memory); function getActiveAttesterCount() external view returns (uint256); - function getAttesterAt(uint256 _index) external view returns (address); - function getProposerAt(uint256 _index) external view returns (address); - function getOperatorAt(uint256 _index) external view returns (OperatorInfo memory); + function getAttesterAtIndex(uint256 _index) external view returns (address); + function getProposerAtIndex(uint256 _index) external view returns (address); + function getOperatorAtIndex(uint256 _index) external view returns (OperatorInfo memory); } diff --git a/l1-contracts/src/core/staking/Staking.sol b/l1-contracts/src/core/staking/Staking.sol index 07d96a8dc78b..225a397ea832 100644 --- a/l1-contracts/src/core/staking/Staking.sol +++ b/l1-contracts/src/core/staking/Staking.sol @@ -157,15 +157,15 @@ contract Staking is IStaking { return attesters.length(); } - function getAttesterAt(uint256 _index) external view override(IStaking) returns (address) { + function getAttesterAtIndex(uint256 _index) external view override(IStaking) returns (address) { return attesters.at(_index); } - function getProposerAt(uint256 _index) external view override(IStaking) returns (address) { + function getProposerAtIndex(uint256 _index) external view override(IStaking) returns (address) { return info[attesters.at(_index)].proposer; } - function getOperatorAt(uint256 _index) + function getOperatorAtIndex(uint256 _index) external view override(IStaking) diff --git a/l1-contracts/test/staking/getters.t.sol b/l1-contracts/test/staking/getters.t.sol index 9022cdad5dc1..74c95fb1abc1 100644 --- a/l1-contracts/test/staking/getters.t.sol +++ b/l1-contracts/test/staking/getters.t.sol @@ -18,34 +18,34 @@ contract GettersTest is StakingBase { }); } - function test_getAttesterAt() external view { - address attester = staking.getAttesterAt(0); + function test_getAttesterAtIndex() external view { + address attester = staking.getAttesterAtIndex(0); assertEq(attester, ATTESTER); } function test_getAttesterOutOfBounds() external { vm.expectRevert(); - staking.getAttesterAt(1); + staking.getAttesterAtIndex(1); } - function test_getProposerAt() external view { - address proposer = staking.getProposerAt(0); + function test_getProposerAtIndex() external view { + address proposer = staking.getProposerAtIndex(0); assertEq(proposer, PROPOSER); } function test_getProposerOutOfBounds() external { vm.expectRevert(); - staking.getProposerAt(1); + staking.getProposerAtIndex(1); } - function test_getOperatorAt() external view { - OperatorInfo memory operator = staking.getOperatorAt(0); + function test_getOperatorAtIndex() external view { + OperatorInfo memory operator = staking.getOperatorAtIndex(0); assertEq(operator.attester, ATTESTER); assertEq(operator.proposer, PROPOSER); } function test_getOperatorOutOfBounds() external { vm.expectRevert(); - staking.getOperatorAt(1); + staking.getOperatorAtIndex(1); } } From 93491d14f9d583c2ace10d8d3d6e5245105c122f Mon Sep 17 00:00:00 2001 From: LHerskind Date: Mon, 2 Dec 2024 14:09:15 +0000 Subject: [PATCH 3/3] chore: an extra getter + easier override --- l1-contracts/src/core/interfaces/IStaking.sol | 8 ++ l1-contracts/src/core/staking/Staking.sol | 130 +++++++++--------- l1-contracts/test/staking/deposit.t.sol | 4 +- .../test/staking/finaliseWithdraw.t.sol | 5 +- l1-contracts/test/staking/getters.t.sol | 5 + .../test/staking/initiateWithdraw.t.sol | 6 +- l1-contracts/test/staking/slash.t.sol | 8 +- 7 files changed, 91 insertions(+), 75 deletions(-) diff --git a/l1-contracts/src/core/interfaces/IStaking.sol b/l1-contracts/src/core/interfaces/IStaking.sol index 59e2792ffab6..12d1cce4ab98 100644 --- a/l1-contracts/src/core/interfaces/IStaking.sol +++ b/l1-contracts/src/core/interfaces/IStaking.sol @@ -34,6 +34,13 @@ struct Exit { } interface IStaking { + event Deposit( + address indexed attester, address indexed proposer, address indexed withdrawer, uint256 amount + ); + event WithdrawInitiated(address indexed attester, address indexed recipient, uint256 amount); + event WithdrawFinalised(address indexed attester, address indexed recipient, uint256 amount); + event Slashed(address indexed attester, uint256 amount); + function deposit(address _attester, address _proposer, address _withdrawer, uint256 _amount) external; function initiateWithdraw(address _attester, address _recipient) external returns (bool); @@ -45,5 +52,6 @@ interface IStaking { function getActiveAttesterCount() external view returns (uint256); function getAttesterAtIndex(uint256 _index) external view returns (address); function getProposerAtIndex(uint256 _index) external view returns (address); + function getProposerForAttester(address _attester) external view returns (address); function getOperatorAtIndex(uint256 _index) external view returns (OperatorInfo memory); } diff --git a/l1-contracts/src/core/staking/Staking.sol b/l1-contracts/src/core/staking/Staking.sol index 225a397ea832..7f0a0c3b4465 100644 --- a/l1-contracts/src/core/staking/Staking.sol +++ b/l1-contracts/src/core/staking/Staking.sol @@ -28,70 +28,12 @@ contract Staking is IStaking { mapping(address attester => ValidatorInfo) internal info; mapping(address attester => Exit) internal exits; - event Deposit( - address indexed attester, address indexed proposer, address indexed withdrawer, uint256 amount - ); - event WithdrawInitiated(address indexed attester, address indexed recipient, uint256 amount); - event WithdrawFinalised(address indexed attester, address indexed recipient, uint256 amount); - event Slashed(address indexed attester, uint256 amount); - constructor(address _slasher, IERC20 _stakingAsset, uint256 _minimumStake) { SLASHER = _slasher; STAKING_ASSET = _stakingAsset; MINIMUM_STAKE = _minimumStake; } - function deposit(address _attester, address _proposer, address _withdrawer, uint256 _amount) - external - override(IStaking) - { - require(_amount >= MINIMUM_STAKE, Errors.Staking__InsufficientStake(_amount, MINIMUM_STAKE)); - STAKING_ASSET.transferFrom(msg.sender, address(this), _amount); - require(info[_attester].status == Status.NONE, Errors.Staking__AlreadyRegistered(_attester)); - require(attesters.add(_attester), Errors.Staking__AlreadyActive(_attester)); - - // If BLS, need to check possession of private key to avoid attacks. - - info[_attester] = ValidatorInfo({ - stake: _amount, - withdrawer: _withdrawer, - proposer: _proposer, - status: Status.VALIDATING - }); - - emit Deposit(_attester, _proposer, _withdrawer, _amount); - } - - function initiateWithdraw(address _attester, address _recipient) - external - override(IStaking) - returns (bool) - { - ValidatorInfo storage validator = info[_attester]; - - require( - msg.sender == validator.withdrawer, - Errors.Staking__NotWithdrawer(validator.withdrawer, msg.sender) - ); - require( - validator.status == Status.VALIDATING || validator.status == Status.LIVING, - Errors.Staking__NothingToExit(_attester) - ); - if (validator.status == Status.VALIDATING) { - require(attesters.remove(_attester), Errors.Staking__FailedToRemove(_attester)); - } - - // Note that the "amount" is not stored here, but reusing the `validators` - // We always exit fully. - exits[_attester] = - Exit({exitableAt: Timestamp.wrap(block.timestamp) + EXIT_DELAY, recipient: _recipient}); - validator.status = Status.EXITING; - - emit WithdrawInitiated(_attester, _recipient, validator.stake); - - return true; - } - function finaliseWithdraw(address _attester) external override(IStaking) { ValidatorInfo storage validator = info[_attester]; require(validator.status == Status.EXITING, Errors.Staking__NotExiting(_attester)); @@ -110,7 +52,7 @@ contract Staking is IStaking { STAKING_ASSET.transfer(recipient, amount); - emit WithdrawFinalised(_attester, recipient, amount); + emit IStaking.WithdrawFinalised(_attester, recipient, amount); } function slash(address _attester, uint256 _amount) external override(IStaking) { @@ -149,12 +91,17 @@ contract Staking is IStaking { return info[_attester]; } - function getExit(address _attester) external view override(IStaking) returns (Exit memory) { - return exits[_attester]; + function getProposerForAttester(address _attester) + external + view + override(IStaking) + returns (address) + { + return info[_attester].proposer; } - function getActiveAttesterCount() external view override(IStaking) returns (uint256) { - return attesters.length(); + function getExit(address _attester) external view override(IStaking) returns (Exit memory) { + return exits[_attester]; } function getAttesterAtIndex(uint256 _index) external view override(IStaking) returns (address) { @@ -174,4 +121,61 @@ contract Staking is IStaking { address attester = attesters.at(_index); return OperatorInfo({proposer: info[attester].proposer, attester: attester}); } + + function deposit(address _attester, address _proposer, address _withdrawer, uint256 _amount) + public + virtual + override(IStaking) + { + require(_amount >= MINIMUM_STAKE, Errors.Staking__InsufficientStake(_amount, MINIMUM_STAKE)); + STAKING_ASSET.transferFrom(msg.sender, address(this), _amount); + require(info[_attester].status == Status.NONE, Errors.Staking__AlreadyRegistered(_attester)); + require(attesters.add(_attester), Errors.Staking__AlreadyActive(_attester)); + + // If BLS, need to check possession of private key to avoid attacks. + + info[_attester] = ValidatorInfo({ + stake: _amount, + withdrawer: _withdrawer, + proposer: _proposer, + status: Status.VALIDATING + }); + + emit IStaking.Deposit(_attester, _proposer, _withdrawer, _amount); + } + + function initiateWithdraw(address _attester, address _recipient) + public + virtual + override(IStaking) + returns (bool) + { + ValidatorInfo storage validator = info[_attester]; + + require( + msg.sender == validator.withdrawer, + Errors.Staking__NotWithdrawer(validator.withdrawer, msg.sender) + ); + require( + validator.status == Status.VALIDATING || validator.status == Status.LIVING, + Errors.Staking__NothingToExit(_attester) + ); + if (validator.status == Status.VALIDATING) { + require(attesters.remove(_attester), Errors.Staking__FailedToRemove(_attester)); + } + + // Note that the "amount" is not stored here, but reusing the `validators` + // We always exit fully. + exits[_attester] = + Exit({exitableAt: Timestamp.wrap(block.timestamp) + EXIT_DELAY, recipient: _recipient}); + validator.status = Status.EXITING; + + emit IStaking.WithdrawInitiated(_attester, _recipient, validator.stake); + + return true; + } + + function getActiveAttesterCount() public view override(IStaking) returns (uint256) { + return attesters.length(); + } } diff --git a/l1-contracts/test/staking/deposit.t.sol b/l1-contracts/test/staking/deposit.t.sol index 84f035fafb39..900d2a58372c 100644 --- a/l1-contracts/test/staking/deposit.t.sol +++ b/l1-contracts/test/staking/deposit.t.sol @@ -4,7 +4,7 @@ pragma solidity >=0.8.27; import {StakingBase} from "./base.t.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; import {IERC20Errors} from "@oz/interfaces/draft-IERC6093.sol"; -import {Staking, Status, ValidatorInfo} from "@aztec/core/staking/Staking.sol"; +import {Staking, IStaking, Status, ValidatorInfo} from "@aztec/core/staking/Staking.sol"; contract DepositTest is StakingBase { uint256 internal depositAmount; @@ -147,7 +147,7 @@ contract DepositTest is StakingBase { assertEq(stakingAsset.balanceOf(address(staking)), 0); vm.expectEmit(true, true, true, true, address(staking)); - emit Staking.Deposit(ATTESTER, PROPOSER, WITHDRAWER, depositAmount); + emit IStaking.Deposit(ATTESTER, PROPOSER, WITHDRAWER, depositAmount); staking.deposit({ _attester: ATTESTER, diff --git a/l1-contracts/test/staking/finaliseWithdraw.t.sol b/l1-contracts/test/staking/finaliseWithdraw.t.sol index 90d82059db5f..b48e9534ccc3 100644 --- a/l1-contracts/test/staking/finaliseWithdraw.t.sol +++ b/l1-contracts/test/staking/finaliseWithdraw.t.sol @@ -3,8 +3,7 @@ pragma solidity >=0.8.27; import {StakingBase} from "./base.t.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; -import {IERC20Errors} from "@oz/interfaces/draft-IERC6093.sol"; -import {Staking, Status, ValidatorInfo, Exit} from "@aztec/core/staking/Staking.sol"; +import {IStaking, Status, ValidatorInfo, Exit} from "@aztec/core/staking/Staking.sol"; import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; contract FinaliseWithdrawTest is StakingBase { @@ -69,7 +68,7 @@ contract FinaliseWithdrawTest is StakingBase { vm.warp(Timestamp.unwrap(exit.exitableAt)); vm.expectEmit(true, true, true, true, address(staking)); - emit Staking.WithdrawFinalised(ATTESTER, RECIPIENT, MINIMUM_STAKE); + emit IStaking.WithdrawFinalised(ATTESTER, RECIPIENT, MINIMUM_STAKE); staking.finaliseWithdraw(ATTESTER); exit = staking.getExit(ATTESTER); diff --git a/l1-contracts/test/staking/getters.t.sol b/l1-contracts/test/staking/getters.t.sol index 74c95fb1abc1..2497c994d5af 100644 --- a/l1-contracts/test/staking/getters.t.sol +++ b/l1-contracts/test/staking/getters.t.sol @@ -48,4 +48,9 @@ contract GettersTest is StakingBase { vm.expectRevert(); staking.getOperatorAtIndex(1); } + + function test_getProposerForAttester() external view { + assertEq(staking.getProposerForAttester(ATTESTER), PROPOSER); + assertEq(staking.getProposerForAttester(address(1)), address(0)); + } } diff --git a/l1-contracts/test/staking/initiateWithdraw.t.sol b/l1-contracts/test/staking/initiateWithdraw.t.sol index 9db93c6c1371..e970a5ae120d 100644 --- a/l1-contracts/test/staking/initiateWithdraw.t.sol +++ b/l1-contracts/test/staking/initiateWithdraw.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.27; import {StakingBase} from "./base.t.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; -import {Staking, Status, ValidatorInfo, Exit} from "@aztec/core/staking/Staking.sol"; +import {IStaking, Status, ValidatorInfo, Exit} from "@aztec/core/staking/Staking.sol"; import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; contract InitiateWithdrawTest is StakingBase { @@ -104,7 +104,7 @@ contract InitiateWithdrawTest is StakingBase { assertEq(staking.getActiveAttesterCount(), 1); vm.expectEmit(true, true, true, true, address(staking)); - emit Staking.WithdrawInitiated(ATTESTER, RECIPIENT, MINIMUM_STAKE); + emit IStaking.WithdrawInitiated(ATTESTER, RECIPIENT, MINIMUM_STAKE); vm.prank(WITHDRAWER); staking.initiateWithdraw(ATTESTER, RECIPIENT); @@ -137,7 +137,7 @@ contract InitiateWithdrawTest is StakingBase { assertEq(staking.getActiveAttesterCount(), 0); vm.expectEmit(true, true, true, true, address(staking)); - emit Staking.WithdrawInitiated(ATTESTER, RECIPIENT, MINIMUM_STAKE); + emit IStaking.WithdrawInitiated(ATTESTER, RECIPIENT, MINIMUM_STAKE); vm.prank(WITHDRAWER); staking.initiateWithdraw(ATTESTER, RECIPIENT); diff --git a/l1-contracts/test/staking/slash.t.sol b/l1-contracts/test/staking/slash.t.sol index a7132dd7edcc..8a9682397af3 100644 --- a/l1-contracts/test/staking/slash.t.sol +++ b/l1-contracts/test/staking/slash.t.sol @@ -3,7 +3,7 @@ pragma solidity >=0.8.27; import {StakingBase} from "./base.t.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; -import {Staking, Status, ValidatorInfo, Exit} from "@aztec/core/staking/Staking.sol"; +import {Staking, IStaking, Status, ValidatorInfo, Exit} from "@aztec/core/staking/Staking.sol"; import {Timestamp} from "@aztec/core/libraries/TimeMath.sol"; contract SlashTest is StakingBase { @@ -82,7 +82,7 @@ contract SlashTest is StakingBase { assertTrue(info.status == Status.EXITING); vm.expectEmit(true, true, true, true, address(staking)); - emit Staking.Slashed(ATTESTER, 1); + emit IStaking.Slashed(ATTESTER, 1); vm.prank(SLASHER); staking.slash(ATTESTER, 1); @@ -112,7 +112,7 @@ contract SlashTest is StakingBase { uint256 balance = info.stake; vm.expectEmit(true, true, true, true, address(staking)); - emit Staking.Slashed(ATTESTER, 1); + emit IStaking.Slashed(ATTESTER, 1); vm.prank(SLASHER); staking.slash(ATTESTER, 1); @@ -162,7 +162,7 @@ contract SlashTest is StakingBase { uint256 balance = info.stake; vm.expectEmit(true, true, true, true, address(staking)); - emit Staking.Slashed(ATTESTER, slashingAmount); + emit IStaking.Slashed(ATTESTER, slashingAmount); vm.prank(SLASHER); staking.slash(ATTESTER, slashingAmount);