diff --git a/src/escrows/StakeDaoEscrow.sol b/src/escrows/StakeDaoEscrow.sol new file mode 100644 index 0000000..77a9f11 --- /dev/null +++ b/src/escrows/StakeDaoEscrow.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IMarket} from "src/interfaces/IMarket.sol"; + +interface IRewardVault is IERC20 { + function deposit(uint256 assets, address receiver, address referrer) external returns (uint256); + function withdraw(uint256 assets, address receiver, address owner) external returns (uint256); + function claim(address[] calldata tokens, address receiver) external returns (uint256[] memory amounts); + function previewRedeem(uint256 shares) external view returns (uint256); + function asset() external view returns (address); + function ACCOUNTANT() external view returns (address); + function gauge() external view returns (address); +} + +interface IAccountant { + function REWARD_TOKEN() external view returns (address); + function claim(address[] calldata gauges, bytes[] calldata harvestData, address receiver) external; +} + +contract StakeDaoEscrow { + using SafeERC20 for IERC20; + + error AlreadyInitialized(); + error OnlyMarket(); + error OnlyBeneficiary(); + error OnlyBeneficiaryOrAllowlist(); + error WrongCollateral(); + error InvalidReceiver(); + + IRewardVault public immutable rewardVault; + IAccountant public immutable accountant; + address public immutable gauge; + address public immutable baseRewardToken; + address public immutable treasury; + + address public market; + IERC20 public token; + address public beneficiary; + + mapping(address => bool) public allowlist; + + modifier onlyBeneficiary() { + if (msg.sender != beneficiary) revert OnlyBeneficiary(); + _; + } + + modifier onlyBeneficiaryOrAllowlist() { + if (msg.sender != beneficiary && !allowlist[msg.sender]) { + revert OnlyBeneficiaryOrAllowlist(); + } + _; + } + + event SetClaimer(address indexed claimer, bool isAllowed); + event Claim(address caller, address receiver, address[] tokens, address baseRewardToken, uint256[] amounts); + + constructor(address _rewardVault, address _treasury) { + rewardVault = IRewardVault(_rewardVault); + accountant = IAccountant(rewardVault.ACCOUNTANT()); + gauge = rewardVault.gauge(); + baseRewardToken = accountant.REWARD_TOKEN(); + treasury = _treasury; + } + + /** + * @notice Initialize escrow with a token + * @dev Must be called right after proxy is created. + * @param _token The market asset + * @param _beneficiary The beneficiary who the token is staked on behalf + */ + function initialize(IERC20 _token, address _beneficiary) public { + if (market != address(0)) revert AlreadyInitialized(); + if (address(_token) != rewardVault.asset()) revert WrongCollateral(); + market = msg.sender; + if (address(_token) != IMarket(market).collateral()) revert WrongCollateral(); + token = _token; + token.approve(address(rewardVault), type(uint256).max); + beneficiary = _beneficiary; + } + + /** + * @notice Withdraws the wrapped token from the reward pool and transfers the associated ERC20 token to a recipient. + * @param recipient The address to receive payment from the escrow + * @param amount The amount of ERC20 token to be transferred. + */ + function pay(address recipient, uint256 amount) public { + if (msg.sender != market) revert OnlyMarket(); + uint256 tokenBal = token.balanceOf(address(this)); + + if (tokenBal < amount) { + //Withdraw needed amount of tokens to this address + rewardVault.withdraw(amount - tokenBal, address(this), address(this)); + } + + token.safeTransfer(recipient, amount); + } + + /** + * @notice Get the token balance of the escrow + * @return Uint representing the token balance of the escrow + */ + function balance() public view returns (uint256) { + return rewardVault.previewRedeem(rewardVault.balanceOf(address(this))) + token.balanceOf(address(this)); + } + + /** + * @notice Function called by market on deposit. Stakes deposited collateral into Stake DAO reward vault + * @dev This function should remain callable by anyone to handle direct inbound transfers. + */ + function onDeposit() public { + uint256 tokenBal = token.balanceOf(address(this)); + if (tokenBal == 0) return; + rewardVault.deposit(tokenBal, address(this), treasury); + } + + /** + * @notice Claims reward tokens to the specified address. Only callable by beneficiary and allowlisted addresses + * @param tokens Array of reward token address to claim to `to` address + * @param to Address to send claimed rewards to + */ + function claim(address[] calldata tokens, address to) public onlyBeneficiaryOrAllowlist { + _claim(tokens, to); + } + + /** + * @notice Claims reward tokens to the specified address. Only callable by beneficiary and allowlisted addresses + * @param tokens Array of reward token address to claim to `to` address + */ + function claim(address[] calldata tokens) public onlyBeneficiary { + _claim(tokens, msg.sender); + } + + /** + * @notice Claims reward tokens to the message sender. Only callable by beneficiary + * @param tokens Array of reward token addresses to claim + */ + function _claim(address[] calldata tokens, address to) internal { + if (to == address(0) || to == address(this)) revert InvalidReceiver(); + //Claim base reward token (crv, bal, etc.) when available. + try accountant.claim(_gaugeArray(), new bytes[](0), to) {} catch {} + //Claim extra reward tokens (cvx, etc.) + uint256[] memory amounts = rewardVault.claim(tokens, to); + emit Claim(msg.sender, to, tokens, baseRewardToken, amounts); + } + + function _gaugeArray() internal view returns (address[] memory gauges) { + gauges = new address[](1); + gauges[0] = gauge; + } + + /** + * @notice Allow address to claim on behalf of the beneficiary to any address + * @param claimer Address that is allowed or disallowed to claim + * @param isAllowed whether `claimer` address is allowed to claim or not + * @dev Can be used to build contracts for auto-compounding or auto-claiming + */ + function setClaimer(address claimer, bool isAllowed) external onlyBeneficiary { + allowlist[claimer] = isAllowed; + emit SetClaimer(claimer, isAllowed); + } +} diff --git a/test/escrowForkTests/StakeDaoEscrowFork.t.sol b/test/escrowForkTests/StakeDaoEscrowFork.t.sol new file mode 100644 index 0000000..2515ec7 --- /dev/null +++ b/test/escrowForkTests/StakeDaoEscrowFork.t.sol @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {Market, IOracle, IDolaBorrowingRights} from "src/Market.sol"; +import {IERC20} from "src/interfaces/IERC20.sol"; +import { + StakeDaoEscrow, + IRewardVault +} from "src/escrows/StakeDaoEscrow.sol"; + +interface IStakeDaoRewardVault is IRewardVault { + function depositRewards( + address rewardsToken, + uint128 amount + ) external; + + function getRewardsDistributor( + address token + ) external view returns (address); + + function getRewardTokens() external view returns (address[] memory); +} + +contract StakeDaoEscrowForkTest is Test { + uint256 internal constant FORK_BLOCK = 25_076_112; + + address internal constant GOV = + 0x926dF14a23BE491164dCF93f4c468A50ef659D5B; + address internal constant LENDER = address(0xA11CE); + address internal constant PAUSE_GUARDIAN = address(0xB0B); + address internal constant TREASURY = + 0x926dF14a23BE491164dCF93f4c468A50ef659D5B; + address internal constant USER = address(0xBEEF); + address internal constant FRIEND = address(0xCAFE); + address internal constant RECEIVER = address(0xD00D); + + address internal constant DBR = + 0xAD038Eb671c44b853887A7E32528FaB35dC5D710; + address internal constant WETH = + 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + address internal constant REWARD_VAULT = + 0xCA137e3853Eab95541290B372223e7F2ee4c0cFa; + address internal constant CRV_FRX_USD_LP = + 0x13e12BB0E6A2f1A3d6901a59a9d585e89A6243e1; + address internal constant CVX = + 0x4e3FBD56CD56c3e72c1403e103b45Db9da5B9D2B; + address internal constant CRV = + 0xD533a949740bb3306d119CC777fa900bA034cd52; + + uint256 internal constant DEPOSIT_AMOUNT = 10_000 ether; + uint256 internal constant REWARD_AMOUNT = 10_000 ether; + + IERC20 internal lpToken; + IERC20 internal cvx; + IERC20 internal crv; + IStakeDaoRewardVault internal rewardVault; + StakeDaoEscrow internal escrowImplementation; + Market internal market; + + function setUp() public { + string memory url = vm.rpcUrl("mainnet"); + vm.createSelectFork(url, FORK_BLOCK); + + lpToken = IERC20(CRV_FRX_USD_LP); + cvx = IERC20(CVX); + crv = IERC20(CRV); + rewardVault = IStakeDaoRewardVault(REWARD_VAULT); + escrowImplementation = new StakeDaoEscrow(REWARD_VAULT, TREASURY); + market = _deployMarket(CRV_FRX_USD_LP, true); + + vm.label(USER, "user"); + vm.label(FRIEND, "friend"); + vm.label(RECEIVER, "receiver"); + vm.label(CRV_FRX_USD_LP, "crvUSD/frxUSD LP"); + vm.label(REWARD_VAULT, "sd-crvfrxUSD-vault"); + vm.label(CVX, "CVX"); + vm.label(CRV, "CRV"); + } + + function testMarketDepositCreatesAndStakesLpEscrow() public { + StakeDaoEscrow escrow = _deposit(DEPOSIT_AMOUNT); + + assertEq(address(escrow.market()), address(market), "market"); + assertEq(escrow.beneficiary(), USER, "beneficiary"); + assertEq(address(escrow.token()), CRV_FRX_USD_LP, "token"); + assertEq(address(escrow.rewardVault()), REWARD_VAULT, "vault"); + assertEq(address(escrow.accountant()), rewardVault.ACCOUNTANT(), "accountant"); + assertEq(escrow.gauge(), rewardVault.gauge(), "gauge"); + assertEq(escrow.baseRewardToken(), CRV, "base reward token"); + assertEq(rewardVault.asset(), CRV_FRX_USD_LP, "vault asset"); + + assertEq(lpToken.balanceOf(address(escrow)), 0, "unstaked LP"); + assertEq( + rewardVault.balanceOf(address(escrow)), + DEPOSIT_AMOUNT, + "vault shares" + ); + assertEq(escrow.balance(), DEPOSIT_AMOUNT, "escrow balance"); + assertEq( + market.getWithdrawalLimit(USER), + DEPOSIT_AMOUNT, + "withdrawal limit" + ); + } + + function testMarketWithdrawUnstakesAndPaysLp() public { + StakeDaoEscrow escrow = _deposit(DEPOSIT_AMOUNT); + + uint256 half = DEPOSIT_AMOUNT / 2; + uint256 userBalanceBefore = lpToken.balanceOf(USER); + uint256 vaultBalanceBefore = rewardVault.balanceOf(address(escrow)); + + vm.prank(USER, USER); + market.withdraw(half); + + assertEq(lpToken.balanceOf(USER), userBalanceBefore + half, "half paid"); + assertEq( + rewardVault.balanceOf(address(escrow)), + vaultBalanceBefore - half, + "half unstaked" + ); + assertEq(escrow.balance(), DEPOSIT_AMOUNT - half, "half remaining"); + + vm.prank(USER, USER); + market.withdrawMax(); + + assertEq( + lpToken.balanceOf(USER), + userBalanceBefore + DEPOSIT_AMOUNT, + "all paid" + ); + assertEq(rewardVault.balanceOf(address(escrow)), 0, "shares cleared"); + assertEq(lpToken.balanceOf(address(escrow)), 0, "LP cleared"); + assertEq(escrow.balance(), 0, "escrow cleared"); + } + + function testClaimCvxRewardsThroughStakeDaoEscrow() public { + StakeDaoEscrow escrow = _deposit(DEPOSIT_AMOUNT); + address distributor = rewardVault.getRewardsDistributor(CVX); + assertTrue(distributor != address(0), "missing distributor"); + + deal(CVX, distributor, REWARD_AMOUNT, false); + vm.startPrank(distributor, distributor); + cvx.approve(address(rewardVault), REWARD_AMOUNT); + rewardVault.depositRewards(CVX, uint128(REWARD_AMOUNT)); + vm.stopPrank(); + + vm.warp(block.timestamp + 1 days); + + address[] memory rewardTokens = _cvxRewardTokens(); + uint256 beneficiaryBalanceBefore = cvx.balanceOf(USER); + + vm.prank(USER); + escrow.claim(rewardTokens); + + assertGt( + cvx.balanceOf(USER), + beneficiaryBalanceBefore, + "CVX not claimed" + ); + assertEq(cvx.balanceOf(address(escrow)), 0, "escrow CVX dust"); + } + + function testAllowlistedClaimerReceivesCrvBaseRewardToken() public { + StakeDaoEscrow escrow = _deposit(DEPOSIT_AMOUNT); + address[] memory rewardTokens = new address[](0); + + vm.prank(USER); + escrow.setClaimer(FRIEND, true); + + vm.warp(block.timestamp + 7 days); + _depositMore(1 ether); + + uint256 receiverBalanceBefore = crv.balanceOf(RECEIVER); + + vm.prank(FRIEND); + escrow.claim(rewardTokens, RECEIVER); + + assertGt( + crv.balanceOf(RECEIVER), + receiverBalanceBefore, + "CRV not claimed" + ); + assertEq(crv.balanceOf(address(escrow)), 0, "escrow CRV dust"); + } + + function testClaimAccessControl() public { + StakeDaoEscrow escrow = _deposit(DEPOSIT_AMOUNT); + address[] memory rewardTokens = _cvxRewardTokens(); + + vm.prank(FRIEND); + vm.expectRevert(StakeDaoEscrow.OnlyBeneficiaryOrAllowlist.selector); + escrow.claim(rewardTokens, RECEIVER); + + vm.prank(FRIEND); + vm.expectRevert(StakeDaoEscrow.OnlyBeneficiary.selector); + escrow.claim(rewardTokens); + + vm.prank(FRIEND); + vm.expectRevert(StakeDaoEscrow.OnlyBeneficiary.selector); + escrow.setClaimer(FRIEND, true); + + vm.prank(USER); + escrow.setClaimer(FRIEND, true); + assertTrue(escrow.allowlist(FRIEND), "friend not allowlisted"); + + vm.prank(FRIEND); + escrow.claim(rewardTokens, RECEIVER); + + vm.prank(USER); + vm.expectRevert(StakeDaoEscrow.InvalidReceiver.selector); + escrow.claim(rewardTokens, address(0)); + + vm.prank(USER); + vm.expectRevert(StakeDaoEscrow.InvalidReceiver.selector); + escrow.claim(rewardTokens, address(escrow)); + } + + function testMarketDepositRevertsForWrongCollateral() public { + StakeDaoEscrow wrongCollateralImplementation = new StakeDaoEscrow( + REWARD_VAULT, + TREASURY + ); + Market wrongCollateralMarket = new Market( + GOV, + LENDER, + PAUSE_GUARDIAN, + address(wrongCollateralImplementation), + IDolaBorrowingRights(DBR), + IERC20(WETH), + IOracle(address(0)), + 5000, + 5000, + 1000, + true + ); + + vm.prank(USER, USER); + vm.expectRevert(StakeDaoEscrow.WrongCollateral.selector); + wrongCollateralMarket.deposit(1); + } + + function _deployMarket( + address collateral, + bool callOnDepositCallback + ) internal returns (Market deployedMarket) { + deployedMarket = new Market( + GOV, + LENDER, + PAUSE_GUARDIAN, + address(escrowImplementation), + IDolaBorrowingRights(DBR), + IERC20(collateral), + IOracle(address(0)), + 5000, + 5000, + 1000, + callOnDepositCallback + ); + } + + function _deposit( + uint256 amount + ) internal returns (StakeDaoEscrow escrow) { + deal(CRV_FRX_USD_LP, USER, amount, false); + escrow = StakeDaoEscrow(address(market.predictEscrow(USER))); + + vm.startPrank(USER, USER); + lpToken.approve(address(market), amount); + market.deposit(amount); + vm.stopPrank(); + } + + function _depositMore(uint256 amount) internal { + deal(CRV_FRX_USD_LP, USER, amount, false); + + vm.startPrank(USER, USER); + lpToken.approve(address(market), amount); + market.deposit(amount); + vm.stopPrank(); + } + + function _cvxRewardTokens() internal pure returns (address[] memory tokens) { + tokens = new address[](1); + tokens[0] = CVX; + } +}