From b680e4e017dfb57a4c97425f1ee85c118fd95e53 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..ef65654 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. If the user is not sanctioned the admin can provide validator public keys that will be exited if they are indeed owned by the blocked user. + +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