From 053c51c2e3e188b8bd1173f62c63dbb76a19646d Mon Sep 17 00:00:00 2001 From: 08xmt Date: Sun, 10 May 2026 15:45:08 +0200 Subject: [PATCH 1/6] Add StakeDAO Escrow --- src/escrows/StakeDaoEscrow.sol | 132 +++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/escrows/StakeDaoEscrow.sol diff --git a/src/escrows/StakeDaoEscrow.sol b/src/escrows/StakeDaoEscrow.sol new file mode 100644 index 0000000..77c6fe2 --- /dev/null +++ b/src/escrows/StakeDaoEscrow.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; + +interface IRewardVault is IERC20{ + function deposit(address account, address receiver, uint assets, 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); +} + +contract StakeDaoEscrow { + using SafeERC20 for IERC20; + + error AlreadyInitialized(); + error OnlyMarket(); + error OnlyBeneficiary(); + error OnlyBeneficiaryOrAllowlist(); + + + IRewardVault public immutable rewardVault; + 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); + + constructor( + address _rewardVault, + address _treasury + ) { + rewardVault = IRewardVault(_rewardVault); + 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(); + market = msg.sender; + token = _token; + token.approve(address(rewardVault), type(uint).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, uint 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 (uint) { + 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(address(this), address(this), tokenBal, 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 claimTo(address[] calldata tokens, address to) public onlyBeneficiaryOrAllowlist { + rewardVault.claim(tokens, to); + emit Claim(msg.sender, to, tokens); + } + + /** + @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) external onlyBeneficiary { + rewardVault.claim(tokens, msg.sender); + emit Claim(msg.sender, msg.sender, tokens); + } + + /** + @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); + } +} From 903c9814e704e42369482e3df5b8c25083b84489 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Mon, 11 May 2026 14:18:45 +0200 Subject: [PATCH 2/6] Update with harry feedback --- src/escrows/StakeDaoEscrow.sol | 54 ++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/src/escrows/StakeDaoEscrow.sol b/src/escrows/StakeDaoEscrow.sol index 77c6fe2..9aabf5a 100644 --- a/src/escrows/StakeDaoEscrow.sol +++ b/src/escrows/StakeDaoEscrow.sol @@ -4,10 +4,21 @@ pragma solidity ^0.8.13; import "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; interface IRewardVault is IERC20{ - function deposit(address account, address receiver, uint assets, address referrer) external returns(uint256); + function deposit(address receiver, uint assets, 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); +} + +interface IAccountant { + function REWARD_TOKEN() external view returns(address); + function claim(address[] calldata gauges, bytes[] calldata harvestData, address receiver) external; + function pendingRewards(address vault, address account) external view returns (uint128); +} + +interface IMarket { + function collateral() external view returns(address); } contract StakeDaoEscrow { @@ -17,9 +28,14 @@ contract StakeDaoEscrow { error OnlyMarket(); error OnlyBeneficiary(); error OnlyBeneficiaryOrAllowlist(); + error WrongCollateral(); IRewardVault public immutable rewardVault; + IAccountant public immutable accountant; + address public immutable gauge; + address[] public gaugeArray; + IERC20 public immutable baseRewardToken; address public immutable treasury; address public market; @@ -40,13 +56,18 @@ contract StakeDaoEscrow { } event SetClaimer(address indexed claimer, bool isAllowed); - event Claim(address caller, address receiver, address[] tokens); + event Claim(address caller, address receiver, address[] tokens, address baseRewardToken, uint256[] amounts); constructor( address _rewardVault, + address _accountant, + address _gauge, address _treasury ) { rewardVault = IRewardVault(_rewardVault); + accountant = IAccountant(_accountant); + gauge = _gauge; + baseRewardToken = IERC20(accountant.REWARD_TOKEN()); treasury = _treasury; } @@ -58,10 +79,13 @@ contract StakeDaoEscrow { */ function initialize(IERC20 _token, address _beneficiary) public { if (market != address(0)) revert AlreadyInitialized(); + if(address(_token) != rewardVault.asset()) revert WrongCollateral(); + if(address(_token) != IMarket(market).collateral()) revert WrongCollateral(); market = msg.sender; token = _token; token.approve(address(rewardVault), type(uint).max); beneficiary = _beneficiary; + gaugeArray.push(gauge); } /** @@ -97,7 +121,7 @@ contract StakeDaoEscrow { function onDeposit() public { uint256 tokenBal = token.balanceOf(address(this)); if (tokenBal == 0) return; - rewardVault.deposit(address(this), address(this), tokenBal, treasury); + rewardVault.deposit(address(this), tokenBal, treasury); } /** @@ -105,18 +129,30 @@ contract StakeDaoEscrow { @param tokens Array of reward token address to claim to `to` address @param to Address to send claimed rewards to */ - function claimTo(address[] calldata tokens, address to) public onlyBeneficiaryOrAllowlist { - rewardVault.claim(tokens, to); - emit Claim(msg.sender, to, tokens); + 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) external onlyBeneficiary { - rewardVault.claim(tokens, msg.sender); - emit Claim(msg.sender, msg.sender, tokens); + function _claim(address[] calldata tokens, address to) internal { + //Claim base reward token (crv, bal, etc.) if there's a balance + if(accountant.pendingRewards(address(rewardVault), msg.sender) > 0) + accountant.claim(gaugeArray, new bytes[](0), to); + //Claim extra reward tokens (cvx, etc.) + uint256[] memory amounts = rewardVault.claim(tokens, to); + emit Claim(msg.sender, to, tokens, address(baseRewardToken), amounts); } /** From 3d8c1454a2c4820919348f12889653d09f914010 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Mon, 11 May 2026 14:27:50 +0200 Subject: [PATCH 3/6] Fix errors --- src/escrows/StakeDaoEscrow.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/escrows/StakeDaoEscrow.sol b/src/escrows/StakeDaoEscrow.sol index 9aabf5a..2d30715 100644 --- a/src/escrows/StakeDaoEscrow.sol +++ b/src/escrows/StakeDaoEscrow.sol @@ -80,8 +80,8 @@ contract StakeDaoEscrow { function initialize(IERC20 _token, address _beneficiary) public { if (market != address(0)) revert AlreadyInitialized(); if(address(_token) != rewardVault.asset()) revert WrongCollateral(); - if(address(_token) != IMarket(market).collateral()) revert WrongCollateral(); market = msg.sender; + if(address(_token) != IMarket(market).collateral()) revert WrongCollateral(); token = _token; token.approve(address(rewardVault), type(uint).max); beneficiary = _beneficiary; @@ -148,7 +148,7 @@ contract StakeDaoEscrow { */ function _claim(address[] calldata tokens, address to) internal { //Claim base reward token (crv, bal, etc.) if there's a balance - if(accountant.pendingRewards(address(rewardVault), msg.sender) > 0) + if(accountant.pendingRewards(address(rewardVault), address(this)) > 0) accountant.claim(gaugeArray, new bytes[](0), to); //Claim extra reward tokens (cvx, etc.) uint256[] memory amounts = rewardVault.claim(tokens, to); From 772233d15ce9e4acd047782e94c7d76dfaea4126 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Mon, 11 May 2026 14:59:21 +0200 Subject: [PATCH 4/6] Add nits --- src/escrows/StakeDaoEscrow.sol | 140 ++++++++++++++++----------------- 1 file changed, 69 insertions(+), 71 deletions(-) diff --git a/src/escrows/StakeDaoEscrow.sol b/src/escrows/StakeDaoEscrow.sol index 2d30715..edb2b2f 100644 --- a/src/escrows/StakeDaoEscrow.sol +++ b/src/escrows/StakeDaoEscrow.sol @@ -2,23 +2,21 @@ pragma solidity ^0.8.13; import "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; - -interface IRewardVault is IERC20{ - function deposit(address receiver, uint assets, 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); +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 REWARD_TOKEN() external view returns (address); function claim(address[] calldata gauges, bytes[] calldata harvestData, address receiver) external; - function pendingRewards(address vault, address account) external view returns (uint128); -} - -interface IMarket { - function collateral() external view returns(address); } contract StakeDaoEscrow { @@ -29,13 +27,12 @@ contract StakeDaoEscrow { error OnlyBeneficiary(); error OnlyBeneficiaryOrAllowlist(); error WrongCollateral(); - + error InvalidReceiver(); IRewardVault public immutable rewardVault; IAccountant public immutable accountant; address public immutable gauge; - address[] public gaugeArray; - IERC20 public immutable baseRewardToken; + address public immutable baseRewardToken; address public immutable treasury; address public market; @@ -50,50 +47,48 @@ contract StakeDaoEscrow { } modifier onlyBeneficiaryOrAllowlist() { - if (msg.sender != beneficiary && !allowlist[msg.sender]) + 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 _accountant, - address _gauge, - address _treasury - ) { - rewardVault = IRewardVault(_rewardVault); - accountant = IAccountant(_accountant); - gauge = _gauge; - baseRewardToken = IERC20(accountant.REWARD_TOKEN()); + constructor(address _rewardVault, address _treasury) { + IRewardVault _vault = IRewardVault(_rewardVault); + IAccountant _accountant = IAccountant(_vault.ACCOUNTANT()); + + rewardVault = _vault; + accountant = _accountant; + gauge = _vault.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 - */ + * @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(); + if (address(_token) != rewardVault.asset()) revert WrongCollateral(); market = msg.sender; - if(address(_token) != IMarket(market).collateral()) revert WrongCollateral(); + if (address(_token) != IMarket(market).collateral()) revert WrongCollateral(); token = _token; - token.approve(address(rewardVault), type(uint).max); + token.approve(address(rewardVault), type(uint256).max); beneficiary = _beneficiary; - gaugeArray.push(gauge); } /** - @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, uint amount) public { + * @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)); @@ -106,61 +101,64 @@ contract StakeDaoEscrow { } /** - @notice Get the token balance of the escrow - @return Uint representing the token balance of the escrow - */ - function balance() public view returns (uint) { - return rewardVault.previewRedeem(rewardVault.balanceOf(address(this))) + - token.balanceOf(address(this)); + * @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. - */ + * @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(address(this), tokenBal, treasury); + 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 - */ + * @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 - */ + * @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 - */ + * @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 { - //Claim base reward token (crv, bal, etc.) if there's a balance - if(accountant.pendingRewards(address(rewardVault), address(this)) > 0) - accountant.claim(gaugeArray, new bytes[](0), to); + 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, address(baseRewardToken), amounts); + 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 - */ + * @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); From c2c1f8cfe6f156447a5e0ed2e0522d138c931755 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Tue, 12 May 2026 04:36:17 +0200 Subject: [PATCH 5/6] Clean up code --- src/escrows/StakeDaoEscrow.sol | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/escrows/StakeDaoEscrow.sol b/src/escrows/StakeDaoEscrow.sol index edb2b2f..77a9f11 100644 --- a/src/escrows/StakeDaoEscrow.sol +++ b/src/escrows/StakeDaoEscrow.sol @@ -57,13 +57,10 @@ contract StakeDaoEscrow { event Claim(address caller, address receiver, address[] tokens, address baseRewardToken, uint256[] amounts); constructor(address _rewardVault, address _treasury) { - IRewardVault _vault = IRewardVault(_rewardVault); - IAccountant _accountant = IAccountant(_vault.ACCOUNTANT()); - - rewardVault = _vault; - accountant = _accountant; - gauge = _vault.gauge(); - baseRewardToken = _accountant.REWARD_TOKEN(); + rewardVault = IRewardVault(_rewardVault); + accountant = IAccountant(rewardVault.ACCOUNTANT()); + gauge = rewardVault.gauge(); + baseRewardToken = accountant.REWARD_TOKEN(); treasury = _treasury; } From 7942bbae26f1644088f2b695a64ff260ccac7c61 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Tue, 12 May 2026 05:08:16 +0200 Subject: [PATCH 6/6] Add integration fork test --- test/escrowForkTests/StakeDaoEscrowFork.t.sol | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 test/escrowForkTests/StakeDaoEscrowFork.t.sol 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; + } +}