From 053c51c2e3e188b8bd1173f62c63dbb76a19646d Mon Sep 17 00:00:00 2001 From: 08xmt Date: Sun, 10 May 2026 15:45:08 +0200 Subject: [PATCH 01/11] 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 00000000..77c6fe25 --- /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 02/11] 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 77c6fe25..9aabf5ad 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 03/11] 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 9aabf5ad..2d307155 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 04/11] 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 2d307155..edb2b2f0 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 05/11] 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 edb2b2f0..77a9f112 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 06/11] 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 00000000..2515ec7a --- /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; + } +} From 865b5aa2c15aa336209e816565525d4b1c05d85d Mon Sep 17 00:00:00 2001 From: 08xmt Date: Wed, 3 Jun 2026 03:27:18 +0200 Subject: [PATCH 07/11] Add NoCallBack deploy error --- src/escrows/StakeDaoEscrow.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/escrows/StakeDaoEscrow.sol b/src/escrows/StakeDaoEscrow.sol index 77a9f112..f793bc0c 100644 --- a/src/escrows/StakeDaoEscrow.sol +++ b/src/escrows/StakeDaoEscrow.sol @@ -28,6 +28,7 @@ contract StakeDaoEscrow { error OnlyBeneficiaryOrAllowlist(); error WrongCollateral(); error InvalidReceiver(); + error NoCallbackInMarket(); IRewardVault public immutable rewardVault; IAccountant public immutable accountant; @@ -75,6 +76,7 @@ contract StakeDaoEscrow { if (address(_token) != rewardVault.asset()) revert WrongCollateral(); market = msg.sender; if (address(_token) != IMarket(market).collateral()) revert WrongCollateral(); + if(!IMarket(market).callOnDepositCallback()) revert NoCallbackInMarket(); token = _token; token.approve(address(rewardVault), type(uint256).max); beneficiary = _beneficiary; From f82eebfe9f5c8ca148b6c99704d9040950cbee81 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Wed, 3 Jun 2026 05:29:31 +0200 Subject: [PATCH 08/11] Remove Callback check from StakeDaoEscrow --- src/escrows/StakeDaoEscrow.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/escrows/StakeDaoEscrow.sol b/src/escrows/StakeDaoEscrow.sol index f793bc0c..77a9f112 100644 --- a/src/escrows/StakeDaoEscrow.sol +++ b/src/escrows/StakeDaoEscrow.sol @@ -28,7 +28,6 @@ contract StakeDaoEscrow { error OnlyBeneficiaryOrAllowlist(); error WrongCollateral(); error InvalidReceiver(); - error NoCallbackInMarket(); IRewardVault public immutable rewardVault; IAccountant public immutable accountant; @@ -76,7 +75,6 @@ contract StakeDaoEscrow { if (address(_token) != rewardVault.asset()) revert WrongCollateral(); market = msg.sender; if (address(_token) != IMarket(market).collateral()) revert WrongCollateral(); - if(!IMarket(market).callOnDepositCallback()) revert NoCallbackInMarket(); token = _token; token.approve(address(rewardVault), type(uint256).max); beneficiary = _beneficiary; From 904852807e137304289166898e36f61677f72497 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Thu, 4 Jun 2026 16:09:18 +0200 Subject: [PATCH 09/11] Add StakeDaoEscrow factory --- src/factory/StakeDaoEscrowFactory.sol | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/factory/StakeDaoEscrowFactory.sol diff --git a/src/factory/StakeDaoEscrowFactory.sol b/src/factory/StakeDaoEscrowFactory.sol new file mode 100644 index 00000000..10fa9273 --- /dev/null +++ b/src/factory/StakeDaoEscrowFactory.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {StakeDaoEscrow} from "src/escrows/StakeDaoEscrow.sol"; + +contract StakeDaoEscrowFactory { + mapping(address => bool) public isFromFactory; + + event NewStakeDaoEscrow( + address indexed rewardVault, address indexed treasury, address indexed escrowImplementation + ); + + /** + * @notice Deploy a new StakeDaoEscrow implementation. + * @dev Markets use this implementation to create borrower-specific escrow clones. + * @param rewardVault StakeDAO reward vault used by the escrow implementation + * @param treasury Referrer address passed to the reward vault on deposits + */ + function deployEscrow(address rewardVault, address treasury) external returns (address escrowImplementation) { + escrowImplementation = address(new StakeDaoEscrow(rewardVault, treasury)); + isFromFactory[escrowImplementation] = true; + + emit NewStakeDaoEscrow(rewardVault, treasury, escrowImplementation); + } +} From 45d638e4f85928f540690b746590f1dd2b4ffb5e Mon Sep 17 00:00:00 2001 From: 08xmt Date: Mon, 15 Jun 2026 14:52:00 +0200 Subject: [PATCH 10/11] Add interface reference in comments --- src/escrows/StakeDaoEscrow.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/escrows/StakeDaoEscrow.sol b/src/escrows/StakeDaoEscrow.sol index 77a9f112..cdf3463b 100644 --- a/src/escrows/StakeDaoEscrow.sol +++ b/src/escrows/StakeDaoEscrow.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.13; import "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; import {IMarket} from "src/interfaces/IMarket.sol"; +// RewardVault implementation: https://etherscan.io/address/0x74D8dd40118B13B210D0a1639141cE4458CAe0c0 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); @@ -14,6 +15,7 @@ interface IRewardVault is IERC20 { function gauge() external view returns (address); } +// Accountant implementation: https://etherscan.io/address/0x93b4B9bd266fFA8AF68e39EDFa8cFe2A62011Ce0 interface IAccountant { function REWARD_TOKEN() external view returns (address); function claim(address[] calldata gauges, bytes[] calldata harvestData, address receiver) external; From edf1661777ad3184dce2d038dec770752f34f698 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Tue, 16 Jun 2026 07:10:18 +0200 Subject: [PATCH 11/11] Add guardian operated vault withdrawal --- src/escrows/StakeDaoEscrow.sol | 45 +++++- test/escrowForkTests/StakeDaoEscrowFork.t.sol | 148 ++++++++++-------- .../StakeDaoEscrowFactoryTest.t.sol | 87 ++++++++++ 3 files changed, 210 insertions(+), 70 deletions(-) create mode 100644 test/factoryTests/StakeDaoEscrowFactoryTest.t.sol diff --git a/src/escrows/StakeDaoEscrow.sol b/src/escrows/StakeDaoEscrow.sol index cdf3463b..6e564a76 100644 --- a/src/escrows/StakeDaoEscrow.sol +++ b/src/escrows/StakeDaoEscrow.sol @@ -8,6 +8,7 @@ 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 redeem(uint256 shares, 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); @@ -21,11 +22,16 @@ interface IAccountant { function claim(address[] calldata gauges, bytes[] calldata harvestData, address receiver) external; } +interface IMarketGuardian { + function pauseGuardian() external view returns (address); +} + contract StakeDaoEscrow { using SafeERC20 for IERC20; error AlreadyInitialized(); error OnlyMarket(); + error OnlyGuardian(); error OnlyBeneficiary(); error OnlyBeneficiaryOrAllowlist(); error WrongCollateral(); @@ -40,6 +46,7 @@ contract StakeDaoEscrow { address public market; IERC20 public token; address public beneficiary; + bool public stakeDaoDepositsEnabled; mapping(address => bool) public allowlist; @@ -57,6 +64,7 @@ contract StakeDaoEscrow { event SetClaimer(address indexed claimer, bool isAllowed); event Claim(address caller, address receiver, address[] tokens, address baseRewardToken, uint256[] amounts); + event SetStakeDaoDepositsEnabled(address indexed guardian, bool enabled, uint256 assets, uint256 shares); constructor(address _rewardVault, address _treasury) { rewardVault = IRewardVault(_rewardVault); @@ -78,8 +86,9 @@ contract StakeDaoEscrow { market = msg.sender; if (address(_token) != IMarket(market).collateral()) revert WrongCollateral(); token = _token; - token.approve(address(rewardVault), type(uint256).max); + token.forceApprove(address(rewardVault), type(uint256).max); beneficiary = _beneficiary; + stakeDaoDepositsEnabled = true; } /** @@ -91,7 +100,7 @@ contract StakeDaoEscrow { if (msg.sender != market) revert OnlyMarket(); uint256 tokenBal = token.balanceOf(address(this)); - if (tokenBal < amount) { + if (stakeDaoDepositsEnabled && tokenBal < amount) { //Withdraw needed amount of tokens to this address rewardVault.withdraw(amount - tokenBal, address(this), address(this)); } @@ -104,6 +113,7 @@ contract StakeDaoEscrow { * @return Uint representing the token balance of the escrow */ function balance() public view returns (uint256) { + if (!stakeDaoDepositsEnabled) return token.balanceOf(address(this)); return rewardVault.previewRedeem(rewardVault.balanceOf(address(this))) + token.balanceOf(address(this)); } @@ -112,11 +122,42 @@ contract StakeDaoEscrow { * @dev This function should remain callable by anyone to handle direct inbound transfers. */ function onDeposit() public { + if (!stakeDaoDepositsEnabled) return; uint256 tokenBal = token.balanceOf(address(this)); if (tokenBal == 0) return; rewardVault.deposit(tokenBal, address(this), treasury); } + /** + * @notice Allows the market pause guardian to pull funds out of Stake DAO or put them back. + * @dev Pulled funds remain in this escrow. Disabling also revokes reward vault allowance. + * @param enabled Whether deposits into Stake DAO should be enabled + */ + function setStakeDaoDepositsEnabled(bool enabled) external { + if (msg.sender != IMarketGuardian(market).pauseGuardian()) revert OnlyGuardian(); + if (stakeDaoDepositsEnabled == enabled) return; + + stakeDaoDepositsEnabled = enabled; + + uint256 assets; + uint256 shares; + if (enabled) { + token.forceApprove(address(rewardVault), type(uint256).max); + assets = token.balanceOf(address(this)); + if (assets != 0) { + shares = rewardVault.deposit(assets, address(this), treasury); + } + } else { + token.forceApprove(address(rewardVault), 0); + shares = rewardVault.balanceOf(address(this)); + if (shares != 0) { + assets = rewardVault.redeem(shares, address(this), address(this)); + } + } + + emit SetStakeDaoDepositsEnabled(msg.sender, enabled, assets, shares); + } + /** * @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 diff --git a/test/escrowForkTests/StakeDaoEscrowFork.t.sol b/test/escrowForkTests/StakeDaoEscrowFork.t.sol index 2515ec7a..274d443d 100644 --- a/test/escrowForkTests/StakeDaoEscrowFork.t.sol +++ b/test/escrowForkTests/StakeDaoEscrowFork.t.sol @@ -4,20 +4,12 @@ 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"; +import {StakeDaoEscrow, IRewardVault} from "src/escrows/StakeDaoEscrow.sol"; interface IStakeDaoRewardVault is IRewardVault { - function depositRewards( - address rewardsToken, - uint128 amount - ) external; + function depositRewards(address rewardsToken, uint128 amount) external; - function getRewardsDistributor( - address token - ) external view returns (address); + function getRewardsDistributor(address token) external view returns (address); function getRewardTokens() external view returns (address[] memory); } @@ -25,29 +17,21 @@ interface IStakeDaoRewardVault is IRewardVault { contract StakeDaoEscrowForkTest is Test { uint256 internal constant FORK_BLOCK = 25_076_112; - address internal constant GOV = - 0x926dF14a23BE491164dCF93f4c468A50ef659D5B; + 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 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 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; + 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; @@ -92,17 +76,9 @@ contract StakeDaoEscrowForkTest is Test { 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(rewardVault.balanceOf(address(escrow)), DEPOSIT_AMOUNT, "vault shares"); assertEq(escrow.balance(), DEPOSIT_AMOUNT, "escrow balance"); - assertEq( - market.getWithdrawalLimit(USER), - DEPOSIT_AMOUNT, - "withdrawal limit" - ); + assertEq(market.getWithdrawalLimit(USER), DEPOSIT_AMOUNT, "withdrawal limit"); } function testMarketWithdrawUnstakesAndPaysLp() public { @@ -116,26 +92,78 @@ contract StakeDaoEscrowForkTest is Test { market.withdraw(half); assertEq(lpToken.balanceOf(USER), userBalanceBefore + half, "half paid"); - assertEq( - rewardVault.balanceOf(address(escrow)), - vaultBalanceBefore - half, - "half unstaked" - ); + 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(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 testPauseGuardianPullsFundsFromStakeDaoAndRestores() public { + StakeDaoEscrow escrow = _deposit(DEPOSIT_AMOUNT); + uint256 additionalDeposit = 1 ether; + + assertTrue(escrow.stakeDaoDepositsEnabled(), "staking disabled"); + assertEq(lpToken.allowance(address(escrow), address(rewardVault)), type(uint256).max, "initial allowance"); + + vm.prank(PAUSE_GUARDIAN); + escrow.setStakeDaoDepositsEnabled(false); + + assertFalse(escrow.stakeDaoDepositsEnabled(), "staking enabled"); + assertEq(rewardVault.balanceOf(address(escrow)), 0, "shares not redeemed"); + assertEq(lpToken.balanceOf(address(escrow)), DEPOSIT_AMOUNT, "LP not pulled"); + assertEq(lpToken.allowance(address(escrow), address(rewardVault)), 0, "allowance not revoked"); + assertEq(escrow.balance(), DEPOSIT_AMOUNT, "pulled balance"); + + _depositMore(additionalDeposit); + + assertEq(rewardVault.balanceOf(address(escrow)), 0, "deposit staked while disabled"); + assertEq(lpToken.balanceOf(address(escrow)), DEPOSIT_AMOUNT + additionalDeposit, "deposit not held liquid"); + assertEq(escrow.balance(), DEPOSIT_AMOUNT + additionalDeposit, "disabled balance with deposit"); + + uint256 userBalanceBefore = lpToken.balanceOf(USER); + vm.prank(USER, USER); + market.withdraw(additionalDeposit); + + assertEq(lpToken.balanceOf(USER), userBalanceBefore + additionalDeposit, "withdraw while disabled"); + assertEq(escrow.balance(), DEPOSIT_AMOUNT, "remaining disabled balance"); + + vm.prank(PAUSE_GUARDIAN); + escrow.setStakeDaoDepositsEnabled(true); + + assertTrue(escrow.stakeDaoDepositsEnabled(), "staking not enabled"); + assertEq(lpToken.allowance(address(escrow), address(rewardVault)), type(uint256).max, "allowance not restored"); + assertEq(lpToken.balanceOf(address(escrow)), 0, "liquid LP not staked"); + assertEq(rewardVault.balanceOf(address(escrow)), DEPOSIT_AMOUNT, "shares not restored"); + assertEq(escrow.balance(), DEPOSIT_AMOUNT, "restored balance"); + } + + function testStakeDaoDepositToggleUsesCurrentMarketPauseGuardian() public { + StakeDaoEscrow escrow = _deposit(DEPOSIT_AMOUNT); + address newGuardian = address(0x123); + + vm.prank(FRIEND); + vm.expectRevert(StakeDaoEscrow.OnlyGuardian.selector); + escrow.setStakeDaoDepositsEnabled(false); + + vm.prank(GOV); + market.setPauseGuardian(newGuardian); + + vm.prank(PAUSE_GUARDIAN); + vm.expectRevert(StakeDaoEscrow.OnlyGuardian.selector); + escrow.setStakeDaoDepositsEnabled(false); + + vm.prank(newGuardian); + escrow.setStakeDaoDepositsEnabled(false); + + assertFalse(escrow.stakeDaoDepositsEnabled(), "new guardian did not pull"); + } + function testClaimCvxRewardsThroughStakeDaoEscrow() public { StakeDaoEscrow escrow = _deposit(DEPOSIT_AMOUNT); address distributor = rewardVault.getRewardsDistributor(CVX); @@ -155,11 +183,7 @@ contract StakeDaoEscrowForkTest is Test { vm.prank(USER); escrow.claim(rewardTokens); - assertGt( - cvx.balanceOf(USER), - beneficiaryBalanceBefore, - "CVX not claimed" - ); + assertGt(cvx.balanceOf(USER), beneficiaryBalanceBefore, "CVX not claimed"); assertEq(cvx.balanceOf(address(escrow)), 0, "escrow CVX dust"); } @@ -178,11 +202,7 @@ contract StakeDaoEscrowForkTest is Test { vm.prank(FRIEND); escrow.claim(rewardTokens, RECEIVER); - assertGt( - crv.balanceOf(RECEIVER), - receiverBalanceBefore, - "CRV not claimed" - ); + assertGt(crv.balanceOf(RECEIVER), receiverBalanceBefore, "CRV not claimed"); assertEq(crv.balanceOf(address(escrow)), 0, "escrow CRV dust"); } @@ -219,10 +239,7 @@ contract StakeDaoEscrowForkTest is Test { } function testMarketDepositRevertsForWrongCollateral() public { - StakeDaoEscrow wrongCollateralImplementation = new StakeDaoEscrow( - REWARD_VAULT, - TREASURY - ); + StakeDaoEscrow wrongCollateralImplementation = new StakeDaoEscrow(REWARD_VAULT, TREASURY); Market wrongCollateralMarket = new Market( GOV, LENDER, @@ -242,10 +259,7 @@ contract StakeDaoEscrowForkTest is Test { wrongCollateralMarket.deposit(1); } - function _deployMarket( - address collateral, - bool callOnDepositCallback - ) internal returns (Market deployedMarket) { + function _deployMarket(address collateral, bool callOnDepositCallback) internal returns (Market deployedMarket) { deployedMarket = new Market( GOV, LENDER, @@ -261,9 +275,7 @@ contract StakeDaoEscrowForkTest is Test { ); } - function _deposit( - uint256 amount - ) internal returns (StakeDaoEscrow escrow) { + function _deposit(uint256 amount) internal returns (StakeDaoEscrow escrow) { deal(CRV_FRX_USD_LP, USER, amount, false); escrow = StakeDaoEscrow(address(market.predictEscrow(USER))); diff --git a/test/factoryTests/StakeDaoEscrowFactoryTest.t.sol b/test/factoryTests/StakeDaoEscrowFactoryTest.t.sol new file mode 100644 index 00000000..0e38b89f --- /dev/null +++ b/test/factoryTests/StakeDaoEscrowFactoryTest.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +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"; +import {StakeDaoEscrowFactory} from "src/factory/StakeDaoEscrowFactory.sol"; + +contract StakeDaoEscrowFactoryTest 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 DBR = 0xAD038Eb671c44b853887A7E32528FaB35dC5D710; + address internal constant REWARD_VAULT = 0xCA137e3853Eab95541290B372223e7F2ee4c0cFa; + address internal constant CRV_FRX_USD_LP = 0x13e12BB0E6A2f1A3d6901a59a9d585e89A6243e1; + address internal constant CRV = 0xD533a949740bb3306d119CC777fa900bA034cd52; + + uint256 internal constant DEPOSIT_AMOUNT = 10_000 ether; + + IERC20 internal lpToken; + IRewardVault internal rewardVault; + StakeDaoEscrowFactory internal factory; + + function setUp() public { + string memory url = vm.rpcUrl("mainnet"); + vm.createSelectFork(url, FORK_BLOCK); + + lpToken = IERC20(CRV_FRX_USD_LP); + rewardVault = IRewardVault(REWARD_VAULT); + factory = new StakeDaoEscrowFactory(); + } + + function testDeployEscrowImplementation() public { + address implementationAddress = factory.deployEscrow(REWARD_VAULT, TREASURY); + StakeDaoEscrow implementation = StakeDaoEscrow(implementationAddress); + + assertTrue(factory.isFromFactory(implementationAddress)); + assertEq(address(implementation.rewardVault()), REWARD_VAULT); + assertEq(address(implementation.accountant()), rewardVault.ACCOUNTANT()); + assertEq(implementation.gauge(), rewardVault.gauge()); + assertEq(implementation.baseRewardToken(), CRV); + assertEq(implementation.treasury(), TREASURY); + } + + function testFactoryEscrowImplementationWorksWithMarket() public { + address implementationAddress = factory.deployEscrow(REWARD_VAULT, TREASURY); + Market market = _deployMarket(implementationAddress); + StakeDaoEscrow escrow = StakeDaoEscrow(address(market.predictEscrow(USER))); + + deal(CRV_FRX_USD_LP, USER, DEPOSIT_AMOUNT, false); + + vm.startPrank(USER, USER); + lpToken.approve(address(market), DEPOSIT_AMOUNT); + market.deposit(DEPOSIT_AMOUNT); + vm.stopPrank(); + + assertEq(address(escrow.market()), address(market)); + assertEq(escrow.beneficiary(), USER); + assertEq(address(escrow.token()), CRV_FRX_USD_LP); + assertEq(address(escrow.rewardVault()), REWARD_VAULT); + assertEq(lpToken.balanceOf(address(escrow)), 0); + assertEq(rewardVault.balanceOf(address(escrow)), DEPOSIT_AMOUNT); + assertEq(escrow.balance(), DEPOSIT_AMOUNT); + } + + function _deployMarket(address implementationAddress) internal returns (Market market) { + market = new Market( + GOV, + LENDER, + PAUSE_GUARDIAN, + implementationAddress, + IDolaBorrowingRights(DBR), + IERC20(CRV_FRX_USD_LP), + IOracle(address(0)), + 5000, + 5000, + 1000, + true + ); + } +}