Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compliance features #101

Merged
merged 9 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .yarn/install-state.gz
Binary file not shown.
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,63 @@
# Staking Contracts changelog


## [latest](https://github.com/kilnfi/staking-contracts)

- [feat: add optional OFAC sanctions check](https://github.com/kilnfi/staking-contracts/pull/101/commits/4513c1e406c8dffe126bc450dfc02af510187933)
- [feat: blockList](https://github.com/kilnfi/staking-contracts/pull/101/commits/b680e4e017dfb57a4c97425f1ee85c118fd95e53)
- [refacto: refresh tests]()

## [1.2.0](https://github.com/kilnfi/staking-contracts/releases/tag/1.2.0)

This entry was created retroactively and is not exhaustive, the git history is fairly detailed and can be used to track minor changes not logged here (tests changes, gas opti, minor fixes). The history is best viewed on github to see the matching issues.

- [feat: implementation of batch withdrawal functions](https://github.com/kilnfi/staking-contracts/commit/eaaff6975dccb641b93e049f072c957a99854754)
- [feat: implementation of CL fee dispatching](https://github.com/kilnfi/staking-contracts/commit/8a2a7e0b61874b71e7d036e16425ec4e9bcf3835)
- [feat: requestValidatorExit()](https://github.com/kilnfi/staking-contracts/commit/757f17d8e187031332a2357427cfdc0a6de7717e)
- [feat: new CL dispatch logic](https://github.com/kilnfi/staking-contracts/commit/60387680768fd0c9da24ab097dd7953a2b8df19d)
- [feat: slashing logic removed](https://github.com/kilnfi/staking-contracts/commit/196a1bbb1b720b1134253890e3f7010c3f3143ee)
- [feat: immutable commission limits](https://github.com/kilnfi/staking-contracts/commit/ea9f10d58b131ee40560364137a046272fe6a62a)
- [fix: split initialization](https://github.com/kilnfi/staking-contracts/commit/3aa65764f2d868d41b52aceac092cdb43a59d7a9)
- [feat: stop deposits flag](https://github.com/kilnfi/staking-contracts/commit/aad23d5b1bec1ff6c8229f0b197a4575a800614c)
- [feat: restrict withdrawal function](https://github.com/kilnfi/staking-contracts/commit/ed1b36be629b13ac4b1f417eb0da084067ef803a)
- [feat: optional AuthorizedFeeRecipient](https://github.com/kilnfi/staking-contracts/commit/35acce30b033314906ec98395c53f4fb2844b61e)

### Audit fixes
- [remove multi operator logic](https://github.com/kilnfi/staking-contracts/commit/e5c91d8a08a5fd64bddb6b5a9e09f467e0b3bbc0)
- [remove Treasury contract](https://github.com/kilnfi/staking-contracts/commit/8306951add826c11f5decc427cb0ea6d6cd889ba)
- [reset operator index when removing validators](https://github.com/kilnfi/staking-contracts/commit/8def7d680a95f66137f16ddb53ca669bf099ab04)
- [implement snapshot mechanism for stored keys](https://github.com/kilnfi/staking-contracts/commit/dc6f050b3bf1f234e89d321037a9e353127dae8a)

### Deployments

- [Ledger Komainu mainnet deployment (now Kiln dApp)](https://github.com/kilnfi/staking-contracts/commit/a74d0810a2c97b2eaaa7763bd347ed30eed2b7e2)

- [Goerli deployment](https://github.com/kilnfi/staking-contracts/commit/fb1be197899b28b3ba72a2f3af752666b5125e81)

- [Updated implementations](https://github.com/kilnfi/staking-contracts/commit/f33eb8dc37fab40217dbe1e69853ca3fcd884a2d)

- [Holesky devenet & testnet](https://github.com/kilnfi/staking-contracts/commit/6df02b9c7d003504f1b57b7ef6d639ce963943dc)

- [Consensys immutable deployment](https://github.com/kilnfi/staking-contracts/commit/53f2d9b0d0662d1f5d44fab7f04684cca56df2fb)

- [Safe promotional deployment + testnet](https://github.com/kilnfi/staking-contracts/commit/af56cf295664d61ab0e23e45d5eabf780e4e59ab)

- [Safe second deployment](https://github.com/kilnfi/staking-contracts/commit/bb8e64d583ce31b03d7f5ff613931c7819621ddb)

## v0.2.2 (September 13th 2022)

### :dizzy: Features

- [feat: add missing events](https://github.com/kilnfi/staking-contracts/pull/61)

### Deployments

- [Enzyme mainnet deployment](https://github.com/kilnfi/staking-contracts/commit/42761e7837498c27798bd15e7d0886f3dea7180b)

- [Ledger Live mainnet deployment](https://github.com/kilnfi/staking-contracts/commit/cd680d350bfe4edacadccf01b6dd1484cd8a49b0)

- [Ledger Vault mainnet deployment](https://github.com/kilnfi/staking-contracts/commit/dd41162155a5e944731d544229f2763d1a99eb9e)

## v0.2.1 (August 26th 2022)

### :dizzy: Features
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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. Blocked users can still request exit and withdraw their funds unless they are also sanctioned.

If a user was wrongly banned or the ban is 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.
2 changes: 1 addition & 1 deletion lib/forge-std
175 changes: 114 additions & 61 deletions src/contracts/StakingContract.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import "./interfaces/IFeeRecipient.sol";
import "./interfaces/IDepositContract.sol";
import "./libs/StakingContractStorageLib.sol";
import "@openzeppelin/contracts/proxy/Clones.sol";
import "./interfaces/ISanctionsOracle.sol";

/// @title Ethereum Staking Contract
/// @author Kiln
Expand Down Expand Up @@ -49,14 +50,8 @@ contract StakingContract {
error MaximumOperatorCountAlreadyReached();
error LastEditAfterSnapshot();
error PublicKeyNotInContract();

struct ValidatorAllocationCache {
bool used;
uint8 operatorIndex;
uint32 funded;
uint32 toDeposit;
uint32 available;
}
error AddressSanctioned(address sanctionedAccount);
error AddressBlocked(address blockedAccount);

event Deposit(address indexed caller, address indexed withdrawer, bytes publicKey, bytes signature);
event ValidatorKeysAdded(uint256 indexed operatorIndex, bytes publicKeys, bytes signatures);
Expand All @@ -72,9 +67,10 @@ contract StakingContract {
event ChangedOperatorAddresses(uint256 operatorIndex, address operatorAddress, address feeRecipientAddress);
event DeactivatedOperator(uint256 _operatorIndex);
event ActivatedOperator(uint256 _operatorIndex);
event SetWithdrawerCustomizationStatus(bool _status);
event ExitRequest(address caller, bytes pubkey);
event ValidatorsEdited(uint256 blockNumber);
event NewSanctionsOracle(address sanctionsOracle);
event BeginOwnershipTransfer(address indexed previousAdmin, address indexed newAdmin);

/// @notice Ensures an initialisation call has been called only once per _version value
/// @param _version The current initialisation value
Expand Down Expand Up @@ -196,11 +192,19 @@ contract StakingContract {
StakingContractStorageLib.setOperatorCommissionLimit(operatorCommissionLimitBPS);
}

/// @notice Changes the behavior of the withdrawer customization logic
/// @param _enabled True to allow users to customize the withdrawer
function setWithdrawerCustomizationEnabled(bool _enabled) external onlyAdmin {
StakingContractStorageLib.setWithdrawerCustomizationEnabled(_enabled);
emit SetWithdrawerCustomizationStatus(_enabled);
/// @notice Changes the sanctions oracle address
/// @param _sanctionsOracle New sanctions oracle address
/// @dev If the address is address(0), the sanctions oracle checks are skipped
function setSanctionsOracle(address _sanctionsOracle) external onlyAdmin {
StakingContractStorageLib.setSanctionsOracle(_sanctionsOracle);
emit NewSanctionsOracle(_sanctionsOracle);
}

/// @notice Get the sanctions oracle address
/// @notice If the address is address(0), the sanctions oracle checks are skipped
/// @return sanctionsOracle The sanctions oracle address
function getSanctionsOracle() external view returns (address) {
return StakingContractStorageLib.getSanctionsOracle();
}

/// @notice Retrieve system admin
Expand Down Expand Up @@ -264,9 +268,20 @@ contract StakingContract {

/// @notice Retrieve withdrawer of public key root
/// @notice In case the validator is not enabled, it will return address(0)
/// @notice In case the owner of the validator is sanctioned, it will revert
/// @param _publicKeyRoot Hash of the public key
function getWithdrawerFromPublicKeyRoot(bytes32 _publicKeyRoot) external view returns (address) {
return _getWithdrawer(_publicKeyRoot);
address withdrawer = _getWithdrawer(_publicKeyRoot);
if (withdrawer == address(0)) {
return address(0);
}
address sanctionsOracle = StakingContractStorageLib.getSanctionsOracle();
if (sanctionsOracle != address(0)) {
if (ISanctionsOracle(sanctionsOracle).isSanctioned(withdrawer)) {
revert AddressSanctioned(withdrawer);
}
}
return withdrawer;
}

/// @notice Retrieve whether the validator exit has been requested
Expand Down Expand Up @@ -365,6 +380,7 @@ contract StakingContract {
/// @param _newAdmin New Administrator address
function transferOwnership(address _newAdmin) external onlyAdmin {
StakingContractStorageLib.setPendingAdmin(_newAdmin);
emit BeginOwnershipTransfer(msg.sender, _newAdmin);
}

/// @notice New admin must accept its role by calling this method
Expand Down Expand Up @@ -423,27 +439,6 @@ contract StakingContract {
emit ChangedOperatorAddresses(_operatorIndex, _operatorAddress, _feeRecipientAddress);
}

/// @notice Set withdrawer for public key
/// @dev Only callable by current public key withdrawer
/// @param _publicKey Public key to change withdrawer
/// @param _newWithdrawer New withdrawer address
function setWithdrawer(bytes calldata _publicKey, address _newWithdrawer) external {
if (!StakingContractStorageLib.getWithdrawerCustomizationEnabled()) {
revert Forbidden();
}
_checkAddress(_newWithdrawer);
bytes32 pubkeyRoot = _getPubKeyRoot(_publicKey);
StakingContractStorageLib.WithdrawersSlot storage withdrawers = StakingContractStorageLib.getWithdrawers();

if (withdrawers.value[pubkeyRoot] != msg.sender) {
revert Unauthorized();
}

emit ChangedWithdrawer(_publicKey, _newWithdrawer);

withdrawers.value[pubkeyRoot] = _newWithdrawer;
}

/// @notice Set operator staking limits
/// @dev Only callable by admin
/// @dev Limit should not exceed the validator key count of the operator
Expand Down Expand Up @@ -537,11 +532,11 @@ contract StakingContract {
revert InvalidArgument();
}

if (_publicKeys.length % PUBLIC_KEY_LENGTH != 0 || _publicKeys.length / PUBLIC_KEY_LENGTH != _keyCount) {
if (_publicKeys.length != PUBLIC_KEY_LENGTH * _keyCount) {
revert InvalidPublicKeys();
}

if (_signatures.length % SIGNATURE_LENGTH != 0 || _signatures.length / SIGNATURE_LENGTH != _keyCount) {
if (_signatures.length != SIGNATURE_LENGTH * _keyCount) {
revert InvalidSignatures();
}

Expand Down Expand Up @@ -719,22 +714,8 @@ contract StakingContract {
}

function requestValidatorsExit(bytes calldata _publicKeys) external {
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 (msg.sender != withdrawer) {
revert Unauthorized();
}
_setExitRequest(pubKeyRoot, true);
emit ExitRequest(withdrawer, publicKey);
unchecked {
i += PUBLIC_KEY_LENGTH;
}
}
_revertIfSanctioned(msg.sender);
_requestExits(_publicKeys, msg.sender);
}

/// @notice Utility to stop or allow deposits
Expand All @@ -743,6 +724,39 @@ contract StakingContract {
StakingContractStorageLib.setDepositStopped(val);
}

/// @notice Utility to ban a user, exits the validators provided if account is not OFAC sanctioned
/// @notice Blocks the account from depositing, the account is still alowed to exit & withdraw if not 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);
}

/// @notice Utility to unban a user
/// @param _account Account to unban
function unblock(address _account) external onlyAdmin {
StakingContractStorageLib.getBlocklist().value[_account] = false;
}

/// @notice Utility to check if an account is blocked or sanctioned
/// @param _account Account to check
/// @return isBlocked True if the account is blocked
/// @return isSanctioned True if the account is sanctioned, always false if not sanctions oracle is set
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];
}

/// ██ ███ ██ ████████ ███████ ██████ ███ ██ █████ ██
/// ██ ████ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██
/// ██ ██ ██ ██ ██ █████ ██████ ██ ██ ██ ███████ ██
Expand Down Expand Up @@ -788,6 +802,29 @@ contract StakingContract {
StakingContractStorageLib.getExitRequestMap().value[_publicKeyRoot] = _value;
}

/// @notice Function to emit the ExitRequest event for each public key
/// @param publicKeys Concatenated public keys
/// @param owner Address of the expected owner of the public keys
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);
Expand Down Expand Up @@ -899,6 +936,7 @@ contract StakingContract {
if (StakingContractStorageLib.getDepositStopped()) {
revert DepositsStopped();
}
_revertIfSanctionedOrBlocked(msg.sender);
if (msg.value == 0 || msg.value % DEPOSIT_SIZE != 0) {
revert InvalidDepositValue();
}
Expand All @@ -914,13 +952,6 @@ contract StakingContract {
_depositOnOneOperator(depositCount, totalAvailableValidators);
}

function _min(uint256 _a, uint256 _b) internal pure returns (uint256) {
if (_a < _b) {
return _a;
}
return _b;
}

/// @notice Internal utility to compute the receiver deterministic address
/// @param _publicKey Public Key assigned to the receiver
/// @param _prefix Prefix used to generate multiple receivers per public key
Expand All @@ -941,6 +972,7 @@ contract StakingContract {
address _dispatcher
) internal {
bytes32 publicKeyRoot = _getPubKeyRoot(_publicKey);
_revertIfSanctioned(msg.sender);
bytes32 feeRecipientSalt = sha256(abi.encodePacked(_prefix, publicKeyRoot));
address implementation = StakingContractStorageLib.getFeeRecipientImplementation();
address feeRecipientAddress = Clones.predictDeterministicAddress(implementation, feeRecipientSalt);
Expand All @@ -956,4 +988,25 @@ contract StakingContract {
revert InvalidZeroAddress();
}
}

function _revertIfSanctionedOrBlocked(address account) internal view {
address sanctionsOracle = StakingContractStorageLib.getSanctionsOracle();
if (sanctionsOracle != address(0)) {
if (ISanctionsOracle(sanctionsOracle).isSanctioned(account)) {
revert AddressSanctioned(account);
}
}
if (StakingContractStorageLib.getBlocklist().value[account]) {
revert AddressBlocked(account);
}
}

function _revertIfSanctioned(address account) internal view {
address sanctionsOracle = StakingContractStorageLib.getSanctionsOracle();
if (sanctionsOracle != address(0)) {
if (ISanctionsOracle(sanctionsOracle).isSanctioned(account)) {
revert AddressSanctioned(account);
}
}
}
}
5 changes: 5 additions & 0 deletions src/contracts/interfaces/ISanctionsOracle.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pragma solidity >=0.8.10;

interface ISanctionsOracle {
function isSanctioned(address account) external view returns (bool);
}
Loading
Loading