From 3a85bb53118778708a7509e35e2785ac0801d40e Mon Sep 17 00:00:00 2001 From: 08xmt Date: Tue, 13 Oct 2026 09:55:28 +0200 Subject: [PATCH 1/5] Add monolith governance StakingRewards escrow --- src/escrows/StakingRewardsEscrow.sol | 121 +++++ src/interfaces/IStakingRewards.sol | 15 + test/StakingRewardsEscrow.t.sol | 714 +++++++++++++++++++++++++++ test/mocks/StakingRewardsFixture.sol | 138 ++++++ 4 files changed, 988 insertions(+) create mode 100644 src/escrows/StakingRewardsEscrow.sol create mode 100644 src/interfaces/IStakingRewards.sol create mode 100644 test/StakingRewardsEscrow.t.sol create mode 100644 test/mocks/StakingRewardsFixture.sol diff --git a/src/escrows/StakingRewardsEscrow.sol b/src/escrows/StakingRewardsEscrow.sol new file mode 100644 index 00000000..c7bb6d2f --- /dev/null +++ b/src/escrows/StakingRewardsEscrow.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {IERC20, SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; +import {IStakingRewards} from "src/interfaces/IStakingRewards.sol"; + +/// @notice Stakes a borrower's FiRM collateral and lets them claim rewards separately. +/// @dev Deploy an implementation per initialized staking pool, then initialize each +/// clone atomically from its market. Tokens must transfer exact amounts and must +/// not rebase. The pool's staking and reward token configuration must remain fixed. +contract StakingRewardsEscrow is ReentrancyGuard { + using SafeERC20 for IERC20; + + error InvalidStakingRewards(); + error InvalidToken(); + error IdenticalTokens(); + error InvalidBeneficiary(); + error InvalidRecipient(); + error AlreadyInitialized(); + error OnlyMarket(); + error OnlyBeneficiary(); + error OnlyBeneficiaryOrAllowlist(); + + IStakingRewards public immutable stakingRewards; + IERC20 public immutable token; + IERC20 public immutable rewardsToken; + + address public market; + address public beneficiary; + mapping(address => bool) public allowlist; + + event AllowClaim(address indexed allowedAddress, bool allowed); + + modifier onlyBeneficiary() { + if (msg.sender != beneficiary) revert OnlyBeneficiary(); + _; + } + + modifier onlyBeneficiaryOrAllowlist() { + if (msg.sender != beneficiary && !allowlist[msg.sender]) revert OnlyBeneficiaryOrAllowlist(); + _; + } + + constructor(address _stakingRewards) { + if (_stakingRewards.code.length == 0) revert InvalidStakingRewards(); + IStakingRewards pool = IStakingRewards(_stakingRewards); + IERC20 collateral = pool.stakingToken(); + IERC20 reward = pool.rewardsToken(); + if (address(collateral).code.length == 0 || address(reward).code.length == 0) revert InvalidToken(); + if (collateral == reward) revert IdenticalTokens(); + + stakingRewards = pool; + token = collateral; + rewardsToken = reward; + } + + /// @notice Binds a new clone to its market and beneficiary and approves staking. + /// @dev Must be called by the market immediately after clone creation. + function initialize(IERC20 _token, address _beneficiary) external nonReentrant { + if (market != address(0)) revert AlreadyInitialized(); + if (_token != token) revert InvalidToken(); + if (_beneficiary == address(0)) revert InvalidBeneficiary(); + market = msg.sender; + beneficiary = _beneficiary; + token.forceApprove(address(stakingRewards), type(uint256).max); + } + + /// @notice Stakes all idle collateral, including direct transfers into the escrow. + /// @dev Permissionless so anyone can process deposits made outside the market. + function onDeposit() external nonReentrant { + uint256 amount = token.balanceOf(address(this)); + if (amount != 0) stakingRewards.stake(amount); + } + + /// @notice Returns idle plus staked collateral, excluding pending and held rewards. + function balance() external view returns (uint256) { + return token.balanceOf(address(this)) + stakingRewards.balanceOf(address(this)); + } + + /// @notice Pays market-authorized withdrawals or liquidations from collateral. + /// @dev Uses idle tokens first and withdraws only the shortfall, without claiming rewards. + function pay(address recipient, uint256 amount) external nonReentrant { + if (msg.sender != market) revert OnlyMarket(); + if (amount == 0) return; + uint256 idleBalance = token.balanceOf(address(this)); + if (idleBalance < amount) stakingRewards.withdraw(amount - idleBalance); + token.safeTransfer(recipient, amount); + } + + /// @notice Claims rewards to the beneficiary. + function claim() external onlyBeneficiary { + claimTo(msg.sender); + } + + /// @notice Claims and forwards all held reward tokens to a nonzero recipient. + /// @dev Allowlisted callers may send rewards to any recipient, but cannot withdraw collateral. + function claimTo(address to) public onlyBeneficiaryOrAllowlist nonReentrant { + if (to == address(0)) revert InvalidRecipient(); + stakingRewards.getReward(); + uint256 amount = rewardsToken.balanceOf(address(this)); + if (amount != 0) rewardsToken.safeTransfer(to, amount); + } + + /// @notice Returns pending staking rewards plus reward tokens already held here. + function claimable() external view returns (uint256) { + return stakingRewards.earned(address(this)) + rewardsToken.balanceOf(address(this)); + } + + /// @notice Authorizes an address to claim rewards to any recipient. + function allowClaimOnBehalf(address allowee) external onlyBeneficiary { + allowlist[allowee] = true; + emit AllowClaim(allowee, true); + } + + /// @notice Revokes an address's permission to claim rewards. + function disallowClaimOnBehalf(address allowee) external onlyBeneficiary { + allowlist[allowee] = false; + emit AllowClaim(allowee, false); + } +} diff --git a/src/interfaces/IStakingRewards.sol b/src/interfaces/IStakingRewards.sol new file mode 100644 index 00000000..f5dc8824 --- /dev/null +++ b/src/interfaces/IStakingRewards.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; + +/// @notice The Synthetix-style staking interface used by StakingRewardsEscrow. +interface IStakingRewards { + function stakingToken() external view returns (IERC20); + function rewardsToken() external view returns (IERC20); + function balanceOf(address account) external view returns (uint256); + function earned(address account) external view returns (uint256); + function stake(uint256 amount) external; + function withdraw(uint256 amount) external; + function getReward() external; +} diff --git a/test/StakingRewardsEscrow.t.sol b/test/StakingRewardsEscrow.t.sol new file mode 100644 index 00000000..42c2f4d8 --- /dev/null +++ b/test/StakingRewardsEscrow.t.sol @@ -0,0 +1,714 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {Clones} from "openzeppelin-contracts/contracts/proxy/Clones.sol"; +import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {IERC20, SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; +import {StakingRewardsEscrow} from "src/escrows/StakingRewardsEscrow.sol"; +import {IStakingRewards} from "src/interfaces/IStakingRewards.sol"; +import {StakingRewardsFixture} from "test/mocks/StakingRewardsFixture.sol"; +import {FiRMBaseTest} from "test/FiRMBaseTest.sol"; +import {Market, IDolaBorrowingRights, IOracle} from "src/Market.sol"; +import {IERC20 as MarketIERC20} from "src/interfaces/IERC20.sol"; + +/// @dev Exact-transfer token with configurable failures and callbacks for escrow tests. +contract EscrowTestToken is ERC20 { + bool public failTransfers; + address public failingSender; + bool public returnFalse; + bool public returnNothing; + bool public requireZeroApproval; + address public callbackSender; + address public callbackTarget; + bytes public callbackData; + bool public callbackSucceeded; + bytes public callbackResult; + + constructor() ERC20("Escrow test token", "TEST") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function setTransferFailure(bool fail, address sender, bool falseReturn) external { + failTransfers = fail; + failingSender = sender; + returnFalse = falseReturn; + } + + function setReturnNothing(bool enabled) external { + returnNothing = enabled; + } + + function setRequireZeroApproval(bool enabled) external { + requireZeroApproval = enabled; + } + + function setAllowance(address owner, address spender, uint256 amount) external { + _approve(owner, spender, amount); + } + + function setCallback(address sender, address target, bytes calldata data) external { + callbackSender = sender; + callbackTarget = target; + callbackData = data; + } + + function transfer(address to, uint256 amount) public override returns (bool) { + if (failTransfers && (failingSender == address(0) || failingSender == msg.sender)) { + if (returnFalse) return false; + revert("TRANSFER FAILED"); + } + super.transfer(to, amount); + _callback(); + if (returnNothing) { + assembly { + return(0, 0) + } + } + return true; + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + super.transferFrom(from, to, amount); + _callback(); + if (returnNothing) { + assembly { + return(0, 0) + } + } + return true; + } + + function approve(address spender, uint256 amount) public override returns (bool) { + if (requireZeroApproval && amount != 0) require(allowance(msg.sender, spender) == 0, "RESET APPROVAL"); + super.approve(spender, amount); + _callback(); + if (returnNothing) { + assembly { + return(0, 0) + } + } + return true; + } + + function _callback() internal { + if (callbackTarget == address(0) || msg.sender != callbackSender) return; + address target = callbackTarget; + callbackTarget = address(0); + (callbackSucceeded, callbackResult) = target.call(callbackData); + } +} + +contract StakingRewardsEscrowTest is Test { + uint256 constant DURATION = 1000; + uint256 constant FUNDING = 1000 ether; + address constant MARKET = address(0xA); + address constant BENEFICIARY = address(0xB); + address constant FRIEND = address(0xC); + address constant RECIPIENT = address(0xD); + + EscrowTestToken collateral; + EscrowTestToken reward; + StakingRewardsFixture pool; + StakingRewardsEscrow implementation; + StakingRewardsEscrow escrow; + + event AllowClaim(address indexed allowedAddress, bool allowed); + + function setUp() public { + vm.warp(1 days); + collateral = new EscrowTestToken(); + reward = new EscrowTestToken(); + pool = new StakingRewardsFixture(address(collateral), address(reward), address(this), DURATION); + implementation = new StakingRewardsEscrow(address(pool)); + escrow = _clone(BENEFICIARY); + } + + function _clone(address beneficiary) internal returns (StakingRewardsEscrow instance) { + instance = StakingRewardsEscrow(Clones.clone(address(implementation))); + vm.prank(MARKET); + instance.initialize(collateral, beneficiary); + } + + function _stake(uint256 amount) internal { + collateral.mint(address(escrow), amount); + escrow.onDeposit(); + } + + function _fund() internal { + reward.mint(address(pool), FUNDING); + pool.notifyRewardAmount(FUNDING); + } + + function _pay(uint256 amount) internal { + vm.prank(MARKET); + escrow.pay(RECIPIENT, amount); + } + + function _assertReentrancyBlocked(EscrowTestToken token) internal view { + assertFalse(token.callbackSucceeded()); + assertEq(token.callbackResult(), abi.encodeWithSelector(ReentrancyGuard.ReentrancyGuardReentrantCall.selector)); + } + + function testInitializeClone() public view { + assertEq(escrow.market(), MARKET); + assertEq(escrow.beneficiary(), BENEFICIARY); + assertEq(address(escrow.token()), address(collateral)); + assertEq(address(escrow.rewardsToken()), address(reward)); + assertEq(address(escrow.stakingRewards()), address(pool)); + assertEq(collateral.allowance(address(escrow), address(pool)), type(uint256).max); + assertEq(collateral.allowance(address(implementation), address(pool)), 0); + } + + function testCannotInitializeImplementationOrReinitializeClone() public { + vm.expectRevert(StakingRewardsEscrow.AlreadyInitialized.selector); + implementation.initialize(collateral, BENEFICIARY); + vm.expectRevert(StakingRewardsEscrow.AlreadyInitialized.selector); + escrow.initialize(collateral, FRIEND); + assertEq(escrow.market(), MARKET); + assertEq(escrow.beneficiary(), BENEFICIARY); + } + + function testInitializeRejectsWrongTokenAndZeroBeneficiary() public { + StakingRewardsEscrow instance = StakingRewardsEscrow(Clones.clone(address(implementation))); + vm.expectRevert(StakingRewardsEscrow.InvalidToken.selector); + instance.initialize(reward, BENEFICIARY); + vm.expectRevert(StakingRewardsEscrow.InvalidToken.selector); + instance.initialize(IERC20(address(0)), BENEFICIARY); + vm.expectRevert(StakingRewardsEscrow.InvalidBeneficiary.selector); + instance.initialize(collateral, address(0)); + assertEq(instance.market(), address(0)); + vm.prank(MARKET); + instance.initialize(collateral, BENEFICIARY); + assertEq(instance.market(), MARKET); + } + + function testConstructorRejectsMissingPoolCode() public { + vm.expectRevert(StakingRewardsEscrow.InvalidStakingRewards.selector); + new StakingRewardsEscrow(address(0)); + vm.expectRevert(StakingRewardsEscrow.InvalidStakingRewards.selector); + new StakingRewardsEscrow(FRIEND); + } + + function testConstructorRejectsInvalidTokenConfiguration() public { + vm.mockCall(address(pool), abi.encodeCall(IStakingRewards.stakingToken, ()), abi.encode(address(0))); + vm.expectRevert(StakingRewardsEscrow.InvalidToken.selector); + new StakingRewardsEscrow(address(pool)); + vm.clearMockedCalls(); + vm.mockCall(address(pool), abi.encodeCall(IStakingRewards.rewardsToken, ()), abi.encode(address(0))); + vm.expectRevert(StakingRewardsEscrow.InvalidToken.selector); + new StakingRewardsEscrow(address(pool)); + vm.mockCall(address(pool), abi.encodeCall(IStakingRewards.rewardsToken, ()), abi.encode(FRIEND)); + vm.expectRevert(StakingRewardsEscrow.InvalidToken.selector); + new StakingRewardsEscrow(address(pool)); + } + + function testConstructorRejectsIdenticalTokens() public { + StakingRewardsFixture sameTokenPool = + new StakingRewardsFixture(address(collateral), address(collateral), address(this), DURATION); + vm.expectRevert(StakingRewardsEscrow.IdenticalTokens.selector); + new StakingRewardsEscrow(address(sameTokenPool)); + } + + function testInitializeHandlesZeroFirstApproval() public { + StakingRewardsEscrow instance = StakingRewardsEscrow(Clones.clone(address(implementation))); + collateral.setRequireZeroApproval(true); + collateral.setAllowance(address(instance), address(pool), 1); + vm.prank(MARKET); + instance.initialize(collateral, BENEFICIARY); + assertEq(collateral.allowance(address(instance), address(pool)), type(uint256).max); + } + + function testNoReturnTokensCanInitializeStakePayAndClaim() public { + collateral.setReturnNothing(true); + reward.setReturnNothing(true); + escrow = _clone(BENEFICIARY); + _stake(10 ether); + _fund(); + vm.warp(block.timestamp + DURATION); + _pay(10 ether); + vm.prank(BENEFICIARY); + escrow.claim(); + assertEq(collateral.balanceOf(RECIPIENT), 10 ether); + assertEq(reward.balanceOf(BENEFICIARY), FUNDING); + } + + function testOnDepositIsPermissionlessAndIncludesDirectTransfers() public { + collateral.mint(FRIEND, 7 ether); + vm.prank(FRIEND); + collateral.transfer(address(escrow), 7 ether); + assertEq(escrow.balance(), 7 ether); + vm.prank(FRIEND); + escrow.onDeposit(); + assertEq(collateral.balanceOf(address(escrow)), 0); + assertEq(pool.balanceOf(address(escrow)), 7 ether); + assertEq(escrow.balance(), 7 ether); + } + + function testZeroDepositDoesNotCallStake() public { + vm.mockCallRevert(address(pool), abi.encodeWithSelector(IStakingRewards.stake.selector), "UNEXPECTED STAKE"); + escrow.onDeposit(); + assertEq(escrow.balance(), 0); + } + + function testBalanceIncludesIdleAndStakedButExcludesRewards() public { + _stake(10 ether); + collateral.mint(address(escrow), 3 ether); + _fund(); + reward.mint(address(escrow), 5 ether); + vm.warp(block.timestamp + 100); + assertEq(escrow.balance(), 13 ether); + assertEq(escrow.claimable(), 105 ether); + } + + function testPayUsesIdleCollateralWithoutCallingPool() public { + _stake(10 ether); + collateral.mint(address(escrow), 4 ether); + vm.mockCallRevert( + address(pool), abi.encodeWithSelector(IStakingRewards.withdraw.selector), "UNEXPECTED WITHDRAW" + ); + _pay(3 ether); + assertEq(collateral.balanceOf(RECIPIENT), 3 ether); + assertEq(pool.balanceOf(address(escrow)), 10 ether); + assertEq(collateral.balanceOf(address(escrow)), 1 ether); + assertEq(escrow.balance(), 11 ether); + } + + function testPayWithdrawsOnlyTheShortfall() public { + _stake(10 ether); + collateral.mint(address(escrow), 3 ether); + vm.expectCall(address(pool), abi.encodeCall(IStakingRewards.withdraw, (4 ether)), 1); + _pay(7 ether); + assertEq(collateral.balanceOf(RECIPIENT), 7 ether); + assertEq(pool.balanceOf(address(escrow)), 6 ether); + assertEq(collateral.balanceOf(address(escrow)), 0); + assertEq(escrow.balance(), 6 ether); + } + + function testPayFullBalanceAndZeroPayments() public { + _stake(10 ether); + collateral.mint(address(escrow), 3 ether); + _pay(13 ether); + assertEq(escrow.balance(), 0); + assertEq(pool.balanceOf(address(escrow)), 0); + assertEq(collateral.balanceOf(RECIPIENT), 13 ether); + vm.mockCallRevert( + address(pool), abi.encodeWithSelector(IStakingRewards.withdraw.selector), "UNEXPECTED WITHDRAW" + ); + collateral.setTransferFailure(true, address(0), false); + _pay(0); + } + + function testPayRejectsUnauthorizedCallersIncludingBeneficiaryAndClaimer() public { + _stake(10 ether); + vm.prank(BENEFICIARY); + escrow.allowClaimOnBehalf(FRIEND); + vm.startPrank(BENEFICIARY); + vm.expectRevert(StakingRewardsEscrow.OnlyMarket.selector); + escrow.pay(BENEFICIARY, 1 ether); + vm.stopPrank(); + vm.startPrank(FRIEND); + vm.expectRevert(StakingRewardsEscrow.OnlyMarket.selector); + escrow.pay(FRIEND, 0); + vm.stopPrank(); + assertEq(escrow.balance(), 10 ether); + } + + function testPayInsufficientCollateralRevertsAtomically() public { + _stake(10 ether); + collateral.mint(address(escrow), 3 ether); + vm.expectRevert(); + _pay(14 ether); + assertEq(escrow.balance(), 13 ether); + assertEq(pool.balanceOf(address(escrow)), 10 ether); + assertEq(collateral.balanceOf(RECIPIENT), 0); + } + + function testPayTransferFailureRollsBackUnstaking() public { + _stake(10 ether); + collateral.setTransferFailure(true, address(escrow), true); + vm.expectRevert(abi.encodeWithSelector(SafeERC20.SafeERC20FailedOperation.selector, address(collateral))); + _pay(4 ether); + assertEq(pool.balanceOf(address(escrow)), 10 ether); + assertEq(escrow.balance(), 10 ether); + assertEq(collateral.balanceOf(RECIPIENT), 0); + } + + function testFuzzPayPreservesCollateralAccounting(uint96 staked, uint96 idle, uint96 payment) public { + _stake(staked); + collateral.mint(address(escrow), idle); + uint256 total = uint256(staked) + idle; + uint256 amount = bound(uint256(payment), 0, total); + _pay(amount); + assertEq(escrow.balance(), total - amount); + assertEq(collateral.balanceOf(RECIPIENT), amount); + assertEq(pool.balanceOf(address(escrow)), amount > idle ? uint256(staked) - (amount - idle) : staked); + } + + function testClaimAccruesRewardsWithoutChangingCollateral() public { + _stake(10 ether); + _fund(); + collateral.mint(address(escrow), 3 ether); + reward.mint(address(escrow), 7 ether); + vm.warp(block.timestamp + 100); + assertEq(escrow.claimable(), 107 ether); + vm.prank(BENEFICIARY); + escrow.claim(); + assertEq(reward.balanceOf(BENEFICIARY), 107 ether); + assertEq(reward.balanceOf(address(escrow)), 0); + assertEq(escrow.claimable(), 0); + assertEq(escrow.balance(), 13 ether); + assertEq(pool.balanceOf(address(escrow)), 10 ether); + vm.prank(BENEFICIARY); + escrow.claim(); + assertEq(reward.balanceOf(BENEFICIARY), 107 ether); + vm.warp(block.timestamp + 100); + vm.prank(BENEFICIARY); + escrow.claim(); + assertEq(reward.balanceOf(BENEFICIARY), 207 ether); + } + + function testClaimToChosenRecipientAndHeldRewardsWithoutStake() public { + reward.mint(address(escrow), 7 ether); + vm.prank(BENEFICIARY); + escrow.claimTo(RECIPIENT); + assertEq(reward.balanceOf(RECIPIENT), 7 ether); + assertEq(reward.balanceOf(BENEFICIARY), 0); + assertEq(escrow.claimable(), 0); + } + + function testClaimRejectsZeroRecipientWithoutConsumingRewards() public { + _stake(10 ether); + _fund(); + vm.warp(block.timestamp + 100); + vm.prank(BENEFICIARY); + vm.expectRevert(StakingRewardsEscrow.InvalidRecipient.selector); + escrow.claimTo(address(0)); + assertEq(escrow.claimable(), 100 ether); + } + + function testClaimRejectsUnauthorizedCallers() public { + vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiary.selector); + escrow.claim(); + vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiaryOrAllowlist.selector); + escrow.claimTo(RECIPIENT); + vm.prank(MARKET); + vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiaryOrAllowlist.selector); + escrow.claimTo(MARKET); + } + + function testAllowlistGrantsAndRevokesClaimToOnly() public { + reward.mint(address(escrow), 7 ether); + vm.prank(BENEFICIARY); + vm.expectEmit(true, false, false, true, address(escrow)); + emit AllowClaim(FRIEND, true); + escrow.allowClaimOnBehalf(FRIEND); + assertTrue(escrow.allowlist(FRIEND)); + vm.startPrank(FRIEND); + vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiary.selector); + escrow.claim(); + escrow.claimTo(RECIPIENT); + vm.stopPrank(); + assertEq(reward.balanceOf(RECIPIENT), 7 ether); + vm.prank(BENEFICIARY); + vm.expectEmit(true, false, false, true, address(escrow)); + emit AllowClaim(FRIEND, false); + escrow.disallowClaimOnBehalf(FRIEND); + assertFalse(escrow.allowlist(FRIEND)); + vm.prank(FRIEND); + vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiaryOrAllowlist.selector); + escrow.claimTo(RECIPIENT); + } + + function testOnlyBeneficiaryCanChangeAllowlist() public { + vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiary.selector); + escrow.allowClaimOnBehalf(FRIEND); + vm.prank(BENEFICIARY); + escrow.allowClaimOnBehalf(FRIEND); + vm.startPrank(FRIEND); + vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiary.selector); + escrow.allowClaimOnBehalf(RECIPIENT); + vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiary.selector); + escrow.disallowClaimOnBehalf(FRIEND); + vm.stopPrank(); + } + + function testPartialAndFullWithdrawalPreserveEarnedRewards() public { + _stake(10 ether); + _fund(); + vm.warp(block.timestamp + 100); + _pay(4 ether); + assertEq(pool.rewards(address(escrow)), 100 ether); + assertEq(reward.balanceOf(address(escrow)), 0); + vm.warp(block.timestamp + 300); + _pay(6 ether); + assertEq(escrow.balance(), 0); + assertEq(escrow.claimable(), 400 ether); + vm.warp(block.timestamp + DURATION); + assertEq(escrow.claimable(), 400 ether); + vm.prank(BENEFICIARY); + escrow.claim(); + assertEq(reward.balanceOf(BENEFICIARY), 400 ether); + } + + function testRewardTransferFailureDoesNotBlockWithdrawals() public { + _stake(10 ether); + _fund(); + vm.warp(block.timestamp + 100); + reward.setTransferFailure(true, address(0), false); + vm.prank(BENEFICIARY); + vm.expectRevert("TRANSFER FAILED"); + escrow.claim(); + _pay(10 ether); + assertEq(collateral.balanceOf(RECIPIENT), 10 ether); + assertEq(escrow.balance(), 0); + assertEq(escrow.claimable(), 100 ether); + reward.setTransferFailure(false, address(0), false); + vm.prank(BENEFICIARY); + escrow.claim(); + assertEq(reward.balanceOf(BENEFICIARY), 100 ether); + } + + function testForwardingFailureRollsBackPoolClaim() public { + _stake(10 ether); + _fund(); + vm.warp(block.timestamp + 100); + reward.setTransferFailure(true, address(escrow), true); + vm.prank(BENEFICIARY); + vm.expectRevert(abi.encodeWithSelector(SafeERC20.SafeERC20FailedOperation.selector, address(reward))); + escrow.claim(); + assertEq(escrow.claimable(), 100 ether); + assertEq(reward.balanceOf(address(pool)), FUNDING); + assertEq(reward.balanceOf(address(escrow)), 0); + assertEq(escrow.balance(), 10 ether); + } + + function testClonesHaveIndependentCollateralRewardsAndPermissions() public { + StakingRewardsEscrow other = _clone(FRIEND); + _stake(10 ether); + collateral.mint(address(other), 30 ether); + other.onDeposit(); + _fund(); + vm.prank(BENEFICIARY); + escrow.allowClaimOnBehalf(RECIPIENT); + assertFalse(other.allowlist(RECIPIENT)); + vm.warp(block.timestamp + DURATION); + assertEq(escrow.claimable(), 250 ether); + assertEq(other.claimable(), 750 ether); + vm.prank(RECIPIENT); + escrow.claimTo(BENEFICIARY); + vm.prank(FRIEND); + other.claim(); + _pay(10 ether); + assertEq(escrow.balance(), 0); + assertEq(other.balance(), 30 ether); + assertEq(reward.balanceOf(BENEFICIARY), 250 ether); + assertEq(reward.balanceOf(FRIEND), 750 ether); + } + + function testInitializeBlocksApprovalReentrancy() public { + StakingRewardsEscrow instance = StakingRewardsEscrow(Clones.clone(address(implementation))); + collateral.setCallback(address(instance), address(instance), abi.encodeCall(StakingRewardsEscrow.onDeposit, ())); + vm.prank(MARKET); + instance.initialize(collateral, BENEFICIARY); + _assertReentrancyBlocked(collateral); + assertEq(instance.market(), MARKET); + } + + function testStakeBlocksTokenReentrancy() public { + collateral.setCallback(address(pool), address(escrow), abi.encodeCall(StakingRewardsEscrow.onDeposit, ())); + _stake(10 ether); + _assertReentrancyBlocked(collateral); + assertEq(escrow.balance(), 10 ether); + } + + function testPayBlocksReentrancyDuringUnstakeAndTransfer() public { + _stake(10 ether); + collateral.setCallback(address(pool), address(escrow), abi.encodeCall(StakingRewardsEscrow.onDeposit, ())); + _pay(4 ether); + _assertReentrancyBlocked(collateral); + collateral.setCallback(address(escrow), address(escrow), abi.encodeCall(StakingRewardsEscrow.onDeposit, ())); + _pay(6 ether); + _assertReentrancyBlocked(collateral); + assertEq(collateral.balanceOf(RECIPIENT), 10 ether); + assertEq(escrow.balance(), 0); + } + + function testClaimBlocksCrossFunctionAndAuthorizedClaimReentrancy() public { + _stake(10 ether); + _fund(); + vm.warp(block.timestamp + 100); + reward.setCallback(address(pool), address(escrow), abi.encodeCall(StakingRewardsEscrow.onDeposit, ())); + vm.prank(BENEFICIARY); + escrow.claim(); + _assertReentrancyBlocked(reward); + vm.prank(BENEFICIARY); + escrow.allowClaimOnBehalf(address(reward)); + vm.warp(block.timestamp + 100); + reward.setCallback(address(escrow), address(escrow), abi.encodeCall(StakingRewardsEscrow.claimTo, (FRIEND))); + vm.prank(BENEFICIARY); + escrow.claim(); + _assertReentrancyBlocked(reward); + assertEq(reward.balanceOf(BENEFICIARY), 200 ether); + assertEq(reward.balanceOf(FRIEND), 0); + assertEq(escrow.balance(), 10 ether); + } +} + +contract StakingRewardsEscrowMarketTest is FiRMBaseTest { + EscrowTestToken reward; + StakingRewardsFixture pool; + StakingRewardsEscrow stakingImplementation; + + function setUp() public { + initialize(replenishmentPriceBps, collateralFactorBps, replenishmentIncentiveBps, liquidationBonusBps, false); + reward = new EscrowTestToken(); + pool = new StakingRewardsFixture(address(WETH), address(reward), address(this), 1000); + stakingImplementation = new StakingRewardsEscrow(address(pool)); + market = new Market( + gov, + address(fed), + pauseGuardian, + address(stakingImplementation), + IDolaBorrowingRights(address(dbr)), + MarketIERC20(address(WETH)), + IOracle(address(oracle)), + collateralFactorBps, + replenishmentIncentiveBps, + liquidationBonusBps, + true + ); + vm.prank(gov); + dbr.addMarket(address(market)); + gibDOLA(address(market), 1_000_000 ether); + gibDBR(user, 10_000 ether); + gibWeth(user, 2 ether); + } + + function _escrow(address account) internal view returns (StakingRewardsEscrow) { + return StakingRewardsEscrow(address(market.escrows(account))); + } + + function _depositAndBorrow() internal { + vm.startPrank(user, user); + WETH.approve(address(market), 2 ether); + market.depositAndBorrow(2 ether, 2000 ether); + vm.stopPrank(); + } + + function _fundAndAccrue() internal { + reward.mint(address(pool), 1000 ether); + pool.notifyRewardAmount(1000 ether); + vm.warp(block.timestamp + 100); + ethFeed.changeUpdatedAt(block.timestamp); + } + + function testMarketDepositAndBorrowUsesCloneAndStakedCollateral() public { + address predicted = address(market.predictEscrow(user)); + _depositAndBorrow(); + StakingRewardsEscrow instance = _escrow(user); + assertEq(address(instance), predicted); + assertEq(instance.market(), address(market)); + assertEq(instance.beneficiary(), user); + assertEq(pool.balanceOf(address(instance)), 2 ether); + assertEq(WETH.balanceOf(address(instance)), 0); + assertEq(market.getCreditLimit(user), 3400 ether); + assertEq(market.debts(user), 2000 ether); + assertEq(DOLA.balanceOf(user), 2000 ether); + _fundAndAccrue(); + assertEq(market.getCreditLimit(user), 3400 ether); + vm.prank(user); + instance.claim(); + assertEq(reward.balanceOf(user), 100 ether); + assertEq(instance.balance(), 2 ether); + } + + function testMarketDepositForAnotherBorrowerIsIsolated() public { + vm.startPrank(user, user); + WETH.approve(address(market), 2 ether); + market.deposit(1 ether); + market.deposit(user2, 1 ether); + vm.stopPrank(); + StakingRewardsEscrow first = _escrow(user); + StakingRewardsEscrow second = _escrow(user2); + assertTrue(address(first) != address(second)); + assertEq(first.beneficiary(), user); + assertEq(second.beneficiary(), user2); + assertEq(pool.balanceOf(address(first)), 1 ether); + assertEq(pool.balanceOf(address(second)), 1 ether); + _fundAndAccrue(); + vm.prank(user2); + second.claim(); + assertEq(reward.balanceOf(user2), 50 ether); + assertEq(first.claimable(), 50 ether); + } + + function testMarketPartialWithdrawalRepaymentAndFullWithdrawal() public { + _depositAndBorrow(); + _fundAndAccrue(); + StakingRewardsEscrow instance = _escrow(user); + vm.startPrank(user, user); + market.withdraw(0.5 ether); + assertEq(instance.balance(), 1.5 ether); + assertEq(WETH.balanceOf(user), 0.5 ether); + vm.expectRevert("Insufficient withdrawal limit"); + market.withdraw(1 ether); + DOLA.approve(address(market), type(uint256).max); + market.repayAndWithdraw(type(uint256).max, 1.5 ether); + instance.claim(); + vm.stopPrank(); + assertEq(instance.balance(), 0); + assertEq(pool.balanceOf(address(instance)), 0); + assertEq(market.debts(user), 0); + assertEq(WETH.balanceOf(user), 2 ether); + assertEq(reward.balanceOf(user), 100 ether); + } + + function testMarketWithdrawMaxWorksWhenRewardsFail() public { + vm.startPrank(user, user); + deposit(2 ether); + vm.stopPrank(); + _fundAndAccrue(); + StakingRewardsEscrow instance = _escrow(user); + reward.setTransferFailure(true, address(0), false); + vm.startPrank(user, user); + vm.expectRevert("TRANSFER FAILED"); + instance.claim(); + market.withdrawMax(); + vm.stopPrank(); + assertEq(WETH.balanceOf(user), 2 ether); + assertEq(instance.balance(), 0); + assertEq(instance.claimable(), 100 ether); + } + + function testMarketLiquidationPaysLiquidatorAndGovernanceDespiteRewardFailure() public { + _depositAndBorrow(); + _fundAndAccrue(); + reward.setTransferFailure(true, address(0), false); + vm.prank(gov); + market.setLiquidationFeeBps(100); + ethFeed.changeAnswer(1000 ether); + uint256 repayment = 1000 ether; + gibDOLA(user2, repayment); + uint256 govBalance = WETH.balanceOf(gov); + vm.startPrank(user2, user2); + DOLA.approve(address(market), repayment); + market.liquidate(user, repayment); + vm.stopPrank(); + StakingRewardsEscrow instance = _escrow(user); + assertEq(WETH.balanceOf(user2), 1.01 ether); + assertEq(WETH.balanceOf(gov) - govBalance, 0.01 ether); + assertEq(instance.balance(), 0.98 ether); + assertEq(pool.balanceOf(address(instance)), 0.98 ether); + assertEq(market.debts(user), 1000 ether); + assertEq(instance.claimable(), 100 ether); + assertEq(reward.balanceOf(user2), 0); + reward.setTransferFailure(false, address(0), false); + vm.prank(user); + instance.claim(); + assertEq(reward.balanceOf(user), 100 ether); + } +} diff --git a/test/mocks/StakingRewardsFixture.sol b/test/mocks/StakingRewardsFixture.sol new file mode 100644 index 00000000..7dd24c0e --- /dev/null +++ b/test/mocks/StakingRewardsFixture.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20, SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; + +/// @notice Test fixture for the supplied StakingRewards contract. +/// @dev Preserves its staking, withdrawal, reward accounting, and funding logic. +/// Only setup differs: constructor/Ownable replace initializer/OwnableUpgradeable, +/// and the pragma permits the repository's Solidity version. No upgradeable dependency is needed. +/// Accounting adapted from Synthetix StakingRewards: +/// https://github.com/Synthetixio/synthetix/blob/develop/contracts/StakingRewards.sol +contract StakingRewardsFixture is Ownable, ReentrancyGuard { + using SafeERC20 for IERC20; + + uint256 public constant REWARD_PRECISION = 1e18; + IERC20 public stakingToken; + IERC20 public rewardsToken; + address public rewardsDistribution; + uint256 private _totalSupply; + mapping(address account => uint256) private _balances; + uint256 public periodFinish; + uint256 public rewardRate; + uint256 public rewardsDuration; + uint256 public lastUpdateTime; + uint256 public rewardPerTokenStored; + mapping(address account => uint256) public userRewardPerTokenPaid; + mapping(address account => uint256) public rewards; + + event RewardAdded(uint256 reward); + event Staked(address indexed user, uint256 amount); + event Withdrawn(address indexed user, uint256 amount); + event RewardPaid(address indexed user, uint256 reward); + + constructor(address stakingToken_, address rewardsToken_, address initialOwner, uint256 rewardsDuration_) + Ownable(initialOwner) + { + require(stakingToken_ != address(0), "Staking token cannot be 0"); + require(rewardsToken_ != address(0), "Rewards token cannot be 0"); + require(rewardsDuration_ > 0, "Rewards duration cannot be 0"); + stakingToken = IERC20(stakingToken_); + rewardsToken = IERC20(rewardsToken_); + rewardsDistribution = initialOwner; + rewardsDuration = rewardsDuration_; + } + + modifier onlyRewardsDistribution() { + require(msg.sender == rewardsDistribution, "Caller is not RewardsDistribution contract"); + _; + } + + modifier updateReward(address account) { + rewardPerTokenStored = rewardPerToken(); + lastUpdateTime = lastTimeRewardApplicable(); + if (account != address(0)) { + rewards[account] = earned(account); + userRewardPerTokenPaid[account] = rewardPerTokenStored; + } + _; + } + + function totalSupply() external view returns (uint256) { + return _totalSupply; + } + + function balanceOf(address account) external view returns (uint256) { + return _balances[account]; + } + + function lastTimeRewardApplicable() public view returns (uint256) { + return block.timestamp < periodFinish ? block.timestamp : periodFinish; + } + + function rewardPerToken() public view returns (uint256) { + if (_totalSupply == 0) return rewardPerTokenStored; + return rewardPerTokenStored + ((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * REWARD_PRECISION) + / _totalSupply; + } + + function earned(address account) public view returns (uint256) { + return (_balances[account] * (rewardPerToken() - userRewardPerTokenPaid[account])) / REWARD_PRECISION + + rewards[account]; + } + + function getRewardForDuration() external view returns (uint256) { + return rewardRate * rewardsDuration; + } + + function stake(uint256 amount) external nonReentrant updateReward(msg.sender) { + require(amount > 0, "Cannot stake 0"); + _totalSupply += amount; + _balances[msg.sender] += amount; + stakingToken.safeTransferFrom(msg.sender, address(this), amount); + emit Staked(msg.sender, amount); + } + + function withdraw(uint256 amount) public nonReentrant updateReward(msg.sender) { + require(amount > 0, "Cannot withdraw 0"); + _totalSupply -= amount; + _balances[msg.sender] -= amount; + stakingToken.safeTransfer(msg.sender, amount); + emit Withdrawn(msg.sender, amount); + } + + function getReward() public nonReentrant updateReward(msg.sender) { + uint256 reward = rewards[msg.sender]; + if (reward > 0) { + rewards[msg.sender] = 0; + rewardsToken.safeTransfer(msg.sender, reward); + emit RewardPaid(msg.sender, reward); + } + } + + function exit() external { + withdraw(_balances[msg.sender]); + getReward(); + } + + function notifyRewardAmount(uint256 reward) external onlyRewardsDistribution updateReward(address(0)) { + if (block.timestamp >= periodFinish) { + rewardRate = reward / rewardsDuration; + } else { + uint256 remaining = periodFinish - block.timestamp; + uint256 leftover = remaining * rewardRate; + rewardRate = (reward + leftover) / rewardsDuration; + } + uint256 balance = rewardsToken.balanceOf(address(this)); + require(rewardRate <= balance / rewardsDuration, "Provided reward too high"); + lastUpdateTime = block.timestamp; + periodFinish = block.timestamp + rewardsDuration; + emit RewardAdded(reward); + } + + function setRewardsDistribution(address _rewardsDistribution) external onlyOwner { + rewardsDistribution = _rewardsDistribution; + } +} From a52e0027f1d42f3c5a8a1f8d7b270c3a63866a0d Mon Sep 17 00:00:00 2001 From: 08xmt Date: Thu, 15 Oct 2026 09:47:30 +0200 Subject: [PATCH 2/5] Update readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 02f8c977..b65e938e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ If a user's DBR balance falls below 0, the burn will continue as the user accrue ### Escrows Each wallet has a unique escrow contract, one for every deployed market. Their primary purpose is holding a user’s collateral, but may have additional functionality that allow borrowers to use their collateral for voting, yield farming etc. +See [StakingRewards escrow integration](docs/StakingRewardsEscrow.md) for staking collateral in a Synthetix-style rewards pool. + ### Fed Feds are a class of contracts in the Inverse Finance ecosystem responsible for minting DOLA in a way that preserves the peg and can't be easily abused. In the FiRM protocol, the role of the Fed is to supply and remove DOLA to and from markets. From dab29e7620b0b9a1ee09bbf8f969a892155f183b Mon Sep 17 00:00:00 2001 From: 08xmt Date: Wed, 28 Oct 2026 02:06:21 +0100 Subject: [PATCH 3/5] Add claimAndStake function --- src/escrows/StakingRewardsEscrow.sol | 34 ++- test/StakingRewardsEscrow.t.sol | 336 ++++++++++++++++++++++++++- 2 files changed, 358 insertions(+), 12 deletions(-) diff --git a/src/escrows/StakingRewardsEscrow.sol b/src/escrows/StakingRewardsEscrow.sol index c7bb6d2f..b69aef9c 100644 --- a/src/escrows/StakingRewardsEscrow.sol +++ b/src/escrows/StakingRewardsEscrow.sol @@ -2,18 +2,21 @@ pragma solidity ^0.8.20; import {IERC20, SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC4626} from "openzeppelin-contracts/contracts/interfaces/IERC4626.sol"; import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; import {IStakingRewards} from "src/interfaces/IStakingRewards.sol"; /// @notice Stakes a borrower's FiRM collateral and lets them claim rewards separately. /// @dev Deploy an implementation per initialized staking pool, then initialize each /// clone atomically from its market. Tokens must transfer exact amounts and must -/// not rebase. The pool's staking and reward token configuration must remain fixed. +/// not rebase. The pool's staking and reward token configuration and the trusted +/// sGOV vault's underlying asset must remain fixed. contract StakingRewardsEscrow is ReentrancyGuard { using SafeERC20 for IERC20; error InvalidStakingRewards(); error InvalidToken(); + error InvalidVault(); error IdenticalTokens(); error InvalidBeneficiary(); error InvalidRecipient(); @@ -21,10 +24,12 @@ contract StakingRewardsEscrow is ReentrancyGuard { error OnlyMarket(); error OnlyBeneficiary(); error OnlyBeneficiaryOrAllowlist(); + error InsufficientShares(); IStakingRewards public immutable stakingRewards; IERC20 public immutable token; IERC20 public immutable rewardsToken; + IERC4626 public immutable sGOV; address public market; address public beneficiary; @@ -42,20 +47,24 @@ contract StakingRewardsEscrow is ReentrancyGuard { _; } - constructor(address _stakingRewards) { + constructor(address _stakingRewards, address _sGOV) { if (_stakingRewards.code.length == 0) revert InvalidStakingRewards(); IStakingRewards pool = IStakingRewards(_stakingRewards); IERC20 collateral = pool.stakingToken(); IERC20 reward = pool.rewardsToken(); if (address(collateral).code.length == 0 || address(reward).code.length == 0) revert InvalidToken(); if (collateral == reward) revert IdenticalTokens(); + if (_sGOV.code.length == 0) revert InvalidVault(); + IERC4626 vault = IERC4626(_sGOV); + if (vault.asset() != address(reward)) revert InvalidVault(); stakingRewards = pool; token = collateral; rewardsToken = reward; + sGOV = vault; } - /// @notice Binds a new clone to its market and beneficiary and approves staking. + /// @notice Binds a new clone to its market and beneficiary and approves collateral and reward staking. /// @dev Must be called by the market immediately after clone creation. function initialize(IERC20 _token, address _beneficiary) external nonReentrant { if (market != address(0)) revert AlreadyInitialized(); @@ -64,6 +73,7 @@ contract StakingRewardsEscrow is ReentrancyGuard { market = msg.sender; beneficiary = _beneficiary; token.forceApprove(address(stakingRewards), type(uint256).max); + rewardsToken.forceApprove(address(sGOV), type(uint256).max); } /// @notice Stakes all idle collateral, including direct transfers into the escrow. @@ -102,6 +112,24 @@ contract StakingRewardsEscrow is ReentrancyGuard { if (amount != 0) rewardsToken.safeTransfer(to, amount); } + /// @notice Claims rewards and deposits all held reward tokens into sGOV for the receiver. + /// @dev Uses the same permissions as claimTo. Any failure reverts both the claim and deposit. + /// @param receiver The nonzero address that receives the sGOV shares directly. + /// @param minShares The minimum shares to mint; zero disables the output floor. + /// @return shares The minted shares, or zero when there are no rewards to deposit. + function claimAndStake(address receiver, uint256 minShares) + external + onlyBeneficiaryOrAllowlist + nonReentrant + returns (uint256 shares) + { + if (receiver == address(0)) revert InvalidRecipient(); + stakingRewards.getReward(); + uint256 amount = rewardsToken.balanceOf(address(this)); + if (amount != 0) shares = sGOV.deposit(amount, receiver); + if (shares < minShares) revert InsufficientShares(); + } + /// @notice Returns pending staking rewards plus reward tokens already held here. function claimable() external view returns (uint256) { return stakingRewards.earned(address(this)) + rewardsToken.balanceOf(address(this)); diff --git a/test/StakingRewardsEscrow.t.sol b/test/StakingRewardsEscrow.t.sol index 42c2f4d8..117476f8 100644 --- a/test/StakingRewardsEscrow.t.sol +++ b/test/StakingRewardsEscrow.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; import {Test} from "forge-std/Test.sol"; import {Clones} from "openzeppelin-contracts/contracts/proxy/Clones.sol"; import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {ERC4626} from "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sol"; import {IERC20, SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; import {StakingRewardsEscrow} from "src/escrows/StakingRewardsEscrow.sol"; @@ -102,6 +103,11 @@ contract EscrowTestToken is ERC20 { } } +/// @dev Standard ERC-4626 accounting and transfers, with no custom staking behavior. +contract EscrowTestVault is ERC4626 { + constructor(IERC20 asset_) ERC20("Staked reward", "sGOV") ERC4626(asset_) {} +} + contract StakingRewardsEscrowTest is Test { uint256 constant DURATION = 1000; uint256 constant FUNDING = 1000 ether; @@ -113,6 +119,7 @@ contract StakingRewardsEscrowTest is Test { EscrowTestToken collateral; EscrowTestToken reward; StakingRewardsFixture pool; + EscrowTestVault vault; StakingRewardsEscrow implementation; StakingRewardsEscrow escrow; @@ -122,8 +129,9 @@ contract StakingRewardsEscrowTest is Test { vm.warp(1 days); collateral = new EscrowTestToken(); reward = new EscrowTestToken(); + vault = new EscrowTestVault(reward); pool = new StakingRewardsFixture(address(collateral), address(reward), address(this), DURATION); - implementation = new StakingRewardsEscrow(address(pool)); + implementation = new StakingRewardsEscrow(address(pool), address(vault)); escrow = _clone(BENEFICIARY); } @@ -148,6 +156,13 @@ contract StakingRewardsEscrowTest is Test { escrow.pay(RECIPIENT, amount); } + function _seedVaultWithYield() internal { + reward.mint(address(this), 100 ether); + reward.approve(address(vault), 100 ether); + vault.deposit(100 ether, address(this)); + reward.mint(address(vault), 100 ether); + } + function _assertReentrancyBlocked(EscrowTestToken token) internal view { assertFalse(token.callbackSucceeded()); assertEq(token.callbackResult(), abi.encodeWithSelector(ReentrancyGuard.ReentrancyGuardReentrantCall.selector)); @@ -159,8 +174,11 @@ contract StakingRewardsEscrowTest is Test { assertEq(address(escrow.token()), address(collateral)); assertEq(address(escrow.rewardsToken()), address(reward)); assertEq(address(escrow.stakingRewards()), address(pool)); + assertEq(address(escrow.sGOV()), address(vault)); assertEq(collateral.allowance(address(escrow), address(pool)), type(uint256).max); + assertEq(reward.allowance(address(escrow), address(vault)), type(uint256).max); assertEq(collateral.allowance(address(implementation), address(pool)), 0); + assertEq(reward.allowance(address(implementation), address(vault)), 0); } function testCannotInitializeImplementationOrReinitializeClone() public { @@ -188,38 +206,54 @@ contract StakingRewardsEscrowTest is Test { function testConstructorRejectsMissingPoolCode() public { vm.expectRevert(StakingRewardsEscrow.InvalidStakingRewards.selector); - new StakingRewardsEscrow(address(0)); + new StakingRewardsEscrow(address(0), address(vault)); vm.expectRevert(StakingRewardsEscrow.InvalidStakingRewards.selector); - new StakingRewardsEscrow(FRIEND); + new StakingRewardsEscrow(FRIEND, address(vault)); } function testConstructorRejectsInvalidTokenConfiguration() public { vm.mockCall(address(pool), abi.encodeCall(IStakingRewards.stakingToken, ()), abi.encode(address(0))); vm.expectRevert(StakingRewardsEscrow.InvalidToken.selector); - new StakingRewardsEscrow(address(pool)); + new StakingRewardsEscrow(address(pool), address(vault)); vm.clearMockedCalls(); vm.mockCall(address(pool), abi.encodeCall(IStakingRewards.rewardsToken, ()), abi.encode(address(0))); vm.expectRevert(StakingRewardsEscrow.InvalidToken.selector); - new StakingRewardsEscrow(address(pool)); + new StakingRewardsEscrow(address(pool), address(vault)); vm.mockCall(address(pool), abi.encodeCall(IStakingRewards.rewardsToken, ()), abi.encode(FRIEND)); vm.expectRevert(StakingRewardsEscrow.InvalidToken.selector); - new StakingRewardsEscrow(address(pool)); + new StakingRewardsEscrow(address(pool), address(vault)); } function testConstructorRejectsIdenticalTokens() public { StakingRewardsFixture sameTokenPool = new StakingRewardsFixture(address(collateral), address(collateral), address(this), DURATION); vm.expectRevert(StakingRewardsEscrow.IdenticalTokens.selector); - new StakingRewardsEscrow(address(sameTokenPool)); + new StakingRewardsEscrow(address(sameTokenPool), address(vault)); } function testInitializeHandlesZeroFirstApproval() public { StakingRewardsEscrow instance = StakingRewardsEscrow(Clones.clone(address(implementation))); collateral.setRequireZeroApproval(true); collateral.setAllowance(address(instance), address(pool), 1); + reward.setRequireZeroApproval(true); + reward.setAllowance(address(instance), address(vault), 1); vm.prank(MARKET); instance.initialize(collateral, BENEFICIARY); assertEq(collateral.allowance(address(instance), address(pool)), type(uint256).max); + assertEq(reward.allowance(address(instance), address(vault)), type(uint256).max); + } + + function testConstructorRejectsMissingVaultCode() public { + vm.expectRevert(StakingRewardsEscrow.InvalidVault.selector); + new StakingRewardsEscrow(address(pool), address(0)); + vm.expectRevert(StakingRewardsEscrow.InvalidVault.selector); + new StakingRewardsEscrow(address(pool), FRIEND); + } + + function testConstructorRejectsWrongVaultAsset() public { + EscrowTestVault wrongVault = new EscrowTestVault(collateral); + vm.expectRevert(StakingRewardsEscrow.InvalidVault.selector); + new StakingRewardsEscrow(address(pool), address(wrongVault)); } function testNoReturnTokensCanInitializeStakePayAndClaim() public { @@ -400,7 +434,7 @@ contract StakingRewardsEscrowTest is Test { escrow.claimTo(MARKET); } - function testAllowlistGrantsAndRevokesClaimToOnly() public { + function testAllowlistGrantsAndRevokesClaimTo() public { reward.mint(address(escrow), 7 ether); vm.prank(BENEFICIARY); vm.expectEmit(true, false, false, true, address(escrow)); @@ -509,6 +543,268 @@ contract StakingRewardsEscrowTest is Test { assertEq(reward.balanceOf(FRIEND), 750 ether); } + function testClaimAndStakeAccruesRewardsAndIncludesHeldBalance() public { + _stake(10 ether); + collateral.mint(address(escrow), 3 ether); + _fund(); + reward.mint(address(escrow), 7 ether); + vm.warp(block.timestamp + 100); + vm.expectCall(address(vault), abi.encodeCall(ERC4626.deposit, (107 ether, RECIPIENT))); + vm.prank(BENEFICIARY); + uint256 shares = escrow.claimAndStake(RECIPIENT, 107 ether); + assertEq(shares, 107 ether); + assertEq(vault.balanceOf(RECIPIENT), shares); + assertEq(vault.balanceOf(address(escrow)), 0); + assertEq(vault.balanceOf(BENEFICIARY), 0); + assertEq(vault.totalAssets(), 107 ether); + assertEq(reward.balanceOf(address(pool)), 900 ether); + assertEq(reward.balanceOf(address(escrow)), 0); + assertEq(reward.balanceOf(RECIPIENT), 0); + assertEq(escrow.claimable(), 0); + assertEq(escrow.balance(), 13 ether); + assertEq(pool.balanceOf(address(escrow)), 10 ether); + + vm.warp(block.timestamp + 100); + vm.prank(BENEFICIARY); + assertEq(escrow.claimAndStake(RECIPIENT, 100 ether), 100 ether); + assertEq(vault.balanceOf(RECIPIENT), 207 ether); + assertEq(escrow.claimable(), 0); + } + + function testClaimAndStakeAllowlistAndRevocation() public { + reward.mint(address(escrow), 7 ether); + vm.prank(FRIEND); + vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiaryOrAllowlist.selector); + escrow.claimAndStake(RECIPIENT, 0); + vm.prank(MARKET); + vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiaryOrAllowlist.selector); + escrow.claimAndStake(RECIPIENT, 0); + + vm.prank(BENEFICIARY); + escrow.allowClaimOnBehalf(FRIEND); + vm.prank(FRIEND); + assertEq(escrow.claimAndStake(FRIEND, 7 ether), 7 ether); + assertEq(vault.balanceOf(FRIEND), 7 ether); + assertEq(escrow.balance(), 0); + + reward.mint(address(escrow), 3 ether); + vm.prank(BENEFICIARY); + escrow.disallowClaimOnBehalf(FRIEND); + vm.prank(FRIEND); + vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiaryOrAllowlist.selector); + escrow.claimAndStake(RECIPIENT, 0); + assertEq(escrow.claimable(), 3 ether); + } + + function testClaimAndStakeRejectsZeroReceiverBeforeClaiming() public { + _stake(10 ether); + _fund(); + vm.warp(block.timestamp + 100); + vm.mockCallRevert(address(pool), abi.encodeCall(IStakingRewards.getReward, ()), "UNEXPECTED CLAIM"); + vm.prank(BENEFICIARY); + vm.expectRevert(StakingRewardsEscrow.InvalidRecipient.selector); + escrow.claimAndStake(address(0), 0); + assertEq(escrow.claimable(), 100 ether); + } + + function testClaimAndStakeZeroRewardsSkipsDepositAndEnforcesMinimum() public { + vm.mockCallRevert(address(vault), abi.encodeWithSelector(ERC4626.deposit.selector), "UNEXPECTED DEPOSIT"); + vm.prank(BENEFICIARY); + assertEq(escrow.claimAndStake(RECIPIENT, 0), 0); + vm.prank(BENEFICIARY); + vm.expectRevert(StakingRewardsEscrow.InsufficientShares.selector); + escrow.claimAndStake(RECIPIENT, 1); + assertEq(vault.totalSupply(), 0); + } + + function testClaimAndStakeNonUnitSharePriceAndExactMinimum() public { + _seedVaultWithYield(); + reward.mint(address(escrow), 100 ether); + uint256 expectedShares = vault.previewDeposit(100 ether); + assertLt(expectedShares, 100 ether); + vm.prank(BENEFICIARY); + uint256 shares = escrow.claimAndStake(RECIPIENT, expectedShares); + assertEq(shares, expectedShares); + assertEq(vault.balanceOf(RECIPIENT), expectedShares); + assertEq(vault.totalAssets(), 300 ether); + assertEq(escrow.claimable(), 0); + } + + function testClaimAndStakeInsufficientSharesRollsBackClaimAndDeposit() public { + _seedVaultWithYield(); + _stake(10 ether); + _fund(); + reward.mint(address(escrow), 7 ether); + vm.warp(block.timestamp + 100); + uint256 expectedShares = vault.previewDeposit(107 ether); + uint256 supplyBefore = vault.totalSupply(); + vm.prank(BENEFICIARY); + vm.expectRevert(StakingRewardsEscrow.InsufficientShares.selector); + escrow.claimAndStake(RECIPIENT, expectedShares + 1); + assertEq(escrow.claimable(), 107 ether); + assertEq(pool.earned(address(escrow)), 100 ether); + assertEq(reward.balanceOf(address(pool)), FUNDING); + assertEq(reward.balanceOf(address(escrow)), 7 ether); + assertEq(vault.balanceOf(RECIPIENT), 0); + assertEq(vault.totalSupply(), supplyBefore); + assertEq(vault.totalAssets(), 200 ether); + assertEq(escrow.balance(), 10 ether); + } + + function testClaimAndStakeZeroMinimumAcceptsVaultOutput() public { + _seedVaultWithYield(); + reward.mint(address(escrow), 100 ether); + uint256 expectedShares = vault.previewDeposit(100 ether); + vm.prank(BENEFICIARY); + assertEq(escrow.claimAndStake(RECIPIENT, 0), expectedShares); + assertEq(vault.balanceOf(RECIPIENT), expectedShares); + } + + function testClaimAndStakeDepositFailureRollsBackPoolClaim() public { + _stake(10 ether); + _fund(); + vm.warp(block.timestamp + 100); + vm.mockCallRevert(address(vault), abi.encodeWithSelector(ERC4626.deposit.selector), "DEPOSIT FAILED"); + vm.prank(BENEFICIARY); + vm.expectRevert("DEPOSIT FAILED"); + escrow.claimAndStake(RECIPIENT, 0); + assertEq(escrow.claimable(), 100 ether); + assertEq(reward.balanceOf(address(pool)), FUNDING); + assertEq(reward.balanceOf(address(escrow)), 0); + assertEq(vault.balanceOf(RECIPIENT), 0); + assertEq(escrow.balance(), 10 ether); + _pay(10 ether); + assertEq(collateral.balanceOf(RECIPIENT), 10 ether); + } + + function testClaimAndStakeRewardTransferFailurePreservesRewards() public { + _stake(10 ether); + _fund(); + vm.warp(block.timestamp + 100); + reward.setTransferFailure(true, address(pool), true); + vm.prank(BENEFICIARY); + vm.expectRevert(abi.encodeWithSelector(SafeERC20.SafeERC20FailedOperation.selector, address(reward))); + escrow.claimAndStake(RECIPIENT, 0); + assertEq(escrow.claimable(), 100 ether); + assertEq(reward.balanceOf(address(pool)), FUNDING); + assertEq(vault.totalSupply(), 0); + } + + function testClaimAndStakeTransferFromFailureRollsBackPoolClaim() public { + _stake(10 ether); + _fund(); + vm.warp(block.timestamp + 100); + vm.mockCall( + address(reward), + abi.encodeCall(IERC20.transferFrom, (address(escrow), address(vault), 100 ether)), + abi.encode(false) + ); + vm.prank(BENEFICIARY); + vm.expectRevert(abi.encodeWithSelector(SafeERC20.SafeERC20FailedOperation.selector, address(reward))); + escrow.claimAndStake(RECIPIENT, 0); + assertEq(escrow.claimable(), 100 ether); + assertEq(reward.balanceOf(address(pool)), FUNDING); + assertEq(reward.balanceOf(address(escrow)), 0); + assertEq(vault.totalAssets(), 0); + assertEq(vault.totalSupply(), 0); + } + + function testNoReturnTokensCanClaimAndStakeAfterFullWithdrawal() public { + collateral.setReturnNothing(true); + reward.setReturnNothing(true); + escrow = _clone(BENEFICIARY); + _stake(10 ether); + _fund(); + vm.warp(block.timestamp + 100); + _pay(10 ether); + assertEq(escrow.balance(), 0); + vm.prank(BENEFICIARY); + assertEq(escrow.claimAndStake(RECIPIENT, 100 ether), 100 ether); + assertEq(vault.balanceOf(RECIPIENT), 100 ether); + assertEq(escrow.claimable(), 0); + } + + function testClaimAndStakeClonesHaveIndependentRewardsAndPermissions() public { + StakingRewardsEscrow other = _clone(FRIEND); + _stake(10 ether); + collateral.mint(address(other), 30 ether); + other.onDeposit(); + _fund(); + vm.warp(block.timestamp + DURATION); + vm.prank(BENEFICIARY); + escrow.allowClaimOnBehalf(RECIPIENT); + assertFalse(other.allowlist(RECIPIENT)); + vm.prank(RECIPIENT); + vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiaryOrAllowlist.selector); + other.claimAndStake(RECIPIENT, 0); + vm.prank(RECIPIENT); + assertEq(escrow.claimAndStake(BENEFICIARY, 250 ether), 250 ether); + assertEq(other.claimable(), 750 ether); + assertEq(other.balance(), 30 ether); + vm.prank(FRIEND); + assertEq(other.claimAndStake(FRIEND, 750 ether), 750 ether); + assertEq(vault.balanceOf(BENEFICIARY), 250 ether); + assertEq(vault.balanceOf(FRIEND), 750 ether); + assertEq(escrow.claimable(), 0); + assertEq(other.claimable(), 0); + } + + function testClaimAndStakeBlocksReentrancyDuringClaimAndDeposit() public { + _stake(10 ether); + _fund(); + vm.prank(BENEFICIARY); + escrow.allowClaimOnBehalf(address(reward)); + vm.warp(block.timestamp + 100); + reward.setCallback( + address(pool), address(escrow), abi.encodeCall(StakingRewardsEscrow.claimAndStake, (FRIEND, 0)) + ); + vm.prank(BENEFICIARY); + escrow.claimAndStake(RECIPIENT, 100 ether); + _assertReentrancyBlocked(reward); + + vm.warp(block.timestamp + 100); + reward.setCallback(address(vault), address(escrow), abi.encodeCall(StakingRewardsEscrow.claimTo, (FRIEND))); + vm.prank(BENEFICIARY); + escrow.claimAndStake(RECIPIENT, 100 ether); + _assertReentrancyBlocked(reward); + + vm.warp(block.timestamp + 100); + reward.setCallback(address(vault), address(escrow), abi.encodeCall(StakingRewardsEscrow.onDeposit, ())); + vm.prank(BENEFICIARY); + escrow.claimAndStake(RECIPIENT, 100 ether); + _assertReentrancyBlocked(reward); + assertEq(vault.balanceOf(RECIPIENT), 300 ether); + assertEq(vault.balanceOf(FRIEND), 0); + assertEq(reward.balanceOf(FRIEND), 0); + assertEq(escrow.balance(), 10 ether); + } + + function testClaimToBlocksReentrancyIntoClaimAndStake() public { + _stake(10 ether); + _fund(); + vm.prank(BENEFICIARY); + escrow.allowClaimOnBehalf(address(reward)); + vm.warp(block.timestamp + 100); + reward.setCallback( + address(pool), address(escrow), abi.encodeCall(StakingRewardsEscrow.claimAndStake, (FRIEND, 0)) + ); + vm.prank(BENEFICIARY); + escrow.claimTo(RECIPIENT); + _assertReentrancyBlocked(reward); + assertEq(reward.balanceOf(RECIPIENT), 100 ether); + assertEq(vault.totalSupply(), 0); + } + + function testInitializeBlocksRewardApprovalReentrancy() public { + StakingRewardsEscrow instance = StakingRewardsEscrow(Clones.clone(address(implementation))); + reward.setCallback(address(instance), address(instance), abi.encodeCall(StakingRewardsEscrow.onDeposit, ())); + vm.prank(MARKET); + instance.initialize(collateral, BENEFICIARY); + _assertReentrancyBlocked(reward); + assertEq(reward.allowance(address(instance), address(vault)), type(uint256).max); + assertEq(instance.market(), MARKET); + } + function testInitializeBlocksApprovalReentrancy() public { StakingRewardsEscrow instance = StakingRewardsEscrow(Clones.clone(address(implementation))); collateral.setCallback(address(instance), address(instance), abi.encodeCall(StakingRewardsEscrow.onDeposit, ())); @@ -561,13 +857,15 @@ contract StakingRewardsEscrowTest is Test { contract StakingRewardsEscrowMarketTest is FiRMBaseTest { EscrowTestToken reward; StakingRewardsFixture pool; + EscrowTestVault vault; StakingRewardsEscrow stakingImplementation; function setUp() public { initialize(replenishmentPriceBps, collateralFactorBps, replenishmentIncentiveBps, liquidationBonusBps, false); reward = new EscrowTestToken(); + vault = new EscrowTestVault(reward); pool = new StakingRewardsFixture(address(WETH), address(reward), address(this), 1000); - stakingImplementation = new StakingRewardsEscrow(address(pool)); + stakingImplementation = new StakingRewardsEscrow(address(pool), address(vault)); market = new Market( gov, address(fed), @@ -646,6 +944,26 @@ contract StakingRewardsEscrowMarketTest is FiRMBaseTest { assertEq(first.claimable(), 50 ether); } + function testMarketCloneClaimAndStakePreservesCollateralAndCredit() public { + _depositAndBorrow(); + _fundAndAccrue(); + StakingRewardsEscrow instance = _escrow(user); + uint256 creditBefore = market.getCreditLimit(user); + uint256 debtBefore = market.debts(user); + vm.prank(user); + uint256 shares = instance.claimAndStake(user2, 100 ether); + assertEq(shares, 100 ether); + assertEq(vault.balanceOf(user2), shares); + assertEq(vault.balanceOf(user), 0); + assertEq(vault.balanceOf(address(instance)), 0); + assertEq(reward.balanceOf(address(instance)), 0); + assertEq(instance.claimable(), 0); + assertEq(instance.balance(), 2 ether); + assertEq(pool.balanceOf(address(instance)), 2 ether); + assertEq(market.getCreditLimit(user), creditBefore); + assertEq(market.debts(user), debtBefore); + } + function testMarketPartialWithdrawalRepaymentAndFullWithdrawal() public { _depositAndBorrow(); _fundAndAccrue(); From b382488bebe19d7d3587c6a2990b9b6cca91adbb Mon Sep 17 00:00:00 2001 From: 08xmt Date: Wed, 28 Oct 2026 02:09:25 +0100 Subject: [PATCH 4/5] Add deployment script --- scripts/StakingRewardsEscrowDeploy.s.sol | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 scripts/StakingRewardsEscrowDeploy.s.sol diff --git a/scripts/StakingRewardsEscrowDeploy.s.sol b/scripts/StakingRewardsEscrowDeploy.s.sol new file mode 100644 index 00000000..3d890ae6 --- /dev/null +++ b/scripts/StakingRewardsEscrowDeploy.s.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Script, console2} from "forge-std/Script.sol"; +import {StakingRewardsEscrow} from "src/escrows/StakingRewardsEscrow.sol"; + +/// @notice Deploys an escrow implementation for the configured staking pool and sGOV vault. +/// @dev Set STAKING_REWARDS and SGOV; select the network and signer through the Forge CLI. +contract StakingRewardsEscrowDeploy is Script { + function run() external returns (StakingRewardsEscrow implementation) { + address stakingRewards = vm.envAddress("STAKING_REWARDS"); + address sGOV = vm.envAddress("SGOV"); + + vm.startBroadcast(); + implementation = new StakingRewardsEscrow(stakingRewards, sGOV); + vm.stopBroadcast(); + + console2.log("StakingRewardsEscrow implementation deployed to", address(implementation)); + console2.log("Staking rewards pool", stakingRewards); + console2.log("sGOV vault", sGOV); + } +} From 02609fdd92102d7bbd07dbb43cf4442d0e661ae4 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Wed, 28 Oct 2026 12:22:36 +0100 Subject: [PATCH 5/5] Remove claimAndStake functionality --- scripts/StakingRewardsEscrowDeploy.s.sol | 8 +- src/escrows/StakingRewardsEscrow.sol | 34 +-- test/StakingRewardsEscrow.t.sol | 324 +---------------------- 3 files changed, 18 insertions(+), 348 deletions(-) diff --git a/scripts/StakingRewardsEscrowDeploy.s.sol b/scripts/StakingRewardsEscrowDeploy.s.sol index 3d890ae6..61835eea 100644 --- a/scripts/StakingRewardsEscrowDeploy.s.sol +++ b/scripts/StakingRewardsEscrowDeploy.s.sol @@ -4,19 +4,17 @@ pragma solidity ^0.8.20; import {Script, console2} from "forge-std/Script.sol"; import {StakingRewardsEscrow} from "src/escrows/StakingRewardsEscrow.sol"; -/// @notice Deploys an escrow implementation for the configured staking pool and sGOV vault. -/// @dev Set STAKING_REWARDS and SGOV; select the network and signer through the Forge CLI. +/// @notice Deploys an escrow implementation for the configured staking pool. +/// @dev Set STAKING_REWARDS; select the network and signer through the Forge CLI. contract StakingRewardsEscrowDeploy is Script { function run() external returns (StakingRewardsEscrow implementation) { address stakingRewards = vm.envAddress("STAKING_REWARDS"); - address sGOV = vm.envAddress("SGOV"); vm.startBroadcast(); - implementation = new StakingRewardsEscrow(stakingRewards, sGOV); + implementation = new StakingRewardsEscrow(stakingRewards); vm.stopBroadcast(); console2.log("StakingRewardsEscrow implementation deployed to", address(implementation)); console2.log("Staking rewards pool", stakingRewards); - console2.log("sGOV vault", sGOV); } } diff --git a/src/escrows/StakingRewardsEscrow.sol b/src/escrows/StakingRewardsEscrow.sol index b69aef9c..2e5f21b3 100644 --- a/src/escrows/StakingRewardsEscrow.sol +++ b/src/escrows/StakingRewardsEscrow.sol @@ -2,21 +2,18 @@ pragma solidity ^0.8.20; import {IERC20, SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; -import {IERC4626} from "openzeppelin-contracts/contracts/interfaces/IERC4626.sol"; import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; import {IStakingRewards} from "src/interfaces/IStakingRewards.sol"; /// @notice Stakes a borrower's FiRM collateral and lets them claim rewards separately. /// @dev Deploy an implementation per initialized staking pool, then initialize each /// clone atomically from its market. Tokens must transfer exact amounts and must -/// not rebase. The pool's staking and reward token configuration and the trusted -/// sGOV vault's underlying asset must remain fixed. +/// not rebase. The pool's staking and reward token configuration must remain fixed. contract StakingRewardsEscrow is ReentrancyGuard { using SafeERC20 for IERC20; error InvalidStakingRewards(); error InvalidToken(); - error InvalidVault(); error IdenticalTokens(); error InvalidBeneficiary(); error InvalidRecipient(); @@ -24,12 +21,10 @@ contract StakingRewardsEscrow is ReentrancyGuard { error OnlyMarket(); error OnlyBeneficiary(); error OnlyBeneficiaryOrAllowlist(); - error InsufficientShares(); IStakingRewards public immutable stakingRewards; IERC20 public immutable token; IERC20 public immutable rewardsToken; - IERC4626 public immutable sGOV; address public market; address public beneficiary; @@ -47,24 +42,20 @@ contract StakingRewardsEscrow is ReentrancyGuard { _; } - constructor(address _stakingRewards, address _sGOV) { + constructor(address _stakingRewards) { if (_stakingRewards.code.length == 0) revert InvalidStakingRewards(); IStakingRewards pool = IStakingRewards(_stakingRewards); IERC20 collateral = pool.stakingToken(); IERC20 reward = pool.rewardsToken(); if (address(collateral).code.length == 0 || address(reward).code.length == 0) revert InvalidToken(); if (collateral == reward) revert IdenticalTokens(); - if (_sGOV.code.length == 0) revert InvalidVault(); - IERC4626 vault = IERC4626(_sGOV); - if (vault.asset() != address(reward)) revert InvalidVault(); stakingRewards = pool; token = collateral; rewardsToken = reward; - sGOV = vault; } - /// @notice Binds a new clone to its market and beneficiary and approves collateral and reward staking. + /// @notice Binds a new clone to its market and beneficiary and approves collateral staking. /// @dev Must be called by the market immediately after clone creation. function initialize(IERC20 _token, address _beneficiary) external nonReentrant { if (market != address(0)) revert AlreadyInitialized(); @@ -73,7 +64,6 @@ contract StakingRewardsEscrow is ReentrancyGuard { market = msg.sender; beneficiary = _beneficiary; token.forceApprove(address(stakingRewards), type(uint256).max); - rewardsToken.forceApprove(address(sGOV), type(uint256).max); } /// @notice Stakes all idle collateral, including direct transfers into the escrow. @@ -112,24 +102,6 @@ contract StakingRewardsEscrow is ReentrancyGuard { if (amount != 0) rewardsToken.safeTransfer(to, amount); } - /// @notice Claims rewards and deposits all held reward tokens into sGOV for the receiver. - /// @dev Uses the same permissions as claimTo. Any failure reverts both the claim and deposit. - /// @param receiver The nonzero address that receives the sGOV shares directly. - /// @param minShares The minimum shares to mint; zero disables the output floor. - /// @return shares The minted shares, or zero when there are no rewards to deposit. - function claimAndStake(address receiver, uint256 minShares) - external - onlyBeneficiaryOrAllowlist - nonReentrant - returns (uint256 shares) - { - if (receiver == address(0)) revert InvalidRecipient(); - stakingRewards.getReward(); - uint256 amount = rewardsToken.balanceOf(address(this)); - if (amount != 0) shares = sGOV.deposit(amount, receiver); - if (shares < minShares) revert InsufficientShares(); - } - /// @notice Returns pending staking rewards plus reward tokens already held here. function claimable() external view returns (uint256) { return stakingRewards.earned(address(this)) + rewardsToken.balanceOf(address(this)); diff --git a/test/StakingRewardsEscrow.t.sol b/test/StakingRewardsEscrow.t.sol index 117476f8..cb24ffdc 100644 --- a/test/StakingRewardsEscrow.t.sol +++ b/test/StakingRewardsEscrow.t.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.20; import {Test} from "forge-std/Test.sol"; import {Clones} from "openzeppelin-contracts/contracts/proxy/Clones.sol"; import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; -import {ERC4626} from "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sol"; import {IERC20, SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol"; import {StakingRewardsEscrow} from "src/escrows/StakingRewardsEscrow.sol"; @@ -103,11 +102,6 @@ contract EscrowTestToken is ERC20 { } } -/// @dev Standard ERC-4626 accounting and transfers, with no custom staking behavior. -contract EscrowTestVault is ERC4626 { - constructor(IERC20 asset_) ERC20("Staked reward", "sGOV") ERC4626(asset_) {} -} - contract StakingRewardsEscrowTest is Test { uint256 constant DURATION = 1000; uint256 constant FUNDING = 1000 ether; @@ -119,7 +113,6 @@ contract StakingRewardsEscrowTest is Test { EscrowTestToken collateral; EscrowTestToken reward; StakingRewardsFixture pool; - EscrowTestVault vault; StakingRewardsEscrow implementation; StakingRewardsEscrow escrow; @@ -129,9 +122,8 @@ contract StakingRewardsEscrowTest is Test { vm.warp(1 days); collateral = new EscrowTestToken(); reward = new EscrowTestToken(); - vault = new EscrowTestVault(reward); pool = new StakingRewardsFixture(address(collateral), address(reward), address(this), DURATION); - implementation = new StakingRewardsEscrow(address(pool), address(vault)); + implementation = new StakingRewardsEscrow(address(pool)); escrow = _clone(BENEFICIARY); } @@ -156,13 +148,6 @@ contract StakingRewardsEscrowTest is Test { escrow.pay(RECIPIENT, amount); } - function _seedVaultWithYield() internal { - reward.mint(address(this), 100 ether); - reward.approve(address(vault), 100 ether); - vault.deposit(100 ether, address(this)); - reward.mint(address(vault), 100 ether); - } - function _assertReentrancyBlocked(EscrowTestToken token) internal view { assertFalse(token.callbackSucceeded()); assertEq(token.callbackResult(), abi.encodeWithSelector(ReentrancyGuard.ReentrancyGuardReentrantCall.selector)); @@ -174,11 +159,8 @@ contract StakingRewardsEscrowTest is Test { assertEq(address(escrow.token()), address(collateral)); assertEq(address(escrow.rewardsToken()), address(reward)); assertEq(address(escrow.stakingRewards()), address(pool)); - assertEq(address(escrow.sGOV()), address(vault)); assertEq(collateral.allowance(address(escrow), address(pool)), type(uint256).max); - assertEq(reward.allowance(address(escrow), address(vault)), type(uint256).max); assertEq(collateral.allowance(address(implementation), address(pool)), 0); - assertEq(reward.allowance(address(implementation), address(vault)), 0); } function testCannotInitializeImplementationOrReinitializeClone() public { @@ -206,54 +188,38 @@ contract StakingRewardsEscrowTest is Test { function testConstructorRejectsMissingPoolCode() public { vm.expectRevert(StakingRewardsEscrow.InvalidStakingRewards.selector); - new StakingRewardsEscrow(address(0), address(vault)); + new StakingRewardsEscrow(address(0)); vm.expectRevert(StakingRewardsEscrow.InvalidStakingRewards.selector); - new StakingRewardsEscrow(FRIEND, address(vault)); + new StakingRewardsEscrow(FRIEND); } function testConstructorRejectsInvalidTokenConfiguration() public { vm.mockCall(address(pool), abi.encodeCall(IStakingRewards.stakingToken, ()), abi.encode(address(0))); vm.expectRevert(StakingRewardsEscrow.InvalidToken.selector); - new StakingRewardsEscrow(address(pool), address(vault)); + new StakingRewardsEscrow(address(pool)); vm.clearMockedCalls(); vm.mockCall(address(pool), abi.encodeCall(IStakingRewards.rewardsToken, ()), abi.encode(address(0))); vm.expectRevert(StakingRewardsEscrow.InvalidToken.selector); - new StakingRewardsEscrow(address(pool), address(vault)); + new StakingRewardsEscrow(address(pool)); vm.mockCall(address(pool), abi.encodeCall(IStakingRewards.rewardsToken, ()), abi.encode(FRIEND)); vm.expectRevert(StakingRewardsEscrow.InvalidToken.selector); - new StakingRewardsEscrow(address(pool), address(vault)); + new StakingRewardsEscrow(address(pool)); } function testConstructorRejectsIdenticalTokens() public { StakingRewardsFixture sameTokenPool = new StakingRewardsFixture(address(collateral), address(collateral), address(this), DURATION); vm.expectRevert(StakingRewardsEscrow.IdenticalTokens.selector); - new StakingRewardsEscrow(address(sameTokenPool), address(vault)); + new StakingRewardsEscrow(address(sameTokenPool)); } function testInitializeHandlesZeroFirstApproval() public { StakingRewardsEscrow instance = StakingRewardsEscrow(Clones.clone(address(implementation))); collateral.setRequireZeroApproval(true); collateral.setAllowance(address(instance), address(pool), 1); - reward.setRequireZeroApproval(true); - reward.setAllowance(address(instance), address(vault), 1); vm.prank(MARKET); instance.initialize(collateral, BENEFICIARY); assertEq(collateral.allowance(address(instance), address(pool)), type(uint256).max); - assertEq(reward.allowance(address(instance), address(vault)), type(uint256).max); - } - - function testConstructorRejectsMissingVaultCode() public { - vm.expectRevert(StakingRewardsEscrow.InvalidVault.selector); - new StakingRewardsEscrow(address(pool), address(0)); - vm.expectRevert(StakingRewardsEscrow.InvalidVault.selector); - new StakingRewardsEscrow(address(pool), FRIEND); - } - - function testConstructorRejectsWrongVaultAsset() public { - EscrowTestVault wrongVault = new EscrowTestVault(collateral); - vm.expectRevert(StakingRewardsEscrow.InvalidVault.selector); - new StakingRewardsEscrow(address(pool), address(wrongVault)); } function testNoReturnTokensCanInitializeStakePayAndClaim() public { @@ -543,268 +509,6 @@ contract StakingRewardsEscrowTest is Test { assertEq(reward.balanceOf(FRIEND), 750 ether); } - function testClaimAndStakeAccruesRewardsAndIncludesHeldBalance() public { - _stake(10 ether); - collateral.mint(address(escrow), 3 ether); - _fund(); - reward.mint(address(escrow), 7 ether); - vm.warp(block.timestamp + 100); - vm.expectCall(address(vault), abi.encodeCall(ERC4626.deposit, (107 ether, RECIPIENT))); - vm.prank(BENEFICIARY); - uint256 shares = escrow.claimAndStake(RECIPIENT, 107 ether); - assertEq(shares, 107 ether); - assertEq(vault.balanceOf(RECIPIENT), shares); - assertEq(vault.balanceOf(address(escrow)), 0); - assertEq(vault.balanceOf(BENEFICIARY), 0); - assertEq(vault.totalAssets(), 107 ether); - assertEq(reward.balanceOf(address(pool)), 900 ether); - assertEq(reward.balanceOf(address(escrow)), 0); - assertEq(reward.balanceOf(RECIPIENT), 0); - assertEq(escrow.claimable(), 0); - assertEq(escrow.balance(), 13 ether); - assertEq(pool.balanceOf(address(escrow)), 10 ether); - - vm.warp(block.timestamp + 100); - vm.prank(BENEFICIARY); - assertEq(escrow.claimAndStake(RECIPIENT, 100 ether), 100 ether); - assertEq(vault.balanceOf(RECIPIENT), 207 ether); - assertEq(escrow.claimable(), 0); - } - - function testClaimAndStakeAllowlistAndRevocation() public { - reward.mint(address(escrow), 7 ether); - vm.prank(FRIEND); - vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiaryOrAllowlist.selector); - escrow.claimAndStake(RECIPIENT, 0); - vm.prank(MARKET); - vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiaryOrAllowlist.selector); - escrow.claimAndStake(RECIPIENT, 0); - - vm.prank(BENEFICIARY); - escrow.allowClaimOnBehalf(FRIEND); - vm.prank(FRIEND); - assertEq(escrow.claimAndStake(FRIEND, 7 ether), 7 ether); - assertEq(vault.balanceOf(FRIEND), 7 ether); - assertEq(escrow.balance(), 0); - - reward.mint(address(escrow), 3 ether); - vm.prank(BENEFICIARY); - escrow.disallowClaimOnBehalf(FRIEND); - vm.prank(FRIEND); - vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiaryOrAllowlist.selector); - escrow.claimAndStake(RECIPIENT, 0); - assertEq(escrow.claimable(), 3 ether); - } - - function testClaimAndStakeRejectsZeroReceiverBeforeClaiming() public { - _stake(10 ether); - _fund(); - vm.warp(block.timestamp + 100); - vm.mockCallRevert(address(pool), abi.encodeCall(IStakingRewards.getReward, ()), "UNEXPECTED CLAIM"); - vm.prank(BENEFICIARY); - vm.expectRevert(StakingRewardsEscrow.InvalidRecipient.selector); - escrow.claimAndStake(address(0), 0); - assertEq(escrow.claimable(), 100 ether); - } - - function testClaimAndStakeZeroRewardsSkipsDepositAndEnforcesMinimum() public { - vm.mockCallRevert(address(vault), abi.encodeWithSelector(ERC4626.deposit.selector), "UNEXPECTED DEPOSIT"); - vm.prank(BENEFICIARY); - assertEq(escrow.claimAndStake(RECIPIENT, 0), 0); - vm.prank(BENEFICIARY); - vm.expectRevert(StakingRewardsEscrow.InsufficientShares.selector); - escrow.claimAndStake(RECIPIENT, 1); - assertEq(vault.totalSupply(), 0); - } - - function testClaimAndStakeNonUnitSharePriceAndExactMinimum() public { - _seedVaultWithYield(); - reward.mint(address(escrow), 100 ether); - uint256 expectedShares = vault.previewDeposit(100 ether); - assertLt(expectedShares, 100 ether); - vm.prank(BENEFICIARY); - uint256 shares = escrow.claimAndStake(RECIPIENT, expectedShares); - assertEq(shares, expectedShares); - assertEq(vault.balanceOf(RECIPIENT), expectedShares); - assertEq(vault.totalAssets(), 300 ether); - assertEq(escrow.claimable(), 0); - } - - function testClaimAndStakeInsufficientSharesRollsBackClaimAndDeposit() public { - _seedVaultWithYield(); - _stake(10 ether); - _fund(); - reward.mint(address(escrow), 7 ether); - vm.warp(block.timestamp + 100); - uint256 expectedShares = vault.previewDeposit(107 ether); - uint256 supplyBefore = vault.totalSupply(); - vm.prank(BENEFICIARY); - vm.expectRevert(StakingRewardsEscrow.InsufficientShares.selector); - escrow.claimAndStake(RECIPIENT, expectedShares + 1); - assertEq(escrow.claimable(), 107 ether); - assertEq(pool.earned(address(escrow)), 100 ether); - assertEq(reward.balanceOf(address(pool)), FUNDING); - assertEq(reward.balanceOf(address(escrow)), 7 ether); - assertEq(vault.balanceOf(RECIPIENT), 0); - assertEq(vault.totalSupply(), supplyBefore); - assertEq(vault.totalAssets(), 200 ether); - assertEq(escrow.balance(), 10 ether); - } - - function testClaimAndStakeZeroMinimumAcceptsVaultOutput() public { - _seedVaultWithYield(); - reward.mint(address(escrow), 100 ether); - uint256 expectedShares = vault.previewDeposit(100 ether); - vm.prank(BENEFICIARY); - assertEq(escrow.claimAndStake(RECIPIENT, 0), expectedShares); - assertEq(vault.balanceOf(RECIPIENT), expectedShares); - } - - function testClaimAndStakeDepositFailureRollsBackPoolClaim() public { - _stake(10 ether); - _fund(); - vm.warp(block.timestamp + 100); - vm.mockCallRevert(address(vault), abi.encodeWithSelector(ERC4626.deposit.selector), "DEPOSIT FAILED"); - vm.prank(BENEFICIARY); - vm.expectRevert("DEPOSIT FAILED"); - escrow.claimAndStake(RECIPIENT, 0); - assertEq(escrow.claimable(), 100 ether); - assertEq(reward.balanceOf(address(pool)), FUNDING); - assertEq(reward.balanceOf(address(escrow)), 0); - assertEq(vault.balanceOf(RECIPIENT), 0); - assertEq(escrow.balance(), 10 ether); - _pay(10 ether); - assertEq(collateral.balanceOf(RECIPIENT), 10 ether); - } - - function testClaimAndStakeRewardTransferFailurePreservesRewards() public { - _stake(10 ether); - _fund(); - vm.warp(block.timestamp + 100); - reward.setTransferFailure(true, address(pool), true); - vm.prank(BENEFICIARY); - vm.expectRevert(abi.encodeWithSelector(SafeERC20.SafeERC20FailedOperation.selector, address(reward))); - escrow.claimAndStake(RECIPIENT, 0); - assertEq(escrow.claimable(), 100 ether); - assertEq(reward.balanceOf(address(pool)), FUNDING); - assertEq(vault.totalSupply(), 0); - } - - function testClaimAndStakeTransferFromFailureRollsBackPoolClaim() public { - _stake(10 ether); - _fund(); - vm.warp(block.timestamp + 100); - vm.mockCall( - address(reward), - abi.encodeCall(IERC20.transferFrom, (address(escrow), address(vault), 100 ether)), - abi.encode(false) - ); - vm.prank(BENEFICIARY); - vm.expectRevert(abi.encodeWithSelector(SafeERC20.SafeERC20FailedOperation.selector, address(reward))); - escrow.claimAndStake(RECIPIENT, 0); - assertEq(escrow.claimable(), 100 ether); - assertEq(reward.balanceOf(address(pool)), FUNDING); - assertEq(reward.balanceOf(address(escrow)), 0); - assertEq(vault.totalAssets(), 0); - assertEq(vault.totalSupply(), 0); - } - - function testNoReturnTokensCanClaimAndStakeAfterFullWithdrawal() public { - collateral.setReturnNothing(true); - reward.setReturnNothing(true); - escrow = _clone(BENEFICIARY); - _stake(10 ether); - _fund(); - vm.warp(block.timestamp + 100); - _pay(10 ether); - assertEq(escrow.balance(), 0); - vm.prank(BENEFICIARY); - assertEq(escrow.claimAndStake(RECIPIENT, 100 ether), 100 ether); - assertEq(vault.balanceOf(RECIPIENT), 100 ether); - assertEq(escrow.claimable(), 0); - } - - function testClaimAndStakeClonesHaveIndependentRewardsAndPermissions() public { - StakingRewardsEscrow other = _clone(FRIEND); - _stake(10 ether); - collateral.mint(address(other), 30 ether); - other.onDeposit(); - _fund(); - vm.warp(block.timestamp + DURATION); - vm.prank(BENEFICIARY); - escrow.allowClaimOnBehalf(RECIPIENT); - assertFalse(other.allowlist(RECIPIENT)); - vm.prank(RECIPIENT); - vm.expectRevert(StakingRewardsEscrow.OnlyBeneficiaryOrAllowlist.selector); - other.claimAndStake(RECIPIENT, 0); - vm.prank(RECIPIENT); - assertEq(escrow.claimAndStake(BENEFICIARY, 250 ether), 250 ether); - assertEq(other.claimable(), 750 ether); - assertEq(other.balance(), 30 ether); - vm.prank(FRIEND); - assertEq(other.claimAndStake(FRIEND, 750 ether), 750 ether); - assertEq(vault.balanceOf(BENEFICIARY), 250 ether); - assertEq(vault.balanceOf(FRIEND), 750 ether); - assertEq(escrow.claimable(), 0); - assertEq(other.claimable(), 0); - } - - function testClaimAndStakeBlocksReentrancyDuringClaimAndDeposit() public { - _stake(10 ether); - _fund(); - vm.prank(BENEFICIARY); - escrow.allowClaimOnBehalf(address(reward)); - vm.warp(block.timestamp + 100); - reward.setCallback( - address(pool), address(escrow), abi.encodeCall(StakingRewardsEscrow.claimAndStake, (FRIEND, 0)) - ); - vm.prank(BENEFICIARY); - escrow.claimAndStake(RECIPIENT, 100 ether); - _assertReentrancyBlocked(reward); - - vm.warp(block.timestamp + 100); - reward.setCallback(address(vault), address(escrow), abi.encodeCall(StakingRewardsEscrow.claimTo, (FRIEND))); - vm.prank(BENEFICIARY); - escrow.claimAndStake(RECIPIENT, 100 ether); - _assertReentrancyBlocked(reward); - - vm.warp(block.timestamp + 100); - reward.setCallback(address(vault), address(escrow), abi.encodeCall(StakingRewardsEscrow.onDeposit, ())); - vm.prank(BENEFICIARY); - escrow.claimAndStake(RECIPIENT, 100 ether); - _assertReentrancyBlocked(reward); - assertEq(vault.balanceOf(RECIPIENT), 300 ether); - assertEq(vault.balanceOf(FRIEND), 0); - assertEq(reward.balanceOf(FRIEND), 0); - assertEq(escrow.balance(), 10 ether); - } - - function testClaimToBlocksReentrancyIntoClaimAndStake() public { - _stake(10 ether); - _fund(); - vm.prank(BENEFICIARY); - escrow.allowClaimOnBehalf(address(reward)); - vm.warp(block.timestamp + 100); - reward.setCallback( - address(pool), address(escrow), abi.encodeCall(StakingRewardsEscrow.claimAndStake, (FRIEND, 0)) - ); - vm.prank(BENEFICIARY); - escrow.claimTo(RECIPIENT); - _assertReentrancyBlocked(reward); - assertEq(reward.balanceOf(RECIPIENT), 100 ether); - assertEq(vault.totalSupply(), 0); - } - - function testInitializeBlocksRewardApprovalReentrancy() public { - StakingRewardsEscrow instance = StakingRewardsEscrow(Clones.clone(address(implementation))); - reward.setCallback(address(instance), address(instance), abi.encodeCall(StakingRewardsEscrow.onDeposit, ())); - vm.prank(MARKET); - instance.initialize(collateral, BENEFICIARY); - _assertReentrancyBlocked(reward); - assertEq(reward.allowance(address(instance), address(vault)), type(uint256).max); - assertEq(instance.market(), MARKET); - } - function testInitializeBlocksApprovalReentrancy() public { StakingRewardsEscrow instance = StakingRewardsEscrow(Clones.clone(address(implementation))); collateral.setCallback(address(instance), address(instance), abi.encodeCall(StakingRewardsEscrow.onDeposit, ())); @@ -857,15 +561,13 @@ contract StakingRewardsEscrowTest is Test { contract StakingRewardsEscrowMarketTest is FiRMBaseTest { EscrowTestToken reward; StakingRewardsFixture pool; - EscrowTestVault vault; StakingRewardsEscrow stakingImplementation; function setUp() public { initialize(replenishmentPriceBps, collateralFactorBps, replenishmentIncentiveBps, liquidationBonusBps, false); reward = new EscrowTestToken(); - vault = new EscrowTestVault(reward); pool = new StakingRewardsFixture(address(WETH), address(reward), address(this), 1000); - stakingImplementation = new StakingRewardsEscrow(address(pool), address(vault)); + stakingImplementation = new StakingRewardsEscrow(address(pool)); market = new Market( gov, address(fed), @@ -944,18 +646,16 @@ contract StakingRewardsEscrowMarketTest is FiRMBaseTest { assertEq(first.claimable(), 50 ether); } - function testMarketCloneClaimAndStakePreservesCollateralAndCredit() public { + function testMarketCloneClaimToPreservesCollateralAndCredit() public { _depositAndBorrow(); _fundAndAccrue(); StakingRewardsEscrow instance = _escrow(user); uint256 creditBefore = market.getCreditLimit(user); uint256 debtBefore = market.debts(user); vm.prank(user); - uint256 shares = instance.claimAndStake(user2, 100 ether); - assertEq(shares, 100 ether); - assertEq(vault.balanceOf(user2), shares); - assertEq(vault.balanceOf(user), 0); - assertEq(vault.balanceOf(address(instance)), 0); + instance.claimTo(user2); + assertEq(reward.balanceOf(user2), 100 ether); + assertEq(reward.balanceOf(user), 0); assertEq(reward.balanceOf(address(instance)), 0); assertEq(instance.claimable(), 0); assertEq(instance.balance(), 2 ether);