diff --git a/.github/workflows/foundry-gas-diff.yml b/.github/workflows/foundry-gas-diff.yml index 47a82a1..48aa85f 100644 --- a/.github/workflows/foundry-gas-diff.yml +++ b/.github/workflows/foundry-gas-diff.yml @@ -35,6 +35,7 @@ jobs: # due to non-deterministic fuzzing, but keep it not always deterministic POLYGON_RPC_URL: ${{ secrets.POLYGON_RPC_URL }} AVALANCHE_RPC_URL: ${{ secrets.AVALANCHE_RPC_URL }} + ETHEREUM_RPC_URL: ${{ secrets.ETHEREUM_RPC_URL }} FOUNDRY_FUZZ_SEED: 0x${{ github.event.pull_request.base.sha || github.sha }} - name: Compare gas reports diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cb051b2..17b698b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,4 +23,5 @@ jobs: env: POLYGON_RPC_URL: ${{ secrets.POLYGON_RPC_URL }} AVALANCHE_RPC_URL: ${{ secrets.AVALANCHE_RPC_URL }} + ETHEREUM_RPC_URL: ${{ secrets.ETHEREUM_RPC_URL }} run: forge test -vvv diff --git a/README.md b/README.md index 2e2c4f5..99615da 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,10 @@ Some of the tests rely on an RPC connection for forking network state. Make sure ``` POLYGON_RPC_URL=[Your favourite Polygon RPC URL] AVALANCHE_RPC_URL=[Your favourite Avalanche RPC URL] +ETHEREUM_RPC_URL=[Your favourite Ethereum RPC URL] ``` -The fork tests all use Polygon, except tests for claiming Aave rewards, which use Avalanche. +The fork tests all use Polygon, except tests for claiming Aave rewards, which use Avalanche, and Merkl rewards, which use Ethereum. This test suite also includes a16z's [ERC-4626 Property Tests](https://a16zcrypto.com/generalized-property-tests-for-erc4626-vaults/), which are in the `ATokenVaultProperties.t.sol` file. These tests do not use a forked network state but rather use mock contracts, found in the `test/mocks` folder. diff --git a/foundry.toml b/foundry.toml index fbb8653..cf85492 100644 --- a/foundry.toml +++ b/foundry.toml @@ -8,6 +8,7 @@ runs = 256 gas_reports = ["ATokenVault"] isolate = true ignored_warnings_from = ["lib/", "node_modules/"] # Ignore warnings from dependencies +evm_version = 'cancun' [fuzz] max_test_rejects = 65536 diff --git a/src/ATokenVaultMerklRewardClaimer.sol b/src/ATokenVaultMerklRewardClaimer.sol new file mode 100644 index 0000000..385a074 --- /dev/null +++ b/src/ATokenVaultMerklRewardClaimer.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: UNLICENSED +// All Rights Reserved © AaveCo + +pragma solidity ^0.8.10; + +import {IPoolAddressesProvider} from "@aave-v3-core/interfaces/IPoolAddressesProvider.sol"; +import {IERC20Upgradeable} from "@openzeppelin-upgradeable/interfaces/IERC20Upgradeable.sol"; +import {SafeERC20Upgradeable} from "@openzeppelin-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; +import {IMerklDistributor} from "./dependencies/merkl/DistributorInterface.sol"; +import {ATokenVault} from "./ATokenVault.sol"; +import {IATokenVaultMerklRewardClaimer} from "./interfaces/IATokenVaultMerklRewardClaimer.sol"; + +/** + * @title ATokenVaultMerklRewardClaimer + * @author Aave Protocol + * @notice ATokenVault, with Merkl reward claiming capability + */ +contract ATokenVaultMerklRewardClaimer is ATokenVault, IATokenVaultMerklRewardClaimer { + using SafeERC20Upgradeable for IERC20Upgradeable; + + /** + * @dev Constructor. + * @param underlying The underlying ERC20 asset which can be supplied to Aave + * @param referralCode The Aave referral code to use for deposits from this vault + * @param poolAddressesProvider The address of the Aave v3 Pool Addresses Provider + */ + constructor(address underlying, uint16 referralCode, IPoolAddressesProvider poolAddressesProvider) + ATokenVault(underlying, referralCode, poolAddressesProvider) + {} + + /// @inheritdoc IATokenVaultMerklRewardClaimer + function claimMerklRewards( + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs, + address[] calldata rewardTokensToForward, + address destination + ) + public + override + onlyOwner + { + require(_s.merklDistributor != address(0), "MERKL_DISTRIBUTOR_NOT_SET"); + require(tokens.length == amounts.length && tokens.length == proofs.length, "ARRAY_LENGTH_MISMATCH"); + + uint256[] memory currentBalancesOfRewardTokens = new uint256[](rewardTokensToForward.length); + for (uint256 i = 0; i < rewardTokensToForward.length; i++) { + currentBalancesOfRewardTokens[i] = IERC20Upgradeable(rewardTokensToForward[i]).balanceOf(address(this)); + } + + address[] memory users = new address[](tokens.length); + for (uint256 i = 0; i < tokens.length; i++) { + users[i] = address(this); + } + IMerklDistributor(_s.merklDistributor).claim(users, tokens, amounts, proofs); + emit MerklRewardsClaimed(_s.merklDistributor, tokens, amounts); + + for (uint256 i = 0; i < rewardTokensToForward.length; i++) { + uint256 newBalance = IERC20Upgradeable(rewardTokensToForward[i]).balanceOf(address(this)); + uint256 amountToForward = newBalance - currentBalancesOfRewardTokens[i]; + if (amountToForward > 0) { + IERC20Upgradeable(rewardTokensToForward[i]).safeTransfer(destination, amountToForward); + emit MerklRewardsTokenForwarded(rewardTokensToForward[i], destination, amountToForward); + } + } + } + + /// @inheritdoc IATokenVaultMerklRewardClaimer + /// @dev Allow setting address(0) to reset the Merkl distributor + function setMerklDistributor(address merklDistributor) external override onlyOwner { + address currentMerklDistributor = _s.merklDistributor; + _s.merklDistributor = merklDistributor; + emit MerklDistributorUpdated(currentMerklDistributor, merklDistributor); + } + + /// @inheritdoc IATokenVaultMerklRewardClaimer + function getMerklDistributor() external view override returns (address) { + return _s.merklDistributor; + } +} diff --git a/src/ATokenVaultStorage.sol b/src/ATokenVaultStorage.sol index 350c4f7..fbca1c6 100644 --- a/src/ATokenVaultStorage.sol +++ b/src/ATokenVaultStorage.sol @@ -20,8 +20,10 @@ abstract contract ATokenVaultStorage { uint40 __deprecated_gap; // as a fraction of 1e18 uint64 fee; + // Merkl distributor contract + address merklDistributor; // Reserved storage space to allow for layout changes in the future - uint256[50] __gap; + uint256[49] __gap; } Storage internal _s; diff --git a/src/dependencies/merkl/DistributorInterface.sol b/src/dependencies/merkl/DistributorInterface.sol new file mode 100644 index 0000000..e364257 --- /dev/null +++ b/src/dependencies/merkl/DistributorInterface.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Based on implementation from https://github.com/AngleProtocol/merkl-contracts/blob/b7bd0e65a3f366e4041bc83494cbd981f8852b16/contracts/Distributor.sol#L202 +pragma solidity ^0.8.10; + +interface IMerklDistributor { + function claim( + address[] calldata users, + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs + ) external; +} diff --git a/src/interfaces/IATokenVaultMerklRewardClaimer.sol b/src/interfaces/IATokenVaultMerklRewardClaimer.sol new file mode 100644 index 0000000..b29039f --- /dev/null +++ b/src/interfaces/IATokenVaultMerklRewardClaimer.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: UNLICENSED +// All Rights Reserved © AaveCo + +pragma solidity ^0.8.10; + +/** + * @title IATokenVaultMerklRewardClaimer + * @author Aave Protocol + * @notice Defines the basic interface of the ATokenVaultMerklRewardClaimer + */ +interface IATokenVaultMerklRewardClaimer { + /** + * @dev Emitted when Merkl rewards are claimed by the vault contract + * @dev The token addresses do not always match the actual tokens received by the vault contract after rewards are claimed + * @dev The amounts do not always match the actual amounts received (the amounts may be the cumulative rewards earned by the user) + * @param distributor Address of the Merkl distributor contract + * @param tokens Addresses of the ERC-20 reward tokens claimed (the tokens passed as params to the Merkl distributor contract) + * @param amounts Amounts of the reward tokens claimed for each token (the amounts passed as params to the Merkl distributor contract) + */ + event MerklRewardsClaimed(address indexed distributor, address[] tokens, uint256[] amounts); + + /** + * @dev Emitted when a reward token is forwarded to a destination + * @param token Address of the ERC-20 reward token + * @param destination Address of the destination receiving the reward token + * @param amount Amount of the reward token transferred to the destination + */ + event MerklRewardsTokenForwarded(address indexed token, address indexed destination, uint256 indexed amount); + + /** + * @dev Emitted when the Merkl distributor address is updated + * @param oldMerklDistributor The old address of the Merkl distributor contract + * @param newMerklDistributor The new address of the Merkl distributor contract + */ + event MerklDistributorUpdated(address indexed oldMerklDistributor, address indexed newMerklDistributor); + + /** + * @notice Claims Merkl protocol rewards accrued from vault deposits. + * @dev Only callable by the owner + * @dev Merkl distributor address must be set + * @dev The IMerklDistributor.claim(...) function does not return a list of tokens and amounts the users actually receive + * @dev The order of the tokens, amounts, and proofs must align with eachother + * @param tokens Addresses of the ERC-20 reward tokens to claim (the tokens passed as params to the Merkl distributor contract) + * @param amounts Amounts of the reward tokens to claim for each token + * @param proofs Merkl proof passed to the Merkl distributor contract + * @param rewardTokensToForward Addresses of the ERC-20 tokens received by the vault contract from reward claiming (to be set if rewards should be forwarded to the `destination`) + * @param destination Address to send the received tokens to + */ + function claimMerklRewards( + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs, + address[] calldata rewardTokensToForward, + address destination + ) external; + + /** + * @notice Sets the Merkl distributor address for the vault uses to claim Merkl rewards. + * @dev Only callable by the owner + * @param merklDistributor Address of the new Merkl distributor contract + */ + function setMerklDistributor(address merklDistributor) external; + + /** + * @notice Returns the address of the Merkl distributor contract. + * @return The address of the Merkl distributor contract + */ + function getMerklDistributor() external view returns (address); +} diff --git a/test/ATokenVaultBaseTest.t.sol b/test/ATokenVaultBaseTest.t.sol index ed3d653..12e15da 100644 --- a/test/ATokenVaultBaseTest.t.sol +++ b/test/ATokenVaultBaseTest.t.sol @@ -10,6 +10,7 @@ import {IPoolAddressesProvider} from "@aave-v3-core/interfaces/IPoolAddressesPro import {TransparentUpgradeableProxy} from "@openzeppelin/proxy/transparent/TransparentUpgradeableProxy.sol"; import {ATokenVault, MathUpgradeable} from "../src/ATokenVault.sol"; +import {ATokenVaultMerklRewardClaimer} from "../src/ATokenVaultMerklRewardClaimer.sol"; contract ATokenVaultBaseTest is Test { using SafeERC20Upgradeable for IERC20Upgradeable; @@ -143,4 +144,31 @@ contract ATokenVaultBaseTest is Test { vault = ATokenVault(address(proxy)); } + + function _deployATokenVaultMerklRewardClaimer(address underlying, address addressesProvider) internal { + _deployATokenVaultMerklRewardClaimer(underlying, addressesProvider, 10e18); + } + + function _deployATokenVaultMerklRewardClaimer(address underlying, address addressesProvider, uint256 _initialLockDeposit) internal { + initialLockDeposit = _initialLockDeposit; + vault = new ATokenVaultMerklRewardClaimer(underlying, referralCode, IPoolAddressesProvider(addressesProvider)); + + bytes memory data = abi.encodeWithSelector( + ATokenVault.initialize.selector, + OWNER, + fee, + SHARE_NAME, + SHARE_SYMBOL, + _initialLockDeposit + ); + + deal(underlying, address(this), _initialLockDeposit); + address proxyAddr = computeCreateAddress(address(this), vm.getNonce(address(this)) + 1); + + IERC20Upgradeable(underlying).safeApprove(address(proxyAddr), _initialLockDeposit); + + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(address(vault), PROXY_ADMIN, data); + + vault = ATokenVaultMerklRewardClaimer(address(proxy)); + } } diff --git a/test/ATokenVaultMerklRewardClaimer.t.sol b/test/ATokenVaultMerklRewardClaimer.t.sol new file mode 100644 index 0000000..a3dc82f --- /dev/null +++ b/test/ATokenVaultMerklRewardClaimer.t.sol @@ -0,0 +1,436 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.10; + +import "forge-std/Test.sol"; + +import {IAToken} from "@aave-v3-core/interfaces/IAToken.sol"; + +import {IATokenVaultMerklRewardClaimer} from "../src/interfaces/IATokenVaultMerklRewardClaimer.sol"; +import {IMerklDistributor} from "../src/dependencies/merkl/DistributorInterface.sol"; + +import {MockAavePoolAddressesProvider} from "./mocks/MockAavePoolAddressesProvider.sol"; +import {MockAavePool} from "./mocks/MockAavePool.sol"; +import {MockAToken} from "./mocks/MockAToken.sol"; +import {MockDAI} from "./mocks/MockDAI.sol"; +import {MockWETH} from "./mocks/MockWETH.sol"; +import {MockMerklDistributor} from "./mocks/MockMerklDistributor.sol"; +import {ATokenVaultBaseTest} from "./ATokenVaultBaseTest.t.sol"; +import "./utils/Constants.sol"; + +/** + * @title ATokenVaultMerklRewardClaimerTest + * @notice Unit test suite for claiming Merkl rewards from the ATokenVault + */ +contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { + MockMerklDistributor internal _merklDistributor; + MockAavePoolAddressesProvider internal __poolAddrProvider; + MockAavePool internal _pool; + MockAToken internal _aDai; + MockDAI internal _dai; + MockWETH internal _weth; + IATokenVaultMerklRewardClaimer internal _vaultMerklRewardClaimer; + + function setUp() public override { + // NOTE: Real DAI has non-standard permit. These tests assume tokens with standard permit + _dai = new MockDAI(); + _weth = new MockWETH(); + _aDai = new MockAToken(address(_dai)); + _pool = new MockAavePool(); + _pool.mockReserve(address(_dai), _aDai); + __poolAddrProvider = new MockAavePoolAddressesProvider(address(_pool)); + + vaultAssetAddress = address(_aDai); + + _pool.setReserveConfigMap(RESERVE_CONFIG_MAP_UNCAPPED_ACTIVE); + + _merklDistributor = new MockMerklDistributor(); + + // Sets the `vault`, but we will not use the vault deployment + _deployATokenVaultMerklRewardClaimer(address(_dai), address(__poolAddrProvider)); + _vaultMerklRewardClaimer = IATokenVaultMerklRewardClaimer(address(vault)); + } + + function testClaimMerklRewards() public { + _setMerklDistributor(); + + bytes32 proof = keccak256("proof1"); + (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(_dai), 1000, proof); + address[] memory rewardTokensToForward = new address[](0); + address destination = address(0); + + address[] memory users = new address[](rewardTokens.length); + for (uint256 i = 0; i < rewardTokens.length; i++) { + users[i] = address(_vaultMerklRewardClaimer); + } + + vm.expectCall( + address(_merklDistributor), + 0, + abi.encodeCall(MockMerklDistributor.claim, (users, rewardTokens, amounts, proofs)) + ); + vm.expectEmit(true, true, false, true, address(_vaultMerklRewardClaimer)); + emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(address(_merklDistributor), rewardTokens, amounts); + vm.prank(OWNER); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); + } + + function testClaimMerklRewardsAndForwardPartialTokenToDestination() public { + // Context: 2 tokens will be rewarded, but only one will be forwarded to the destination. + _setMerklDistributor(); + + uint256 amountOfATokenRewarded = 789 * 1e18; + uint256 amountOfDAIRewarded = 1234 * 1e18; + address[] memory mockRecipients = new address[](2); + mockRecipients[0] = address(_vaultMerklRewardClaimer); + mockRecipients[1] = address(_vaultMerklRewardClaimer); + address[] memory mockRewardTokens = new address[](2); + mockRewardTokens[0] = address(_aDai); + mockRewardTokens[1] = address(_dai); + uint256[] memory mockAmounts = new uint256[](2); + mockAmounts[0] = amountOfATokenRewarded; + mockAmounts[1] = amountOfDAIRewarded; + _merklDistributor.mockTokensToSend(mockRecipients, mockRewardTokens, mockAmounts); + + _aDai.mint(address(this), address(_merklDistributor), amountOfATokenRewarded, 0); + assertEq(_aDai.balanceOf(address(_merklDistributor)), amountOfATokenRewarded); + _dai.mint(address(_merklDistributor), amountOfDAIRewarded); + assertEq(_dai.balanceOf(address(_merklDistributor)), amountOfDAIRewarded); + + bytes32 proof = keccak256("proof1"); + bytes32[][] memory proofs = new bytes32[][](2); + proofs[0] = new bytes32[](1); + proofs[0][0] = proof; + proofs[1] = new bytes32[](1); + proofs[1][0] = proof; + // Forward the DAI only to the destination. Leave the aDAI in the vault. + address[] memory rewardTokensToForward = new address[](1); + rewardTokensToForward[0] = address(_dai); + address destination = makeAddr("destination"); + + // Check that the vault does not have any aDAI. + uint256 beforeBalanceOfAToken = _aDai.balanceOf(address(_vaultMerklRewardClaimer)); + uint256 beforeBalanceOfDAI = _dai.balanceOf(address(_vaultMerklRewardClaimer)); + + vm.expectEmit(true, true, false, true, address(_vaultMerklRewardClaimer)); + emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(address(_merklDistributor), mockRewardTokens, mockAmounts); + vm.expectEmit(true, true, false, true, address(_vaultMerklRewardClaimer)); + emit IATokenVaultMerklRewardClaimer.MerklRewardsTokenForwarded(address(_dai), destination, amountOfDAIRewarded); + vm.prank(OWNER); + _vaultMerklRewardClaimer.claimMerklRewards(mockRewardTokens, mockAmounts, proofs, rewardTokensToForward, destination); + + // Check that the vault did not hold onto the DAI. + assertEq(_dai.balanceOf(address(_vaultMerklRewardClaimer)), beforeBalanceOfDAI); + // Check that the destination received the DAI. + assertEq(_dai.balanceOf(destination), amountOfDAIRewarded); + // Check that the vault did hold onto the aDAI. The aDAI balance is initialized with a virtual amount. + assertEq(_aDai.balanceOf(address(_vaultMerklRewardClaimer)), beforeBalanceOfAToken + amountOfATokenRewarded); + } + + function testClaimMerklRewardsAndForwardFullTokenToDestination() public { + // Context: 2 tokens will be rewarded, and both will be forwarded to the destination. + _setMerklDistributor(); + + uint256 amountOfATokenRewarded = 789 * 1e18; + uint256 amountOfDAIRewarded = 1234 * 1e18; + address[] memory mockRecipients = new address[](2); + mockRecipients[0] = address(_vaultMerklRewardClaimer); + mockRecipients[1] = address(_vaultMerklRewardClaimer); + address[] memory mockRewardTokens = new address[](2); + mockRewardTokens[0] = address(_aDai); + mockRewardTokens[1] = address(_dai); + uint256[] memory mockAmounts = new uint256[](2); + mockAmounts[0] = amountOfATokenRewarded; + mockAmounts[1] = amountOfDAIRewarded; + _merklDistributor.mockTokensToSend(mockRecipients, mockRewardTokens, mockAmounts); + + _aDai.mint(address(this), address(_merklDistributor), amountOfATokenRewarded, 0); + assertEq(_aDai.balanceOf(address(_merklDistributor)), amountOfATokenRewarded); + _dai.mint(address(_merklDistributor), amountOfDAIRewarded); + assertEq(_dai.balanceOf(address(_merklDistributor)), amountOfDAIRewarded); + + bytes32 proof = keccak256("proof1"); + bytes32[][] memory proofs = new bytes32[][](2); + proofs[0] = new bytes32[](1); + proofs[0][0] = proof; + proofs[1] = new bytes32[](1); + proofs[1][0] = proof; + // Forward the DAI only to the destination. Leave the aDAI in the vault. + address[] memory rewardTokensToForward = new address[](2); + rewardTokensToForward[0] = address(_aDai); + rewardTokensToForward[1] = address(_dai); + address destination = makeAddr("destination"); + + // Check that the vault does not have any aDAI. + uint256 beforeBalanceOfAToken = _aDai.balanceOf(address(_vaultMerklRewardClaimer)); + uint256 beforeBalanceOfDAI = _dai.balanceOf(address(_vaultMerklRewardClaimer)); + + vm.expectEmit(true, true, false, true, address(_vaultMerklRewardClaimer)); + emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(address(_merklDistributor), mockRewardTokens, mockAmounts); + vm.expectEmit(true, true, false, true, address(_vaultMerklRewardClaimer)); + emit IATokenVaultMerklRewardClaimer.MerklRewardsTokenForwarded(address(_dai), destination, amountOfDAIRewarded); + vm.prank(OWNER); + _vaultMerklRewardClaimer.claimMerklRewards(mockRewardTokens, mockAmounts, proofs, rewardTokensToForward, destination); + + // Check that the destination received the DAI and aDAI. + assertEq(_dai.balanceOf(destination), amountOfDAIRewarded); + assertEq(_aDai.balanceOf(destination), amountOfATokenRewarded); + // Check that the vault did not hold onto the aDAI and DAI. + assertEq(_aDai.balanceOf(address(_vaultMerklRewardClaimer)), beforeBalanceOfAToken); + assertEq(_dai.balanceOf(address(_vaultMerklRewardClaimer)), beforeBalanceOfDAI); + } + + function testClaimMerklRewardsIfATokenIsRewarded() public { + _setMerklDistributor(); + + uint256 amountOfATokenRewarded = 789 * 1e18; + address[] memory mockRecipients = new address[](1); + mockRecipients[0] = address(_vaultMerklRewardClaimer); + address[] memory mockRewardTokens = new address[](1); + mockRewardTokens[0] = address(_aDai); + uint256[] memory mockAmounts = new uint256[](1); + mockAmounts[0] = amountOfATokenRewarded; + _merklDistributor.mockTokensToSend(mockRecipients, mockRewardTokens, mockAmounts); + + _aDai.mint(address(this), address(_merklDistributor), amountOfATokenRewarded, 0); + assertEq(_aDai.balanceOf(address(_merklDistributor)), amountOfATokenRewarded); + + address user1 = makeAddr("user1"); + address user2 = makeAddr("user2"); + uint256 amountOfDAIDepositedByUser = 100_000 * 1e18; + _depositFromUser(user1, amountOfDAIDepositedByUser); + _depositFromUser(user2, amountOfDAIDepositedByUser); + uint256 user1ShareBalanceBefore = vault.balanceOf(user1); + uint256 user2ShareBalanceBefore = vault.balanceOf(user2); + uint256 user1ATokenBalanceBefore = vault.previewRedeem(user1ShareBalanceBefore); + uint256 user2ATokenBalanceBefore = vault.previewRedeem(user2ShareBalanceBefore); + uint256 vaultATokenBalanceBefore = _aDai.balanceOf(address(vault)); + + // Avoid stack too deep error + _claimMerklRewards(); + + uint256 vaultATokenBalanceAfter = _aDai.balanceOf(address(vault)); + assertEq(vaultATokenBalanceAfter, vaultATokenBalanceBefore + amountOfATokenRewarded); + + uint256 user1ShareBalanceAfter = vault.balanceOf(user1); + assertEq(user1ShareBalanceAfter, user1ShareBalanceBefore); + uint256 user2ShareBalanceAfter = vault.balanceOf(user2); + assertEq(user2ShareBalanceAfter, user2ShareBalanceBefore); + uint256 user1ATokenBalanceAfter = vault.previewRedeem(user1ShareBalanceAfter); + assertGt(user1ATokenBalanceAfter, user1ATokenBalanceBefore); + uint256 user2ATokenBalanceAfter = vault.previewRedeem(user2ShareBalanceAfter); + assertGt(user2ATokenBalanceAfter, user2ATokenBalanceBefore); + + // Check that emergency rescue is not allowed + vm.expectRevert(bytes("CANNOT_RESCUE_ATOKEN")); + vm.prank(OWNER); + vault.emergencyRescue(address(_aDai), address(this), vaultATokenBalanceAfter); + } + + function testClaimMerklRewardsIfUnderlyingTokenAndRescue() public { + _setMerklDistributor(); + + uint256 amountOfUnderlyingTokenRewarded = 789 * 1e18; + address[] memory mockRecipients = new address[](1); + mockRecipients[0] = address(_vaultMerklRewardClaimer); + address[] memory mockRewardTokens = new address[](1); + mockRewardTokens[0] = address(_dai); + uint256[] memory mockAmounts = new uint256[](1); + mockAmounts[0] = amountOfUnderlyingTokenRewarded; + _merklDistributor.mockTokensToSend(mockRecipients, mockRewardTokens, mockAmounts); + _dai.mint(address(_merklDistributor), amountOfUnderlyingTokenRewarded); + + uint256 vaultBalanceOfUnderlyingTokenBefore = _dai.balanceOf(address(vault)); + uint256 vaultBalanceOfATokenBefore = _aDai.balanceOf(address(vault)); + + _claimMerklRewards(); + + uint256 vaultBalanceOfUnderlyingTokenAfter = _dai.balanceOf(address(vault)); + assertEq(vaultBalanceOfUnderlyingTokenAfter, vaultBalanceOfUnderlyingTokenBefore + amountOfUnderlyingTokenRewarded); + uint256 vaultBalanceOfATokenAfter = _aDai.balanceOf(address(vault)); + assertEq(vaultBalanceOfATokenAfter, vaultBalanceOfATokenBefore); + + // Rescue the underlying + vm.prank(OWNER); + vault.emergencyRescue(address(_dai), address(this), amountOfUnderlyingTokenRewarded); + assertEq(_dai.balanceOf(address(this)), amountOfUnderlyingTokenRewarded); + assertEq(_dai.balanceOf(address(vault)), 0); + } + + function testClaimMerklRewardsRevertsIfNativeTokenIsRewarded() public { + _setMerklDistributor(); + + address[] memory mockRecipients = new address[](1); + mockRecipients[0] = address(_vaultMerklRewardClaimer); + uint256 amountOfNativeToken = 789; + address[] memory mockRewardTokens = new address[](1); + mockRewardTokens[0] = address(0); + uint256[] memory mockAmounts = new uint256[](1); + mockAmounts[0] = amountOfNativeToken; + _merklDistributor.mockTokensToSend(mockRecipients, mockRewardTokens, mockAmounts); + vm.deal(address(_merklDistributor), amountOfNativeToken); + + bytes32 proof = keccak256("proof1"); + (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(_weth), 1000, proof); + address[] memory rewardTokensToForward = new address[](0); + address destination = address(0); + vm.expectRevert(); + vm.prank(OWNER); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); + } + + function testClaimMerklRewardsRevertsIfMerklDistributorNotSet() public { + address[] memory rewardTokens = new address[](0); + uint256[] memory amounts = new uint256[](0); + bytes32[][] memory proofs = new bytes32[][](0); + address[] memory rewardTokensToForward = new address[](0); + address destination = address(0); + vm.prank(OWNER); + vm.expectRevert(bytes("MERKL_DISTRIBUTOR_NOT_SET")); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); + } + + function testClaimMerklRewardsRevertsIfArrayLengthMismatchFromTokens() public { + _setMerklDistributor(); + bytes32 proof = keccak256("proof1"); + address[] memory rewardTokens = new address[](2); + rewardTokens[0] = address(_dai); + rewardTokens[1] = address(_dai); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1000; + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = new bytes32[](1); + proofs[0][0] = proof; + address[] memory rewardTokensToForward = new address[](0); + address destination = address(0); + vm.expectRevert(bytes("ARRAY_LENGTH_MISMATCH")); + vm.prank(OWNER); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); + } + + function testClaimMerklRewardsRevertsIfArrayLengthMismatchFromAmounts() public { + _setMerklDistributor(); + bytes32 proof = keccak256("proof1"); + address[] memory rewardTokens = new address[](1); + rewardTokens[0] = address(_dai); + uint256[] memory amounts = new uint256[](2); + amounts[0] = 1000; + amounts[1] = 1000; + bytes32[][] memory proofs = new bytes32[][](1); + proofs[0] = new bytes32[](1); + proofs[0][0] = proof; + address[] memory rewardTokensToForward = new address[](0); + address destination = address(0); + vm.expectRevert(bytes("ARRAY_LENGTH_MISMATCH")); + vm.prank(OWNER); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); + } + + function testClaimMerklRewardsRevertsIfArrayLengthMismatchFromProofs() public { + _setMerklDistributor(); + bytes32 proof = keccak256("proof1"); + address[] memory rewardTokens = new address[](1); + rewardTokens[0] = address(_dai); + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1000; + bytes32[][] memory proofs = new bytes32[][](2); + proofs[0] = new bytes32[](1); + proofs[0][0] = proof; + proofs[1] = new bytes32[](1); + proofs[1][0] = proof; + address[] memory rewardTokensToForward = new address[](0); + address destination = address(0); + vm.expectRevert(bytes("ARRAY_LENGTH_MISMATCH")); + vm.prank(OWNER); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); + } + + function testClaimMerklRewardsRevertsIfNotOwner() public { + address[] memory rewardTokens = new address[](0); + uint256[] memory amounts = new uint256[](0); + bytes32[][] memory proofs = new bytes32[][](0); + address[] memory rewardTokensToForward = new address[](0); + address destination = address(0); + vm.expectRevert(bytes("Ownable: caller is not the owner")); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); + } + + function testClaimMerklRewardsRevertsIfMerklDistributorReverts() public { + _setMerklDistributor(); + + bytes32 proof = keccak256("proof1"); + (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(_dai), 1000, proof); + address[] memory rewardTokensToForward = new address[](0); + address destination = address(0); + string memory revertReason = "revert because of insufficient balance"; + _merklDistributor.setShouldRevert(true, revertReason); + vm.expectRevert(bytes(revertReason)); + vm.prank(OWNER); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); + } + + function testSetMerklDistributor() public { + vm.expectEmit(true, true, false, true, address(_vaultMerklRewardClaimer)); + emit IATokenVaultMerklRewardClaimer.MerklDistributorUpdated(address(0), address(_merklDistributor)); + vm.prank(OWNER); + _vaultMerklRewardClaimer.setMerklDistributor(address(_merklDistributor)); + assertEq(_vaultMerklRewardClaimer.getMerklDistributor(), address(_merklDistributor)); + + address newMerklDistributor = makeAddr("newMerklDistributor"); + vm.expectEmit(true, true, false, true, address(_vaultMerklRewardClaimer)); + emit IATokenVaultMerklRewardClaimer.MerklDistributorUpdated(address(_merklDistributor), newMerklDistributor); + vm.prank(OWNER); + _vaultMerklRewardClaimer.setMerklDistributor(newMerklDistributor); + assertEq(_vaultMerklRewardClaimer.getMerklDistributor(), newMerklDistributor); + } + + function testSetMerklDistributorAllowsZeroAddress() public { + vm.prank(OWNER); + _vaultMerklRewardClaimer.setMerklDistributor(address(_merklDistributor)); + assertEq(_vaultMerklRewardClaimer.getMerklDistributor(), address(_merklDistributor)); + vm.prank(OWNER); + _vaultMerklRewardClaimer.setMerklDistributor(address(0)); + assertEq(_vaultMerklRewardClaimer.getMerklDistributor(), address(0)); + } + + function testSetMerklDistributorRevertsIfNotOwner() public { + vm.expectRevert(bytes("Ownable: caller is not the owner")); + _vaultMerklRewardClaimer.setMerklDistributor(address(_merklDistributor)); + } + + function _setMerklDistributor() internal { + vm.prank(OWNER); + _vaultMerklRewardClaimer.setMerklDistributor(address(_merklDistributor)); + } + + function _buildMerklRewardsClaimData( + address token, + uint256 amount, + bytes32 proof + ) internal pure returns (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) { + rewardTokens = new address[](1); + rewardTokens[0] = token; + amounts = new uint256[](1); + amounts[0] = amount; + proofs = new bytes32[][](1); + proofs[0] = new bytes32[](1); + proofs[0][0] = proof; + } + + function _claimMerklRewards() internal { + bytes32 proof = keccak256("proof1"); + (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(_dai), 1000, proof); + address[] memory rewardTokensToForward = new address[](0); + address destination = address(0); + vm.prank(OWNER); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); + } + + function _depositFromUser(address user, uint256 amount) internal { + _dai.mint(user, amount); + vm.startPrank(user); + _dai.approve(address(vault), amount); + vault.deposit(amount, user); + vm.stopPrank(); + } +} diff --git a/test/ATokenVaultMerklRewardClaimerFork.t.sol b/test/ATokenVaultMerklRewardClaimerFork.t.sol new file mode 100644 index 0000000..337c710 --- /dev/null +++ b/test/ATokenVaultMerklRewardClaimerFork.t.sol @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.10; + +import "forge-std/Test.sol"; +import {stdStorage, StdStorage} from "forge-std/Test.sol"; + +import {IAToken} from "@aave-v3-core/interfaces/IAToken.sol"; +import {IERC20} from "@openzeppelin/interfaces/IERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/interfaces/IERC20Metadata.sol"; + +import {ATokenVaultBaseTest} from "./ATokenVaultBaseTest.t.sol"; + +import {IATokenVaultMerklRewardClaimer} from "../src/interfaces/IATokenVaultMerklRewardClaimer.sol"; +import {IATokenVault} from "../src/interfaces/IATokenVault.sol"; + +/** + * @title ATokenVaultMerklRewardClaimerForkTest + * @notice Test suite for claiming Merkl rewards from the ATokenVault on a forked Ethereum mainnet + * @dev Etches the ATokenVault onto an address that has claimable rewards as of the forked block + * @dev foundry.toml must use evm_version = 'cancun' to run this test + */ +contract ATokenVaultMerklRewardClaimerForkTest is ATokenVaultBaseTest { + using stdStorage for StdStorage; + uint256 ethereumFork; + // The block before rewards claim in tx: https://etherscan.io/tx/0x42ef6b499d1b6e96a4250f2d5a005b60173386e2ac3a3e424aa407db3da802ea + uint256 ETHEREUM_FORK_BLOCK = 23921479; // Dec 1st 2025 + + address constant MERKL_DISTRIBUTOR = 0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae; + address constant ADDRESS_WITH_CLAIMABLE_REWARDS = 0x424629A0D581B6076322A952FB43b78624dB8A15; + address constant ETHEREUM_HORIZON_POOL_ADDRESSES_PROVIDER = 0x5D39E06b825C1F2B80bf2756a73e28eFAA128ba0; + address constant ETHEREUM_RLUSD = 0x8292Bb45bf1Ee4d140127049757C2E0fF06317eD; + address constant A_HOR_RWA_RLUSD = 0xE3190143Eb552456F88464662f0c0C4aC67A77eB; + address constant WRAPPED_A_HOR_RWA_RLUSD = 0x503D751B13a71D8e69Db021DF110bfa7aE1dA889; + IAToken constant aHorRwaRLUSD = IAToken(A_HOR_RWA_RLUSD); + + function setUp() public override { + ethereumFork = vm.createFork(vm.envString("ETHEREUM_RPC_URL")); + vm.selectFork(ethereumFork); + vm.rollFork(ETHEREUM_FORK_BLOCK); + + vaultAssetAddress = address(aHorRwaRLUSD); + + // Sets the `vault`, but we will not use the vault deployment + _deployATokenVaultMerklRewardClaimer(ETHEREUM_RLUSD, ETHEREUM_HORIZON_POOL_ADDRESSES_PROVIDER); + } + + function testOwnerCanClaimMerklRewardsWithoutForwarding() public { + _setMerklDistributor(); + // Set the code for an address that has claimable rewards as of the fork block + // We will use this in place of the vault deployment + _etchVault(ADDRESS_WITH_CLAIMABLE_REWARDS); + + uint256 beforeBalanceOfAHorRwaRLUSD = IERC20(A_HOR_RWA_RLUSD).balanceOf(ADDRESS_WITH_CLAIMABLE_REWARDS); + uint256 beforeBalanceOfWrappedAHorRwaRLUSD = IERC20(WRAPPED_A_HOR_RWA_RLUSD).balanceOf(ADDRESS_WITH_CLAIMABLE_REWARDS); + + (address[] memory tokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(); + address[] memory rewardTokensToForward = new address[](0); + + vm.expectEmit(true, true, false, true, ADDRESS_WITH_CLAIMABLE_REWARDS); + emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(MERKL_DISTRIBUTOR, tokens, amounts); + vm.prank(OWNER); + IATokenVaultMerklRewardClaimer(ADDRESS_WITH_CLAIMABLE_REWARDS).claimMerklRewards(tokens, amounts, proofs, rewardTokensToForward, address(0)); + + // Check that the vault received the A tokens (the tokens received from claiming Merkl rewards). + assertGt(IERC20(A_HOR_RWA_RLUSD).balanceOf(ADDRESS_WITH_CLAIMABLE_REWARDS), beforeBalanceOfAHorRwaRLUSD); + // Check that total assets is the same as the balance of the A tokens. + assertEq(IERC20(A_HOR_RWA_RLUSD).balanceOf(ADDRESS_WITH_CLAIMABLE_REWARDS), IATokenVault(ADDRESS_WITH_CLAIMABLE_REWARDS).totalAssets()); + // Check that the vault's balance of the wrapped aToken is unchcanged. + assertEq(IERC20(WRAPPED_A_HOR_RWA_RLUSD).balanceOf(ADDRESS_WITH_CLAIMABLE_REWARDS), beforeBalanceOfWrappedAHorRwaRLUSD); + } + + function testOwnerCanClaimMerklRewardsAndForwardToDestination() public { + uint256 expectedAmountOfAHorRwaRLUSDReceived = 3772222577889726879658; + _setMerklDistributor(); + // Set the code for an address that has claimable rewards as of the fork block + // We will use this in place of the vault deployment + _etchVault(ADDRESS_WITH_CLAIMABLE_REWARDS); + address destination = makeAddr("destination"); + + uint256 beforeBalanceOfAHorRwaRLUSD = IERC20(A_HOR_RWA_RLUSD).balanceOf(ADDRESS_WITH_CLAIMABLE_REWARDS); + uint256 beforeBalanceOfWrappedAHorRwaRLUSD = IERC20(WRAPPED_A_HOR_RWA_RLUSD).balanceOf(ADDRESS_WITH_CLAIMABLE_REWARDS); + + (address[] memory tokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(); + address[] memory rewardTokensToForward = new address[](1); + rewardTokensToForward[0] = address(A_HOR_RWA_RLUSD); + + vm.expectEmit(true, true, false, true, ADDRESS_WITH_CLAIMABLE_REWARDS); + emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(MERKL_DISTRIBUTOR, tokens, amounts); + vm.expectEmit(true, true, false, true, ADDRESS_WITH_CLAIMABLE_REWARDS); + emit IATokenVaultMerklRewardClaimer.MerklRewardsTokenForwarded(address(A_HOR_RWA_RLUSD), destination, expectedAmountOfAHorRwaRLUSDReceived); + vm.prank(OWNER); + IATokenVaultMerklRewardClaimer(ADDRESS_WITH_CLAIMABLE_REWARDS).claimMerklRewards(tokens, amounts, proofs, rewardTokensToForward, destination); + + // Check that the vault did not hold onto the A tokens. + assertEq(IERC20(A_HOR_RWA_RLUSD).balanceOf(ADDRESS_WITH_CLAIMABLE_REWARDS), beforeBalanceOfAHorRwaRLUSD); + // Check that total assets is the same as the balance of the A tokens. + assertEq(IERC20(A_HOR_RWA_RLUSD).balanceOf(ADDRESS_WITH_CLAIMABLE_REWARDS), IATokenVault(ADDRESS_WITH_CLAIMABLE_REWARDS).totalAssets()); + // Check that the vault's balance of the wrapped aToken is unchcanged. + assertEq(IERC20(WRAPPED_A_HOR_RWA_RLUSD).balanceOf(ADDRESS_WITH_CLAIMABLE_REWARDS), beforeBalanceOfWrappedAHorRwaRLUSD); + // Check that the destination received the A tokens. + assertEq(IERC20(A_HOR_RWA_RLUSD).balanceOf(destination), expectedAmountOfAHorRwaRLUSDReceived); + } + + function _buildMerklRewardsClaimData() internal pure returns (address[] memory tokens, uint256[] memory amounts, bytes32[][] memory proofs) { + tokens = new address[](1); + tokens[0] = address(WRAPPED_A_HOR_RWA_RLUSD); + + amounts = new uint256[](1); + amounts[0] = 108475300663546315531064; + + proofs = new bytes32[][](1); + proofs[0] = new bytes32[](18); + proofs[0][0] = 0xcd60c655efb4b907fcd241863868d14b469487021d66d1edfdbda9472f9a64fa; + proofs[0][1] = 0xb8fb6ed03a4fe66e40d0393936caaf89288f0f0d45a54b2d021e6436e08d5cab; + proofs[0][2] = 0x51da9788480e72478a3983b1fcc6417d6e2fb23f17d82402980bf7a662951ebc; + proofs[0][3] = 0xbd1e1f1f6b3bff2c96cdaac05eef648a12d7b26aff0cb6bf2e01df739326d221; + proofs[0][4] = 0xc3666e909b4378eb3190a55407b3992791f45947ccfb78a450618fd0d2d186ef; + proofs[0][5] = 0xeae511fe93c29449d7dc421a00989e9b90193b1ea95a6727edfd0ac23c85b1a8; + proofs[0][6] = 0x7cb3e28ed93250acb734cf34cf07ae533800fc0161e936ee29c20f36c66e7d4a; + proofs[0][7] = 0x467aa2d6889230db0b841374136b586ded4697016c9cb5b9259677357c9de435; + proofs[0][8] = 0xb1b5b41688ba77f9426265f60c5f9a922062156ab08bdde674dceebacc1c92b3; + proofs[0][9] = 0x51dda19784b96153bcda1086489d5d6c5f17566202c92a0d911cbd2aa7a3ff7a; + proofs[0][10] = 0x4606c269974519cf0d4fe1907baab3ca27c5eef64560408e4137eaf62d273c08; + proofs[0][11] = 0xcc7bdca69b12e1f75d7b26045ba5e0c108de8d85bcee444ac05a02b940a20ea7; + proofs[0][12] = 0x67cfe6ef02168544a983543b06f1823d97e78d17e67bd6833f7fc6b8e3a2cc77; + proofs[0][13] = 0x3f2a8d6cb9b7784fe51ac5719c41e1a973856a7f89f3847b2ec5e259b6977b90; + proofs[0][14] = 0xed9131f643d7100fcdfcebbf4abb97e7bcf31ee08f7ab8fb3230231f6f3f7533; + proofs[0][15] = 0x4dd1263f903416095b1556a5b9473082ee23a3eda267d6f98dbdcb200ba1d648; + proofs[0][16] = 0x5df7d74a8a29e2552ddecdd29e57b8e1daedbc786f9f9ca6d308506f14b13995; + proofs[0][17] = 0xa8568c4dc81b1ee1667791a9567914d57f698ab8f6444fd1bd9fa88b158598c6; + } + + function _setMerklDistributor() internal { + vm.prank(OWNER); + IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(MERKL_DISTRIBUTOR); + } + + function _etchVault(address target) internal { + // 1. Fix the Proxy: Copy the implementation address from 'vault' to the etched address + // EIP-1967 Implementation slot: bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) + bytes32 implSlot = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + bytes32 implementation = vm.load(address(vault), implSlot); + + vm.etch(target, address(vault).code); + vm.store(target, implSlot, implementation); + + // 2. Fix State: Copy Owner and MerklDistributor + // Based on forge inspect storage layout: + // _owner is at slot 151 + uint256 ownerSlot = 151; + vm.store(target, bytes32(ownerSlot), vm.load(address(vault), bytes32(ownerSlot))); + // _s starts at slot 254 + // _s.merklDistributor is at slot 256 (254: balances/fees, 255: gap/fee, 256: merkl) + uint256 merklSlot = 256; + vm.store(target, bytes32(merklSlot), vm.load(address(vault), bytes32(merklSlot))); + } +} diff --git a/test/mocks/MockMerklDistributor.sol b/test/mocks/MockMerklDistributor.sol new file mode 100644 index 0000000..e2dd3aa --- /dev/null +++ b/test/mocks/MockMerklDistributor.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.10; + +import {ERC20} from "@openzeppelin/token/ERC20/ERC20.sol"; + +import {IMerklDistributor} from "../../src/dependencies/merkl/DistributorInterface.sol"; + +contract MockMerklDistributor is IMerklDistributor { + address[] internal _recipients; + address[] internal _tokens; + uint256[] internal _amounts; + + bool internal _shouldRevert = false; + string internal _revertReason = ""; + + function claim( + address[] calldata users, + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs + ) external { + if (_shouldRevert) { + revert(_revertReason); + } + require(users.length == tokens.length && users.length == amounts.length && users.length == proofs.length, "ARRAY_LENGTH_MISMATCH"); + for (uint256 i = 0; i < _recipients.length; i++) { + if (_tokens[i] == address(0)) { + payable(_recipients[i]).transfer(_amounts[i]); + } else { + ERC20(_tokens[i]).transfer(_recipients[i], _amounts[i]); + } + } + } + + function mockTokensToSend( + address[] memory recipients, + address[] memory tokens, + uint256[] memory amounts + ) external { + _recipients = recipients; + _tokens = tokens; + _amounts = amounts; + } + + function setShouldRevert(bool shouldRevert, string memory revertReason) external { + _shouldRevert = shouldRevert; + _revertReason = revertReason; + } +} diff --git a/test/mocks/MockWETH.sol b/test/mocks/MockWETH.sol new file mode 100644 index 0000000..cb9a34a --- /dev/null +++ b/test/mocks/MockWETH.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.10; + +import {ERC20} from "@openzeppelin/token/ERC20/ERC20.sol"; + +contract MockWETH is ERC20 { + constructor() ERC20("Mock WETH", "mWETH") {} +}