diff --git a/.gitmodules b/.gitmodules index b56a365..9d66330 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "contracts/lib/Warlord"] path = contracts/lib/Warlord url = https://github.com/0xtekgrinder/Warlord +[submodule "contracts/lib/solady"] + path = contracts/lib/solady + url = https://github.com/Vectorized/solady diff --git a/contracts/.vscode/settings.json b/contracts/.vscode/settings.json new file mode 100644 index 0000000..22e095c --- /dev/null +++ b/contracts/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "solidity.packageDefaultDependenciesContractsDirectory": "src", + "solidity.packageDefaultDependenciesDirectory": "lib", + "solidity.compileUsingRemoteVersion": "v0.8.20", + "[solidity]": { + "editor.defaultFormatter": "JuanBlanco.solidity" + }, + "solidity.formatter": "forge", + "search.exclude": { "lib": true }, +} \ No newline at end of file diff --git a/contracts/lib/solady b/contracts/lib/solady new file mode 160000 index 0000000..16c0dda --- /dev/null +++ b/contracts/lib/solady @@ -0,0 +1 @@ +Subproject commit 16c0dda5838fbb1350a0735dffa564b0b0a11e7e diff --git a/contracts/remappings.txt b/contracts/remappings.txt index 2d8ed05..d884778 100644 --- a/contracts/remappings.txt +++ b/contracts/remappings.txt @@ -3,4 +3,6 @@ forge-std/=lib/forge-std/src/ openzeppelin-contracts/=lib/openzeppelin-contracts/contracts solmate/=lib/solmate/src/ warlord/=lib/Warlord/src/ -solgen/=lib/solidity-generators/src/ \ No newline at end of file +solgen/=lib/solidity-generators/src/ +solady/=lib/solady/src/ +src=src/ \ No newline at end of file diff --git a/contracts/src/abstracts/AIncentiveClaimer.sol b/contracts/src/abstracts/AIncentiveClaimer.sol new file mode 100644 index 0000000..95c831c --- /dev/null +++ b/contracts/src/abstracts/AIncentiveClaimer.sol @@ -0,0 +1,106 @@ +//SPDX-License-Identifier GPL-3.0-or-later +pragma solidity 0.8.20; + +import "interfaces/IIncentivizedLocker.sol"; +import { + IQuestDistributor, + IDelegationDistributor, + IVotiumDistributor, + IHiddenHandDistributor +} from "warlord/interfaces/external/incentives/IIncentivesDistributors.sol"; +import { Errors } from "utils/Errors.sol"; +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; +import { ERC20 } from "solady/tokens/ERC20.sol"; +import { ReentrancyGuard } from "solmate/utils/ReentrancyGuard.sol"; + +/** + * @title Incentivized Locker contract + * @author Paladin + * @notice Locker contract capable of claiming vote rewards from different sources + */ +abstract contract AIncentiveClaimer is IIncentivizedLocker, ReentrancyGuard { + /** + * @notice Claims voting rewards from Quest + * @param distributor Address of the contract distributing the rewards + * @param questID ID of the Quest to claim rewards from + * @param period Timestamp of the Quest period to claim + * @param index Index in the Merkle Tree + * @param account Address claiming the rewards + * @param amount Amount to claim + * @param merkleProof Merkle Proofs for the claim + */ + function claimQuestRewards( + address distributor, + uint256 questID, + uint256 period, + uint256 index, + address account, + uint256 amount, + bytes32[] calldata merkleProof + ) external nonReentrant { + IQuestDistributor _distributor = IQuestDistributor(distributor); + ERC20 _token = ERC20(_distributor.questRewardToken(questID)); + + _distributor.claim(questID, period, index, account, amount, merkleProof); + } + + /** + * @notice Claims voting rewards from the Paladin Delegation address + * @param distributor Address of the contract distributing the rewards + * @param token Address of the reward token to claim + * @param index Index in the Merkle Tree + * @param account Address claiming the rewards + * @param amount Amount to claim + * @param merkleProof Merkle Proofs for the claim + */ + function claimDelegationRewards( + address distributor, + address token, + uint256 index, + address account, + uint256 amount, + bytes32[] calldata merkleProof + ) external nonReentrant { + IDelegationDistributor(distributor).claim(token, index, account, amount, merkleProof); + } + + // TODO mayybe delete this function for liquis + /** + * @notice Claims voting rewards from Votium + * @param distributor Address of the contract distributing the rewards + * @param token Address of the reward token to claim + * @param index Index in the Merkle Tree + * @param account Address claiming the rewards + * @param amount Amount to claim + * @param merkleProof Merkle Proofs for the claim + */ + function claimVotiumRewards( + address distributor, + address token, + uint256 index, + address account, + uint256 amount, + bytes32[] calldata merkleProof + ) external nonReentrant { + IVotiumDistributor(distributor).claim(token, index, account, amount, merkleProof); + } + + /** + * @notice Claims voting rewards from HiddenHand + * @param distributor Address of the contract distributing the rewards + * @param claimParams Parameters for claims + */ + function claimHiddenHandRewards(address distributor, IHiddenHandDistributor.Claim[] calldata claimParams) + external + nonReentrant + { + require(claimParams.length == 1); + + IHiddenHandDistributor _distributor = IHiddenHandDistributor(distributor); + address token = _distributor.rewards(claimParams[0].identifier).token; + + uint256 initialBalance = ERC20(token).balanceOf(address(this)); + + _distributor.claim(claimParams); + } +} diff --git a/contracts/src/abstracts/APounder.sol b/contracts/src/abstracts/APounder.sol new file mode 100644 index 0000000..7d8ce2a --- /dev/null +++ b/contracts/src/abstracts/APounder.sol @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.20; + +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; +import { AIncentiveClaimer } from "./AIncentiveClaimer.sol"; +import { AProtocolClaimer } from "./AProtocolClaimer.sol"; +import { Ownable } from "solady/auth/Ownable.sol"; +import { ERC20 } from "solady/tokens/ERC20.sol"; + +abstract contract APounder is ERC20, AIncentiveClaimer, AProtocolClaimer, Ownable { + address public asset; + address public swapper; + /** + * @notice Address of the voting power delegate + */ + address public delegate; + + // The amount of CVX that needs to remain unlocked for redemptions + uint256 public outstandingRedemptions; + + // The amount of new CVX deposits that is awaiting lock + uint256 public pendingLocks; + + /** + * @notice Event emitted when the delegate is updated + */ + event SetDelegate(address newDelegatee); + + constructor(address initialSwapper, address definitiveAsset) { + asset = definitiveAsset; + swapper = initialSwapper; + } + + /** + * @dev Updates the Delegatee & delegates the voting power + * @param _delegatee Address of the delegatee + */ + function _setDelegate(address _delegatee) internal virtual; + + /** + * @notice Updates the Delegatee & delegates the voting power + * @param _delegatee Address of the delegatee + */ + function setDelegate(address _delegatee) external onlyOwner { + delegate = _delegatee; + _setDelegate(_delegatee); + + emit SetDelegate(_delegatee); + } + + function _lock(uint256 amount) internal virtual; + + /** + * @dev Locks the tokens in the vlToken contract + * @param amount Amount to lock + */ + function lock() external { + _processUnlock(); + + uint256 balance = asset.balanceOf(address(this)); + bool balanceGreaterThanRedemptions = balance > outstandingRedemptions; + + // Lock CVX if the balance is greater than outstanding redemptions or if there are pending locks + if (balanceGreaterThanRedemptions || pendingLocks != 0) { + uint256 balanceRedemptionsDifference = balanceGreaterThanRedemptions + ? balance - outstandingRedemptions + : 0; + + // Lock amount is the greater of the two: balanceRedemptionsDifference or pendingLocks + // balanceRedemptionsDifference is greater if there is unlocked CVX that isn't reserved for redemptions + deposits + // pendingLocks is greater if there are more new deposits than unlocked CVX that is reserved for redemptions + _lock( + balanceRedemptionsDifference > pendingLocks + ? balanceRedemptionsDifference + : pendingLocks + ); + + pendingLocks = 0; + } + } + + /** + * @dev Processes the unlock of tokens + */ + function _processUnlock() internal virtual; + + function deposit(uint256 amount, address receiver) external { + if (amount == 0) revert ZeroAmount(); + if (receiver == address(0)) revert ZeroAddress(); + + pendingLocks += amount; + + // Transfer the tokens to the contract + SafeTransferLib.safeTransferFrom(asset, msg.sender, address(this), amount); + + _mint(receiver, amount); + } + + function _initiateRedemption( + ICvxLocker.LockedBalance memory lockData, + Futures f, + uint256 assets, + address receiver, + uint256 feeMin, + uint256 feeMax + ) internal returns (uint256 feeAmount) { + if (assets == 0) revert ZeroAmount(); + if (receiver == address(0)) revert ZeroAddress(); + + uint256 unlockTime = lockData.unlockTime; + + // Used for calculating the fee and conditionally adding a round + uint256 waitTime = unlockTime - block.timestamp; + + if (feeMax != 0) { + uint256 feePercent = feeMax - + (((feeMax - feeMin) * waitTime) / MAX_REDEMPTION_TIME); + + feeAmount = (assets * feePercent) / FEE_DENOMINATOR; + } + + uint256 postFeeAmount = assets - feeAmount; + + // Increment redemptions for this unlockTime to prevent over-redeeming + redemptions[unlockTime] += postFeeAmount; + + // Check if there is any sufficient allowance after factoring in redemptions by others + if (redemptions[unlockTime] > lockData.amount) + revert InsufficientRedemptionAllowance(); + + // Track assets that needs to remain unlocked for redemptions + outstandingRedemptions += postFeeAmount; + + // Mint upxCVX with unlockTime as the id - validates `to` + upxCvx.mint(receiver, unlockTime, postFeeAmount, UNUSED_1155_DATA); + + return feeAmount; + } + + function initiateRedemptions( + uint256[] calldata lockIndexes, + Futures f, + uint256[] calldata assets, + address receiver + ) external whenNotPaused nonReentrant { + uint256 lockLen = lockIndexes.length; + + if (lockLen == 0) revert EmptyArray(); + if (lockLen != assets.length) revert MismatchedArrayLengths(); + + emit InitiateRedemptions(lockIndexes, f, assets, receiver); + + (, , , ICvxLocker.LockedBalance[] memory lockData) = cvxLocker + .lockedBalances(address(this)); + uint256 totalAssets; + uint256 feeAmount; + uint256 feeMin = fees[Fees.RedemptionMin]; + uint256 feeMax = fees[Fees.RedemptionMax]; + + for (uint256 i; i < lockLen; ++i) { + totalAssets += assets[i]; + feeAmount += _initiateRedemption( + lockData[lockIndexes[i]], + f, + assets[i], + receiver, + feeMin, + feeMax + ); + } + + // Burn pxCVX - reverts if sender balance is insufficient + pxCvx.burn(msg.sender, totalAssets - feeAmount); + + if (feeAmount != 0) { + // Allow PirexFees to distribute fees directly from sender + pxCvx.operatorApprove(msg.sender, address(pirexFees), feeAmount); + + // Distribute fees + pirexFees.distributeFees(msg.sender, address(pxCvx), feeAmount); + } + } + + function _redeem( + uint256[] calldata unlockTimes, + uint256[] calldata assets, + address receiver, + bool legacy + ) internal { + uint256 unlockLen = unlockTimes.length; + + if (unlockLen == 0) revert EmptyArray(); + if (unlockLen != assets.length) revert MismatchedArrayLengths(); + if (receiver == address(0)) revert ZeroAddress(); + + emit Redeem(unlockTimes, assets, receiver, legacy); + + uint256 totalAssets; + + for (uint256 i; i < unlockLen; ++i) { + uint256 asset = assets[i]; + + if (!legacy && unlockTimes[i] > block.timestamp) + revert BeforeUnlock(); + if (asset == 0) revert ZeroAmount(); + + totalAssets += asset; + } + + // Perform unlocking and locking procedure to ensure enough CVX is available + if (!legacy) { + _lock(); + } + + // Subtract redemption amount from outstanding CVX amount + outstandingRedemptions -= totalAssets; + + // Reverts if sender has an insufficient upxCVX balance for any `unlockTime` id + upxCvx.burnBatch(msg.sender, unlockTimes, assets); + + // Validates `to` + asset.safeTransfer(receiver, totalAssets); + } + + function redeem( + uint256[] calldata unlockTimes, + uint256[] calldata assets, + address receiver + ) external whenNotPaused nonReentrant { + if (upxCvxDeprecated) revert RedeemClosed(); + + _redeem(unlockTimes, assets, receiver, false); + } + + /** + @notice Manually unlock CVX in the case of a mass unlock + */ + function unlock() external onlyOwner { + _processUnlock(); + } +} diff --git a/contracts/src/abstracts/AProtocolClaimer.sol b/contracts/src/abstracts/AProtocolClaimer.sol new file mode 100644 index 0000000..d89cebf --- /dev/null +++ b/contracts/src/abstracts/AProtocolClaimer.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.20; + +abstract contract AProtocolClaimer { + event ProtocolRewardsHarvest(); + + /** + * @dev Harvest rewards & send them to the Controller + */ + function _harvest() internal virtual; + + /** + * @notice Harvest rewards + */ + function harvest() external { + emit ProtocolRewardsHarvest(); + _harvest(); + } +} diff --git a/contracts/src/interfaces/IIncentiveClaimer.sol b/contracts/src/interfaces/IIncentiveClaimer.sol new file mode 100644 index 0000000..9508d0c --- /dev/null +++ b/contracts/src/interfaces/IIncentiveClaimer.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity ^0.8.0; + +import { IHiddenHandDistributor } from "interfaces/external/incentives/IIncentivesDistributors.sol"; + +interface IIncentiveClaimer { + function claimQuestRewards( + address distributor, + uint256 questID, + uint256 period, + uint256 index, + address account, + uint256 amount, + bytes32[] calldata merkleProof + ) external; + + function claimDelegationRewards( + address distributor, + address token, + uint256 index, + address account, + uint256 amount, + bytes32[] calldata merkleProof + ) external; + + function claimVotiumRewards( + address distributor, + address token, + uint256 index, + address account, + uint256 amount, + bytes32[] calldata merkleProof + ) external; + + function claimHiddenHandRewards(address distributor, IHiddenHandDistributor.Claim[] calldata claimParams) + external; +}