From c3247819751b8efab646ed05b3b781be403653e1 Mon Sep 17 00:00:00 2001 From: Lasse Herskind <16536249+LHerskind@users.noreply.github.com> Date: Tue, 3 Dec 2024 11:22:46 +0000 Subject: [PATCH] feat: standalone ssd (#10317) --- l1-contracts/src/core/Rollup.sol | 13 +- l1-contracts/src/core/interfaces/IStaking.sol | 57 ++++++ l1-contracts/src/core/libraries/Errors.sol | 13 ++ l1-contracts/src/core/staking/Staking.sol | 181 ++++++++++++++++++ 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 | 84 ++++++++ .../test/staking/finaliseWithdraw.tree | 11 ++ l1-contracts/test/staking/getters.t.sol | 56 ++++++ .../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, 1022 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 d1082047a18..ef98e18d6cc 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 00000000000..12d1cce4ab9 --- /dev/null +++ b/l1-contracts/src/core/interfaces/IStaking.sol @@ -0,0 +1,57 @@ +// 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 { + 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); + 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 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/libraries/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index 85d684b77b4..32d6e3a65ba 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 00000000000..7f0a0c3b446 --- /dev/null +++ b/l1-contracts/src/core/staking/Staking.sol @@ -0,0 +1,181 @@ +// 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; + + constructor(address _slasher, IERC20 _stakingAsset, uint256 _minimumStake) { + SLASHER = _slasher; + STAKING_ASSET = _stakingAsset; + MINIMUM_STAKE = _minimumStake; + } + + 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 IStaking.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 getProposerForAttester(address _attester) + external + view + override(IStaking) + returns (address) + { + return info[_attester].proposer; + } + + function getExit(address _attester) external view override(IStaking) returns (Exit memory) { + return exits[_attester]; + } + + function getAttesterAtIndex(uint256 _index) external view override(IStaking) returns (address) { + return attesters.at(_index); + } + + function getProposerAtIndex(uint256 _index) external view override(IStaking) returns (address) { + return info[attesters.at(_index)].proposer; + } + + function getOperatorAtIndex(uint256 _index) + external + view + override(IStaking) + returns (OperatorInfo memory) + { + 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/StakingCheater.sol b/l1-contracts/test/staking/StakingCheater.sol new file mode 100644 index 00000000000..224c732c6c9 --- /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 00000000000..e47b6e8d24a --- /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 00000000000..900d2a58372 --- /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, IStaking, 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 IStaking.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 00000000000..beb1a2569c9 --- /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 00000000000..b48e9534ccc --- /dev/null +++ b/l1-contracts/test/staking/finaliseWithdraw.t.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {StakingBase} from "./base.t.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {IStaking, 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 IStaking.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 00000000000..4e5df831146 --- /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 00000000000..2497c994d5a --- /dev/null +++ b/l1-contracts/test/staking/getters.t.sol @@ -0,0 +1,56 @@ +// 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_getAttesterAtIndex() external view { + address attester = staking.getAttesterAtIndex(0); + assertEq(attester, ATTESTER); + } + + function test_getAttesterOutOfBounds() external { + vm.expectRevert(); + staking.getAttesterAtIndex(1); + } + + function test_getProposerAtIndex() external view { + address proposer = staking.getProposerAtIndex(0); + assertEq(proposer, PROPOSER); + } + + function test_getProposerOutOfBounds() external { + vm.expectRevert(); + staking.getProposerAtIndex(1); + } + + 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.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 new file mode 100644 index 00000000000..e970a5ae120 --- /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 {IStaking, 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 IStaking.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 IStaking.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 00000000000..2fdf14609bd --- /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 00000000000..8a9682397af --- /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, IStaking, 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 IStaking.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 IStaking.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 IStaking.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 00000000000..5cc36fe9542 --- /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