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. diff --git a/scripts/StakingRewardsEscrowDeploy.s.sol b/scripts/StakingRewardsEscrowDeploy.s.sol new file mode 100644 index 00000000..61835eea --- /dev/null +++ b/scripts/StakingRewardsEscrowDeploy.s.sol @@ -0,0 +1,20 @@ +// 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. +/// @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"); + + vm.startBroadcast(); + implementation = new StakingRewardsEscrow(stakingRewards); + vm.stopBroadcast(); + + console2.log("StakingRewardsEscrow implementation deployed to", address(implementation)); + console2.log("Staking rewards pool", stakingRewards); + } +} diff --git a/src/escrows/StakingRewardsEscrow.sol b/src/escrows/StakingRewardsEscrow.sol new file mode 100644 index 00000000..2e5f21b3 --- /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 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(); + 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..cb24ffdc --- /dev/null +++ b/test/StakingRewardsEscrow.t.sol @@ -0,0 +1,732 @@ +// 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 testAllowlistGrantsAndRevokesClaimTo() 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 testMarketCloneClaimToPreservesCollateralAndCredit() public { + _depositAndBorrow(); + _fundAndAccrue(); + StakingRewardsEscrow instance = _escrow(user); + uint256 creditBefore = market.getCreditLimit(user); + uint256 debtBefore = market.debts(user); + vm.prank(user); + 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); + assertEq(pool.balanceOf(address(instance)), 2 ether); + assertEq(market.getCreditLimit(user), creditBefore); + assertEq(market.debts(user), debtBefore); + } + + 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; + } +}