From ea632bd869ced8a3e8e34e772de05a4e5d1529ab Mon Sep 17 00:00:00 2001 From: 0xvv Date: Tue, 3 Dec 2024 11:06:47 +0100 Subject: [PATCH] feat: blocklist --- README.md | 9 ++ src/contracts/StakingContract.sol | 77 +++++++++---- src/contracts/interfaces/ISanctionsOracle.sol | 2 +- .../libs/StakingContractStorageLib.sol | 20 +++- src/test/StakingContract.t.sol | 104 +++++++++++++++++- 5 files changed, 186 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index d8967ea..5f73929 100644 --- a/README.md +++ b/README.md @@ -325,3 +325,12 @@ sequenceDiagram D->>U: Send principial + net rewards ``` +## OFAC checking / Blocklist + +If the admin sets the oracle address to a non-zero address, the contract will check the OFAC list for the address of the msg.sender when depositing and when requesting exits and withdrawals. + +Admin can also block an address by calling `blockAccount(address, bytes)` on the contract. This will prevent the address from depositing, exiting or withdrawing. And force the exit of the validators provided if provided and the user is not sanctioned. + +If a user was wrongly banned or the sanctions were lifted, the admin can call `unblock(address)` to remove the address from the blocklist. + +The view function `isBlockedOrSanctioned(address) returns (bool isBlocked, bool isSanctioned)` can be used to check if an address is blocked or sanctioned, if no sanction oracle is set the isSanctioned bool will always return false. \ No newline at end of file diff --git a/src/contracts/StakingContract.sol b/src/contracts/StakingContract.sol index 573945f..4f3f328 100644 --- a/src/contracts/StakingContract.sol +++ b/src/contracts/StakingContract.sol @@ -50,7 +50,8 @@ contract StakingContract { error MaximumOperatorCountAlreadyReached(); error LastEditAfterSnapshot(); error PublicKeyNotInContract(); - error AddressSanctioned(address sanctioned); + error AddressSanctioned(address sanctionedAccount); + error AddressBlocked(address blockedAccount); struct ValidatorAllocationCache { bool used; @@ -728,23 +729,8 @@ contract StakingContract { } function requestValidatorsExit(bytes calldata _publicKeys) external { - if (_publicKeys.length % PUBLIC_KEY_LENGTH != 0) { - revert InvalidPublicKeys(); - } - _revertIfSanctioned(msg.sender); - for (uint256 i = 0; i < _publicKeys.length; ) { - bytes memory publicKey = BytesLib.slice(_publicKeys, i, PUBLIC_KEY_LENGTH); - bytes32 pubKeyRoot = _getPubKeyRoot(publicKey); - address withdrawer = _getWithdrawer(pubKeyRoot); - if (msg.sender != withdrawer) { - revert Unauthorized(); - } - _setExitRequest(pubKeyRoot, true); - emit ExitRequest(withdrawer, publicKey); - unchecked { - i += PUBLIC_KEY_LENGTH; - } - } + _revertIfSanctionedOrBlocked(msg.sender); + _requestExits(_publicKeys, msg.sender); } /// @notice Utility to stop or allow deposits @@ -753,6 +739,32 @@ contract StakingContract { StakingContractStorageLib.setDepositStopped(val); } + /// @notice Utility to ban a user, exits the validators provided if account is not OFAC sanctioned + /// @param _account Account to ban + /// @param _publicKeys Public keys to exit + function blockAccount(address _account, bytes calldata _publicKeys) external onlyAdmin { + StakingContractStorageLib.getBlocklist().value[_account] = true; + address sanctionsOracle = StakingContractStorageLib.getSanctionsOracle(); + if (sanctionsOracle != address(0)) { + if (ISanctionsOracle(sanctionsOracle).isSanctioned(_account)) { + return; + } + } + _requestExits(_publicKeys, _account); + } + + function unblock(address _account) external onlyAdmin { + StakingContractStorageLib.getBlocklist().value[_account] = false; + } + + function isBlockedOrSanctioned(address _account) public view returns (bool isBlocked, bool isSanctioned) { + address sanctionsOracle = StakingContractStorageLib.getSanctionsOracle(); + if (sanctionsOracle != address(0)) { + isSanctioned = ISanctionsOracle(sanctionsOracle).isSanctioned(_account); + } + isBlocked = StakingContractStorageLib.getBlocklist().value[_account]; + } + /// ██ ███ ██ ████████ ███████ ██████ ███ ██ █████ ██ /// ██ ████ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ /// ██ ██ ██ ██ ██ █████ ██████ ██ ██ ██ ███████ ██ @@ -798,6 +810,26 @@ contract StakingContract { StakingContractStorageLib.getExitRequestMap().value[_publicKeyRoot] = _value; } + function _requestExits(bytes calldata publicKeys, address owner) internal { + if (publicKeys.length % PUBLIC_KEY_LENGTH != 0) { + revert InvalidPublicKeys(); + } + + for (uint256 i = 0; i < publicKeys.length; ) { + bytes memory publicKey = BytesLib.slice(publicKeys, i, PUBLIC_KEY_LENGTH); + bytes32 pubKeyRoot = _getPubKeyRoot(publicKey); + address withdrawer = _getWithdrawer(pubKeyRoot); + if (owner != withdrawer) { + revert Unauthorized(); + } + _setExitRequest(pubKeyRoot, true); + emit ExitRequest(withdrawer, publicKey); + unchecked { + i += PUBLIC_KEY_LENGTH; + } + } + } + function _updateAvailableValidatorCount(uint256 _operatorIndex) internal { StakingContractStorageLib.ValidatorsFundingInfo memory validatorFundingInfo = StakingContractStorageLib .getValidatorsFundingInfo(_operatorIndex); @@ -909,7 +941,7 @@ contract StakingContract { if (StakingContractStorageLib.getDepositStopped()) { revert DepositsStopped(); } - _revertIfSanctioned(msg.sender); + _revertIfSanctionedOrBlocked(msg.sender); if (msg.value == 0 || msg.value % DEPOSIT_SIZE != 0) { revert InvalidDepositValue(); } @@ -953,7 +985,7 @@ contract StakingContract { ) internal { bytes32 publicKeyRoot = _getPubKeyRoot(_publicKey); address withdrawer = _getWithdrawer(publicKeyRoot); - _revertIfSanctioned(withdrawer); + _revertIfSanctionedOrBlocked(withdrawer); bytes32 feeRecipientSalt = sha256(abi.encodePacked(_prefix, publicKeyRoot)); address implementation = StakingContractStorageLib.getFeeRecipientImplementation(); address feeRecipientAddress = Clones.predictDeterministicAddress(implementation, feeRecipientSalt); @@ -970,12 +1002,15 @@ contract StakingContract { } } - function _revertIfSanctioned(address account) internal { + function _revertIfSanctionedOrBlocked(address account) internal { address sanctionsOracle = StakingContractStorageLib.getSanctionsOracle(); if (sanctionsOracle != address(0)) { if (ISanctionsOracle(sanctionsOracle).isSanctioned(account)) { revert AddressSanctioned(account); } } + if (StakingContractStorageLib.getBlocklist().value[account]) { + revert AddressBlocked(account); + } } } diff --git a/src/contracts/interfaces/ISanctionsOracle.sol b/src/contracts/interfaces/ISanctionsOracle.sol index d28efd4..1bc9e34 100644 --- a/src/contracts/interfaces/ISanctionsOracle.sol +++ b/src/contracts/interfaces/ISanctionsOracle.sol @@ -1,5 +1,5 @@ pragma solidity >=0.8.10; interface ISanctionsOracle { - function isSanctioned(address account) external returns (bool); + function isSanctioned(address account) external view returns (bool); } diff --git a/src/contracts/libs/StakingContractStorageLib.sol b/src/contracts/libs/StakingContractStorageLib.sol index 2cc6560..911412d 100644 --- a/src/contracts/libs/StakingContractStorageLib.sol +++ b/src/contracts/libs/StakingContractStorageLib.sol @@ -424,7 +424,8 @@ library StakingContractStorageLib { =========================================== =========================================*/ - bytes32 internal constant SANCTIONS_ORACLE_SLOT = bytes32(uint256(keccak256("StakingContract.sanctionsOracle")) - 1); + bytes32 internal constant SANCTIONS_ORACLE_SLOT = + bytes32(uint256(keccak256("StakingContract.sanctionsOracle")) - 1); function getSanctionsOracle() internal view returns (address) { return getAddress(SANCTIONS_ORACLE_SLOT); @@ -434,5 +435,20 @@ library StakingContractStorageLib { setAddress(SANCTIONS_ORACLE_SLOT, val); } - + /* ======================================== + =========================================== + =========================================*/ + + bytes32 internal constant BLOCKLIST_SLOT = bytes32(uint256(keccak256("StakingContract.blocklist")) - 1); + + struct BlockListMap { + mapping(address => bool) value; + } + + function getBlocklist() internal pure returns (BlockListMap storage p) { + bytes32 slot = BLOCKLIST_SLOT; + assembly { + p.slot := slot + } + } } diff --git a/src/test/StakingContract.t.sol b/src/test/StakingContract.t.sol index 97b4d74..39b3e09 100644 --- a/src/test/StakingContract.t.sol +++ b/src/test/StakingContract.t.sol @@ -2023,7 +2023,7 @@ contract StakingContractOneValidatorTest is Test { } contract SanctionsOracle { - mapping(address => bool) sanctionsMap; + mapping(address => bool) sanctionsMap; function isSanctioned(address user) public returns (bool) { return sanctionsMap[user]; @@ -2031,7 +2031,7 @@ contract SanctionsOracle { function setSanction(address user, bool status) public { sanctionsMap[user] = status; - } + } } contract StakingContractBehindProxyTest is Test { @@ -3416,4 +3416,104 @@ contract StakingContractBehindProxyTest is Test { vm.prank(bob); stakingContract.requestValidatorsExit(publicKeys); } + + function test_block__NoDeposit_UserNotSanctioned() public { + vm.prank(admin); + stakingContract.blockAccount(bob, ""); + + vm.deal(bob, 32 ether); + + (bool isBlocked, bool isSanctioned) = stakingContract.isBlockedOrSanctioned(bob); + + assertTrue(isBlocked); + + vm.expectRevert(abi.encodeWithSignature("AddressBlocked(address)", bob)); + vm.prank(bob); + stakingContract.deposit{value: 32 ether}(); + } + + function test_unblock__NoDeposit_UserNotSanctioned() public { + vm.prank(admin); + stakingContract.blockAccount(bob, ""); + + (bool isBlocked, bool isSanctioned) = stakingContract.isBlockedOrSanctioned(bob); + + assertTrue(isBlocked); + + vm.prank(admin); + stakingContract.unblock(bob); + + vm.deal(bob, 32 ether); + vm.prank(bob); + stakingContract.deposit{value: 32 ether}(); + } + + function getPubkeyRoot(bytes memory pubkey) public pure returns (bytes32) { + return sha256(abi.encodePacked(pubkey, bytes16(0))); + } + + function test_block_UserDepositOneValidator_NotSanctioned() public { + vm.deal(bob, 32 ether); + + vm.prank(bob); + stakingContract.deposit{value: 32 ether}(); + + bytes + memory publicKey = hex"21d2e725aef3a8f9e09d8f4034948bb7f79505fc7c40e7a7ca15734bad4220a594bf0c6257cef7db88d9fc3fd4360759"; + + vm.expectEmit(true, true, true, true); + emit ExitRequest(bob, publicKey); + vm.prank(admin); + stakingContract.blockAccount(bob, publicKey); + + (bool isBlocked, bool isSanctioned) = stakingContract.isBlockedOrSanctioned(bob); + + assertTrue(isBlocked); + + assertTrue(stakingContract.getExitRequestedFromRoot(getPubkeyRoot(publicKey))); + } + + function test_block_UserDepositOneValidator_Sanctioned() public { + vm.prank(admin); + stakingContract.setSanctionsOracle(address(oracle)); + + vm.deal(bob, 32 ether); + + vm.prank(bob); + stakingContract.deposit{value: 32 ether}(); + + bytes + memory publicKey = hex"21d2e725aef3a8f9e09d8f4034948bb7f79505fc7c40e7a7ca15734bad4220a594bf0c6257cef7db88d9fc3fd4360759"; + oracle.setSanction(bob, true); + + vm.prank(admin); + stakingContract.blockAccount(bob, publicKey); + + (bool isBlocked, bool isSanctioned) = stakingContract.isBlockedOrSanctioned(bob); + + assertTrue(isBlocked); + + assertFalse(stakingContract.getExitRequestedFromRoot(getPubkeyRoot(publicKey))); + } + + function test_block_UserDepositOneValidator_NotSanctioned_WrongPublicKey() public { + vm.prank(admin); + stakingContract.setSanctionsOracle(address(oracle)); + + vm.deal(bob, 32 ether); + + vm.prank(bob); + stakingContract.deposit{value: 32 ether}(); + + bytes + memory publicKey = hex"21d2e725aef3a8f9e09d8f4034948bb7f79505fc7c40e7a7ca15734bad4220a594bf0c6257cef7db88d9fc3fd4360759"; + bytes + memory wrongPublicKey = hex"ffffe725aef3a8f9e09d8f4034948bb7f79505fc7c40e7a7ca15734bad4220a594bf0c6257cef7db88d9fc3fd4360759"; + + vm.expectRevert(abi.encodeWithSignature("Unauthorized()")); + + vm.prank(admin); + stakingContract.blockAccount(bob, wrongPublicKey); + } } +// TODO test block does block exits and withdrawals