From 12ea1e3da1fdd8cf57df474114d628c5890bc1d5 Mon Sep 17 00:00:00 2001 From: cosminobol Date: Wed, 23 Oct 2024 09:38:13 +0300 Subject: [PATCH] feat: added OWR pectra v0.1 --- .../OptimisticWithdrawalRecipientPectra.sol | 329 ++++++++++++++++++ ...misticWithdrawalRecipientPectraFactory.sol | 120 +++++++ src/test/owr/pectra/PectraWithdrawalMock.sol | 34 ++ 3 files changed, 483 insertions(+) create mode 100644 src/owr/OptimisticWithdrawalRecipientPectra.sol create mode 100644 src/owr/OptimisticWithdrawalRecipientPectraFactory.sol create mode 100644 src/test/owr/pectra/PectraWithdrawalMock.sol diff --git a/src/owr/OptimisticWithdrawalRecipientPectra.sol b/src/owr/OptimisticWithdrawalRecipientPectra.sol new file mode 100644 index 0000000..c740d3a --- /dev/null +++ b/src/owr/OptimisticWithdrawalRecipientPectra.sol @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.19; + +import {Clone} from "solady/utils/Clone.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; + +/// @title OptimisticWithdrawalRecipientPectra +/// @author Obol +/// @notice A maximally-composable contract that distributes payments +/// based on threshold to it's recipients +/// @dev Only ETH can be distributed for a given deployment. There is a +/// recovery method for tokens sent by accident. +contract OptimisticWithdrawalRecipientPectra is Clone { + /// ----------------------------------------------------------------------- + /// libraries + /// ----------------------------------------------------------------------- + + using SafeTransferLib for address; + + /// ----------------------------------------------------------------------- + /// errors + /// ----------------------------------------------------------------------- + + /// Invalid token recovery recipient + error InvalidTokenRecovery_InvalidRecipient(); + + /// Invalid distribution + error InvalidDistribution_TooLarge(); + + /// Invalid withdrawal + error InvalidWithdrawal_Failed(); + + /// ----------------------------------------------------------------------- + /// events + /// ----------------------------------------------------------------------- + + /// Emitted after each successful ETH transfer to proxy + /// @param amount Amount of ETH received + /// @dev embedded in & emitted from clone bytecode + event ReceiveETH(uint256 amount); + + /// Emitted after funds are distributed to recipients + /// @param principalPayout Amount of principal paid out + /// @param rewardPayout Amount of reward paid out + /// @param pullFlowFlag Flag for pushing funds to recipients or storing for + /// pulling + event DistributeFunds(uint256 principalPayout, uint256 rewardPayout, uint256 pullFlowFlag); + + /// Emitted after tokens are recovered to a recipient + /// @param recoveryAddressToken Recovered token (cannot be + /// ETH) + /// @param recipient Address receiving recovered token + /// @param amount Amount of recovered token + event RecoverNonOWRecipientFunds(address recoveryAddressToken, address recipient, uint256 amount); + + /// Emitted after funds withdrawn using pull flow + /// @param account Account withdrawing funds for + /// @param amount Amount withdrawn + event Withdrawal(address account, uint256 amount); + + /// ----------------------------------------------------------------------- + /// storage + /// ----------------------------------------------------------------------- + + /// ----------------------------------------------------------------------- + /// storage - constants + /// ----------------------------------------------------------------------- + uint256 internal constant PUSH = 0; + uint256 internal constant PULL = 1; + + uint256 internal constant ONE_WORD = 32; + uint256 internal constant ADDRESS_BITS = 160; + + /// @dev threshold for pushing balance update as reward or principal + uint256 internal constant BALANCE_CLASSIFICATION_THRESHOLD = 16 ether; + uint256 internal constant PRINCIPAL_RECIPIENT_INDEX = 0; + uint256 internal constant REWARD_RECIPIENT_INDEX = 1; + + /// ----------------------------------------------------------------------- + /// storage - cwia offsets + /// ----------------------------------------------------------------------- + + // recoveryAddress (address, 20 bytes), + // tranches (uint256[], numTranches * 32 bytes) + + // 0; first item + uint256 internal constant PECTRA_WITHDRAWAL_ADDRESS_OFFSET = 0; + uint256 internal constant RECOVERY_ADDRESS_OFFSET = 20; + // 40 = withdrawalAddress_offset(0) + withdrawalAddress_size(address, 20 bytes) + recoveryAddress_offset (20) + recoveryAddress_size (address, 20 + // bytes) + uint256 internal constant TRANCHES_OFFSET = 40; + + /// Address to recover non-OWR tokens to + /// @dev equivalent to address public immutable recoveryAddress; + function recoveryAddress() public pure returns (address) { + return _getArgAddress(RECOVERY_ADDRESS_OFFSET); + } + + /// Address to recover non-OWR tokens to + /// @dev equivalent to address public immutable recoveryAddress; + function pectraWithdrawalAddress() public pure returns (address) { + return _getArgAddress(PECTRA_WITHDRAWAL_ADDRESS_OFFSET); + } + + /// Get OWR tranche `i` + /// @dev emulates to uint256[] internal immutable tranche; + function _getTranche(uint256 i) internal pure returns (uint256) { + unchecked { + // shouldn't overflow + return _getArgUint256(TRANCHES_OFFSET + i * ONE_WORD); + } + } + + /// ----------------------------------------------------------------------- + /// storage - mutables + /// ----------------------------------------------------------------------- + + /// Amount of active balance set aside for pulls + /// @dev ERC20s with very large decimals may overflow & cause issues + uint128 public fundsPendingWithdrawal; + + /// Amount of distributed OWRecipient token for principal + /// @dev Would be less than or equal to amount of stake + /// @dev ERC20s with very large decimals may overflow & cause issues + uint128 public claimedPrincipalFunds; + + /// Mapping to account balances for pulling + mapping(address => uint256) internal pullBalances; + + /// ----------------------------------------------------------------------- + /// constructor + /// ----------------------------------------------------------------------- + + // solhint-disable-next-line no-empty-blocks + /// clone implementation doesn't use constructor + constructor() {} + + /// ----------------------------------------------------------------------- + /// functions + /// ----------------------------------------------------------------------- + + /// ----------------------------------------------------------------------- + /// functions - public & external + /// ----------------------------------------------------------------------- + + /// emit event when receiving ETH + /// @dev implemented w/i clone bytecode + /* receive() external payable { */ + /* emit ReceiveETH(msg.value); */ + /* } */ + + /// Requests withdrawal + function requestWithdrawal(bytes calldata data) external payable { + (bool ret, ) = pectraWithdrawalAddress().call{value: msg.value}(data); + if (!ret) revert InvalidWithdrawal_Failed(); + } + + /// Distributes target token inside the contract to recipients + /// @dev pushes funds to recipients + function distributeFunds() external payable { + _distributeFunds(PUSH); + } + + /// Distributes target token inside the contract to recipients + /// @dev backup recovery if any recipient tries to brick the OWRecipient for + /// remaining recipients + function distributeFundsPull() external payable { + _distributeFunds(PULL); + } + + /// Recover non-OWR tokens to a recipient + /// @param nonOWRToken Token to recover (cannot be OWR token) + /// @param recipient Address to receive recovered token + function recoverFunds(address nonOWRToken, address recipient) external payable { + /// checks + + // if recoveryAddress is set, recipient must match it + // else, recipient must be one of the OWR recipients + + address _recoveryAddress = recoveryAddress(); + if (_recoveryAddress == address(0)) { + // ensure txn recipient is a valid OWR recipient + (address principalRecipient, address rewardRecipient,) = getTranches(); + if (recipient != principalRecipient && recipient != rewardRecipient) { + revert InvalidTokenRecovery_InvalidRecipient(); + } + } else if (recipient != _recoveryAddress) { + revert InvalidTokenRecovery_InvalidRecipient(); + } + + /// effects + + /// interactions + + // recover non-target token + uint256 amount = ERC20(nonOWRToken).balanceOf(address(this)); + nonOWRToken.safeTransfer(recipient, amount); + + emit RecoverNonOWRecipientFunds(nonOWRToken, recipient, amount); + } + + /// Withdraw token balance for account `account` + /// @param account Address to withdraw on behalf of + function withdraw(address account) external { + uint256 tokenAmount = pullBalances[account]; + unchecked { + // shouldn't underflow; fundsPendingWithdrawal = sum(pullBalances) + fundsPendingWithdrawal -= uint128(tokenAmount); + } + pullBalances[account] = 0; + account.safeTransferETH(tokenAmount); + + emit Withdrawal(account, tokenAmount); + } + + /// ----------------------------------------------------------------------- + /// functions - view & pure + /// ----------------------------------------------------------------------- + + /// Return unpacked tranches + /// @return principalRecipient Addres of principal recipient + /// @return rewardRecipient Address of reward recipient + /// @return amountOfPrincipalStake Absolute payment threshold for principal + function getTranches() + public + pure + returns (address principalRecipient, address rewardRecipient, uint256 amountOfPrincipalStake) + { + uint256 tranche = _getTranche(PRINCIPAL_RECIPIENT_INDEX); + principalRecipient = address(uint160(tranche)); + amountOfPrincipalStake = tranche >> ADDRESS_BITS; + + rewardRecipient = address(uint160(_getTranche(REWARD_RECIPIENT_INDEX))); + } + + /// Returns the balance for account `account` + /// @param account Account to return balance for + /// @return Account's balance OWR token + function getPullBalance(address account) external view returns (uint256) { + return pullBalances[account]; + } + + /// ----------------------------------------------------------------------- + /// functions - private & internal + /// ----------------------------------------------------------------------- + + /// Distributes target token inside the contract to next-in-line recipients + /// @dev can PUSH or PULL funds to recipients + function _distributeFunds(uint256 pullFlowFlag) internal { + /// checks + + /// effects + + // load storage into memory + uint256 currentbalance = address(this).balance; + uint256 _claimedPrincipalFunds = uint256(claimedPrincipalFunds); + uint256 _memoryFundsPendingWithdrawal = uint256(fundsPendingWithdrawal); + uint256 _fundsToBeDistributed = currentbalance - _memoryFundsPendingWithdrawal; + + (address principalRecipient, address rewardRecipient, uint256 amountOfPrincipalStake) = getTranches(); + + // determine which recipeint is getting paid based on funds to be + // distributed + uint256 _principalPayout = 0; + uint256 _rewardPayout = 0; + + unchecked { + // _claimedPrincipalFunds should always be <= amountOfPrincipalStake + uint256 principalStakeRemaining = amountOfPrincipalStake - _claimedPrincipalFunds; + + if (_fundsToBeDistributed >= BALANCE_CLASSIFICATION_THRESHOLD && principalStakeRemaining > 0) { + if (_fundsToBeDistributed > principalStakeRemaining) { + // this means there is reward part of the funds to be + // distributed + _principalPayout = principalStakeRemaining; + // shouldn't underflow + _rewardPayout = _fundsToBeDistributed - principalStakeRemaining; + } else { + // this means there is no reward part of the funds to be + // distributed + _principalPayout = _fundsToBeDistributed; + } + } else { + _rewardPayout = _fundsToBeDistributed; + } + } + + { + if (_fundsToBeDistributed > type(uint128).max) revert InvalidDistribution_TooLarge(); + // Write to storage + // the principal value + // it cannot overflow because _principalPayout < _fundsToBeDistributed + if (_principalPayout > 0) claimedPrincipalFunds += uint128(_principalPayout); + } + + /// interactions + + // pay outs + // earlier tranche recipients may try to re-enter but will cause fn to + // revert + // when later external calls fail (bc balance is emptied early) + + // pay out principal + _payout(principalRecipient, _principalPayout, pullFlowFlag); + // pay out reward + _payout(rewardRecipient, _rewardPayout, pullFlowFlag); + + if (pullFlowFlag == PULL) { + if (_principalPayout > 0 || _rewardPayout > 0) { + // Write to storage + fundsPendingWithdrawal = uint128(_memoryFundsPendingWithdrawal + _principalPayout + _rewardPayout); + } + } + + emit DistributeFunds(_principalPayout, _rewardPayout, pullFlowFlag); + } + + function _payout(address recipient, uint256 payoutAmount, uint256 pullFlowFlag) internal { + if (payoutAmount > 0) { + if (pullFlowFlag == PULL) { + // Write to Storage + pullBalances[recipient] += payoutAmount; + } else { + recipient.safeTransferETH(payoutAmount); + } + } + } +} diff --git a/src/owr/OptimisticWithdrawalRecipientPectraFactory.sol b/src/owr/OptimisticWithdrawalRecipientPectraFactory.sol new file mode 100644 index 0000000..6e1b74e --- /dev/null +++ b/src/owr/OptimisticWithdrawalRecipientPectraFactory.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.19; + +import {OptimisticWithdrawalRecipientPectra} from "./OptimisticWithdrawalRecipientPectra.sol"; +import {LibClone} from "solady/utils/LibClone.sol"; +import {IENSReverseRegistrar} from "../interfaces/IENSReverseRegistrar.sol"; + +/// @title OptimisticWithdrawalRecipientPectraFactory +/// @author Obol +/// @notice A factory contract for cheaply deploying +/// OptimisticWithdrawalRecipientPectra. +/// @dev This contract uses token = address(0) to refer to ETH. +contract OptimisticWithdrawalRecipientPectraFactory { + /// ----------------------------------------------------------------------- + /// errors + /// ----------------------------------------------------------------------- + + /// Invalid number of recipients, must be 2 + error Invalid__Recipients(); + + /// Thresholds must be positive + error Invalid__ZeroThreshold(); + + /// Invalid threshold at `index`; must be < 2^96 + /// @param threshold threshold of too-large threshold + error Invalid__ThresholdTooLarge(uint256 threshold); + + /// ----------------------------------------------------------------------- + /// libraries + /// ----------------------------------------------------------------------- + + using LibClone for address; + + /// ----------------------------------------------------------------------- + /// events + /// ----------------------------------------------------------------------- + + /// Emitted after a new OptimisticWithdrawalRecipientPectra module is deployed + /// @param owr Address of newly created OptimisticWithdrawalRecipientPectra clone + /// @param recoveryAddress Address to recover non-OWR tokens to + /// @param principalRecipient Address to distribute principal payment to + /// @param rewardRecipient Address to distribute reward payment to + /// @param threshold Absolute payment threshold for OWR first recipient + /// (reward recipient has no threshold & receives all residual flows) + event CreateOWRecipient( + address indexed owr, address recoveryAddress, address principalRecipient, address rewardRecipient, uint256 threshold + ); + + /// ----------------------------------------------------------------------- + /// storage + /// ----------------------------------------------------------------------- + + uint256 internal constant ADDRESS_BITS = 160; + + + /// OptimisticWithdrawalRecipientPectra implementation address + OptimisticWithdrawalRecipientPectra public immutable owrImpl; + + address public immutable pectraWithdrawalAddress; + + /// ----------------------------------------------------------------------- + /// constructor + /// ----------------------------------------------------------------------- + + constructor(string memory _ensName, address _ensReverseRegistrar, address _ensOwner, address _pectraWithdrawalAddress) { + owrImpl = new OptimisticWithdrawalRecipientPectra(); + IENSReverseRegistrar(_ensReverseRegistrar).setName(_ensName); + IENSReverseRegistrar(_ensReverseRegistrar).claim(_ensOwner); + + pectraWithdrawalAddress = _pectraWithdrawalAddress; + } + + /// ----------------------------------------------------------------------- + /// functions + /// ----------------------------------------------------------------------- + + /// ----------------------------------------------------------------------- + /// functions - public & external + /// ----------------------------------------------------------------------- + + /// Create a new OptimisticWithdrawalRecipientPectra clone + /// @param recoveryAddress Address to recover tokens to + /// If this address is 0x0, recovery of unrelated tokens can be completed by + /// either the principal or reward recipients. If this address is set, only + /// this address can recover + /// tokens (or ether) that isn't the token of the OWRecipient contract + /// @param principalRecipient Address to distribute principal payments to + /// @param rewardRecipient Address to distribute reward payments to + /// @param amountOfPrincipalStake Absolute amount of stake to be paid to + /// principal recipient (multiple of 32 ETH) + /// (reward recipient has no threshold & receives all residual flows) + /// it cannot be greater than uint96 + /// @return owr Address of new OptimisticWithdrawalRecipientPectra clone + function createOWRecipient( + address recoveryAddress, + address principalRecipient, + address rewardRecipient, + uint256 amountOfPrincipalStake + ) external returns (OptimisticWithdrawalRecipientPectra owr) { + /// checks + + // ensure doesn't have address(0) + if (principalRecipient == address(0) || rewardRecipient == address(0)) revert Invalid__Recipients(); + // ensure threshold isn't zero + if (amountOfPrincipalStake == 0) revert Invalid__ZeroThreshold(); + // ensure threshold isn't too large + if (amountOfPrincipalStake > type(uint96).max) revert Invalid__ThresholdTooLarge(amountOfPrincipalStake); + + /// effects + uint256 principalData = (amountOfPrincipalStake << ADDRESS_BITS) | uint256(uint160(principalRecipient)); + uint256 rewardData = uint256(uint160(rewardRecipient)); + + // would not exceed contract size limits + // important to not reorder + bytes memory data = abi.encodePacked(pectraWithdrawalAddress, recoveryAddress, principalData, rewardData); + owr = OptimisticWithdrawalRecipientPectra(address(owrImpl).clone(data)); + + emit CreateOWRecipient(address(owr), recoveryAddress, principalRecipient, rewardRecipient, amountOfPrincipalStake); + } +} diff --git a/src/test/owr/pectra/PectraWithdrawalMock.sol b/src/test/owr/pectra/PectraWithdrawalMock.sol new file mode 100644 index 0000000..0bc210a --- /dev/null +++ b/src/test/owr/pectra/PectraWithdrawalMock.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.19; + +import "forge-std/console.sol"; + +contract PectraWithdrawalMock { + + uint64 public receivedAmount; + + fallback() external payable { + // Input data has the following layout: + // + // +--------+--------+ + // | pubkey | amount | + // +--------+--------+ + // 48 8 + bytes memory data = msg.data; + + bytes memory pubkey = new bytes(48); + assembly { + pubkey := mload(add(data, 48)) + } + + uint64 amount; + assembly { + let word := mload(add(data, 56)) + + // Extract the last 8 bytes (uint64) + amount := and(shr(192, word), 0xFFFFFFFFFFFFFFFF) + } + + receivedAmount = amount; + } +} \ No newline at end of file