From 34ae3cb30d3643f5e48acc404a4bd37756fc31a2 Mon Sep 17 00:00:00 2001 From: Alpay Aldemir Date: Mon, 1 Dec 2025 19:01:06 -0800 Subject: [PATCH 01/12] feat: add new contract to support merkl reward claiming - inherits from ATokenVault - updates storage struct to store merkl distributor contract (consumes the first of 50 gap slots) - expose functions to set/get distributor (onlyOwner) - expose function to claim rewards from merkl distributor (onlyOwner) - add test that forks Ethereum mainnet to claim rewards --- .github/workflows/foundry-gas-diff.yml | 1 + .github/workflows/tests.yml | 1 + README.md | 3 +- foundry.toml | 1 + src/ATokenVaultMerklRewardClaimer.sol | 75 +++++++ src/ATokenVaultStorage.sol | 4 +- .../IATokenVaultMerklRewardClaimer.sol | 54 +++++ test/ATokenVaultBaseTest.t.sol | 28 +++ test/ATokenVaultMerklRewardClaimer.t.sol | 201 ++++++++++++++++++ 9 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 src/ATokenVaultMerklRewardClaimer.sol create mode 100644 src/interfaces/IATokenVaultMerklRewardClaimer.sol create mode 100644 test/ATokenVaultMerklRewardClaimer.t.sol 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..347dcb1 --- /dev/null +++ b/src/ATokenVaultMerklRewardClaimer.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: UNLICENSED +// All Rights Reserved © AaveCo + +pragma solidity ^0.8.10; + +import {IPoolAddressesProvider} from "@aave-v3-core/interfaces/IPoolAddressesProvider.sol"; +import {IERC20} from "@openzeppelin/interfaces/IERC20.sol"; + +import {ATokenVault} from "./ATokenVault.sol"; +import {IATokenVaultMerklRewardClaimer} from "./interfaces/IATokenVaultMerklRewardClaimer.sol"; + +interface IMerklDistributor { + function claim( + address[] calldata users, + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs + ) external; +} + +/** + * @title ATokenVaultMerklRewardClaimer + * @author Aave Protocol + * @notice A contract that allows the owner to claim Merkl rewards for the ATokenVault + */ +contract ATokenVaultMerklRewardClaimer is ATokenVault, IATokenVaultMerklRewardClaimer { + /** + * @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 getMerklDistributor() external view override returns (address) { + return _s.merklDistributor; + } + + /// @inheritdoc IATokenVaultMerklRewardClaimer + function setMerklDistributor(address merklDistributor) external override onlyOwner { + require(merklDistributor != address(0), "ZERO_ADDRESS_NOT_VALID"); + address currentMerklDistributor = _s.merklDistributor; + _s.merklDistributor = merklDistributor; + emit MerklDistributorUpdated(currentMerklDistributor, merklDistributor); + } + + /// @inheritdoc IATokenVaultMerklRewardClaimer + function claimMerklRewards(address[] calldata rewardTokens, uint256[] calldata amounts, bytes32[][] calldata proofs) + public + override + onlyOwner + { + require(_s.merklDistributor != address(0), "MERKL_DISTRIBUTOR_NOT_SET"); + + address[] memory users = new address[](rewardTokens.length); + for (uint256 i = 0; i < rewardTokens.length; i++) { + // users represent depositors into Aave which is this contract + users[i] = address(this); + } + + // The claim function does not return a list of tokens and amounts actually received. + // It is possible for rewards to be in aTokens, the underlying asset or some other token. + // If necessary the owner can use IATokenVault.emergencyRescue(...) to rescue the non-aToken rewards. + IMerklDistributor(_s.merklDistributor).claim(users, rewardTokens, amounts, proofs); + // Do not attempt to accrue yield as it can be delegated to subsequent calls to this contract. + // We do not need to accrue before claiming because new shares are not granted anywhere (rewards are socialized across all current share holders). + // We do not need to accrue after claiming because any subsequent call will trigger an accrual before state updates + // and preview functions read the balance of aTokens on the vault at runtime. + + emit MerklRewardsClaimed(rewardTokens, amounts); + } +} diff --git a/src/ATokenVaultStorage.sol b/src/ATokenVaultStorage.sol index 350c4f7..255c27e 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 called to claim Merkl rewards + 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/interfaces/IATokenVaultMerklRewardClaimer.sol b/src/interfaces/IATokenVaultMerklRewardClaimer.sol new file mode 100644 index 0000000..a7983da --- /dev/null +++ b/src/interfaces/IATokenVaultMerklRewardClaimer.sol @@ -0,0 +1,54 @@ +// 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 by the vault contract after rewards are claimed + * @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[] tokens, uint256[] amounts); + + /** + * @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 Getter for the contract address called to claim Merkl rewards + * @return Address of the Merkl distributor contract + */ + function getMerklDistributor() external view returns (address); + + /** + * @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 Claims Merkl rewards earned by deposits from this contract through the Merkl distributor contract + * @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 rewardTokens 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 + */ + function claimMerklRewards(address[] calldata rewardTokens, uint256[] calldata amounts, bytes32[][] calldata proofs) + external; +} 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..2836766 --- /dev/null +++ b/test/ATokenVaultMerklRewardClaimer.t.sol @@ -0,0 +1,201 @@ +// 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 ATokenVaultMerklRewardClaimerTest + * @notice Test suite for claiming Merkl rewards from the ATokenVault + * @dev Forks Ethereum mainnet to etch 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 ATokenVaultMerklRewardClaimerTest 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); + } + + /*////////////////////////////////////////////////////////////// + ETHEREUM FORK TESTS + //////////////////////////////////////////////////////////////*/ + + function testEthereumForkWorks() public { + assertEq(vm.activeFork(), ethereumFork); + } + + function testEthereumForkAtExpectedBlock() public { + assertEq(block.number, ETHEREUM_FORK_BLOCK); + } + + function testEthereumForkBalanceOfAddressWithClaimableRewards() public { + assertEq(ADDRESS_WITH_CLAIMABLE_REWARDS.balance, 154288817306978598); + } + + /*////////////////////////////////////////////////////////////// + MERKL REWARDS CLAIM TESTS + //////////////////////////////////////////////////////////////*/ + + function testOwnerCanClaimMerklRewards() 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(); + + vm.prank(OWNER); + IATokenVaultMerklRewardClaimer(ADDRESS_WITH_CLAIMABLE_REWARDS).claimMerklRewards(tokens, amounts, proofs); + + // 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 testClaimMerklRewardsEmitsEvent() 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); + + (address[] memory tokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(); + + vm.prank(OWNER); + vm.expectEmit(true, false, false, true, ADDRESS_WITH_CLAIMABLE_REWARDS); + emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(tokens, amounts); + IATokenVaultMerklRewardClaimer(ADDRESS_WITH_CLAIMABLE_REWARDS).claimMerklRewards(tokens, amounts, proofs); + } + + function testClaimMerklRewardsRevertsIfMerklDistributorNotSet() public { + (address[] memory tokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(); + + vm.prank(OWNER); + vm.expectRevert(bytes("MERKL_DISTRIBUTOR_NOT_SET")); + IATokenVaultMerklRewardClaimer(address(vault)).claimMerklRewards(tokens, amounts, proofs); + } + + function testClaimMerklRewardsRevertsIfNotOwner() public { + (address[] memory tokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(); + + vm.expectRevert(bytes("Ownable: caller is not the owner")); + IATokenVaultMerklRewardClaimer(address(vault)).claimMerklRewards(tokens, amounts, proofs); + } + + function testSetMerklDistributor() public { + vm.prank(OWNER); + IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(MERKL_DISTRIBUTOR); + assertEq(IATokenVaultMerklRewardClaimer(address(vault)).getMerklDistributor(), MERKL_DISTRIBUTOR); + + address newMerklDistributor = makeAddr("newMerklDistributor"); + vm.prank(OWNER); + IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(newMerklDistributor); + assertEq(IATokenVaultMerklRewardClaimer(address(vault)).getMerklDistributor(), newMerklDistributor); + } + + function testSetMerklDistributorEmitsEvent() public { + vm.prank(OWNER); + vm.expectEmit(true, false, false, true, address(vault)); + emit IATokenVaultMerklRewardClaimer.MerklDistributorUpdated(address(0), MERKL_DISTRIBUTOR); + IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(MERKL_DISTRIBUTOR); + } + + function testSetMerklDistributorRevertsIfZeroAddress() public { + vm.prank(OWNER); + vm.expectRevert(bytes("ZERO_ADDRESS_NOT_VALID")); + IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(address(0)); + } + + function testSetMerklDistributorRevertsIfNotOwner() public { + vm.expectRevert(bytes("Ownable: caller is not the owner")); + IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(MERKL_DISTRIBUTOR); + } + + 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))); + } +} From a2bdcc369af9064aa2ad239359132d5f9e11055f Mon Sep 17 00:00:00 2001 From: Alpay Aldemir Date: Thu, 4 Dec 2025 17:59:10 -0800 Subject: [PATCH 02/12] enh: address PR comments - make comments more concise - clarify in comment that native asset can not be rescued - move custom interface into a /dependencies dir - order read/write functions on ATokenVaultMerklRewardsClaim contract and interface --- src/ATokenVaultMerklRewardClaimer.sol | 40 ++++++++----------- src/ATokenVaultStorage.sol | 2 +- .../merkl/DistributorInterface.sol | 14 +++++++ .../IATokenVaultMerklRewardClaimer.sol | 28 ++++++------- 4 files changed, 45 insertions(+), 39 deletions(-) create mode 100644 src/dependencies/merkl/DistributorInterface.sol diff --git a/src/ATokenVaultMerklRewardClaimer.sol b/src/ATokenVaultMerklRewardClaimer.sol index 347dcb1..fdbabfd 100644 --- a/src/ATokenVaultMerklRewardClaimer.sol +++ b/src/ATokenVaultMerklRewardClaimer.sol @@ -8,20 +8,12 @@ import {IERC20} from "@openzeppelin/interfaces/IERC20.sol"; import {ATokenVault} from "./ATokenVault.sol"; import {IATokenVaultMerklRewardClaimer} from "./interfaces/IATokenVaultMerklRewardClaimer.sol"; - -interface IMerklDistributor { - function claim( - address[] calldata users, - address[] calldata tokens, - uint256[] calldata amounts, - bytes32[][] calldata proofs - ) external; -} +import {IMerklDistributor} from "./dependencies/merkl/DistributorInterface.sol"; /** * @title ATokenVaultMerklRewardClaimer * @author Aave Protocol - * @notice A contract that allows the owner to claim Merkl rewards for the ATokenVault + * @notice ATokenVault, with Merkl reward claiming capability */ contract ATokenVaultMerklRewardClaimer is ATokenVault, IATokenVaultMerklRewardClaimer { /** @@ -34,19 +26,6 @@ contract ATokenVaultMerklRewardClaimer is ATokenVault, IATokenVaultMerklRewardCl ATokenVault(underlying, referralCode, poolAddressesProvider) {} - /// @inheritdoc IATokenVaultMerklRewardClaimer - function getMerklDistributor() external view override returns (address) { - return _s.merklDistributor; - } - - /// @inheritdoc IATokenVaultMerklRewardClaimer - function setMerklDistributor(address merklDistributor) external override onlyOwner { - require(merklDistributor != address(0), "ZERO_ADDRESS_NOT_VALID"); - address currentMerklDistributor = _s.merklDistributor; - _s.merklDistributor = merklDistributor; - emit MerklDistributorUpdated(currentMerklDistributor, merklDistributor); - } - /// @inheritdoc IATokenVaultMerklRewardClaimer function claimMerklRewards(address[] calldata rewardTokens, uint256[] calldata amounts, bytes32[][] calldata proofs) public @@ -63,7 +42,7 @@ contract ATokenVaultMerklRewardClaimer is ATokenVault, IATokenVaultMerklRewardCl // The claim function does not return a list of tokens and amounts actually received. // It is possible for rewards to be in aTokens, the underlying asset or some other token. - // If necessary the owner can use IATokenVault.emergencyRescue(...) to rescue the non-aToken rewards. + // If necessary the owner can use IATokenVault.emergencyRescue(...) to rescue the non-aToken rewards and non-native rewards. IMerklDistributor(_s.merklDistributor).claim(users, rewardTokens, amounts, proofs); // Do not attempt to accrue yield as it can be delegated to subsequent calls to this contract. // We do not need to accrue before claiming because new shares are not granted anywhere (rewards are socialized across all current share holders). @@ -72,4 +51,17 @@ contract ATokenVaultMerklRewardClaimer is ATokenVault, IATokenVaultMerklRewardCl emit MerklRewardsClaimed(rewardTokens, amounts); } + + /// @inheritdoc IATokenVaultMerklRewardClaimer + function setMerklDistributor(address merklDistributor) external override onlyOwner { + require(merklDistributor != address(0), "ZERO_ADDRESS_NOT_VALID"); + 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 255c27e..fbca1c6 100644 --- a/src/ATokenVaultStorage.sol +++ b/src/ATokenVaultStorage.sol @@ -20,7 +20,7 @@ abstract contract ATokenVaultStorage { uint40 __deprecated_gap; // as a fraction of 1e18 uint64 fee; - // Merkl distributor contract address called to claim Merkl rewards + // Merkl distributor contract address merklDistributor; // Reserved storage space to allow for layout changes in the future uint256[49] __gap; diff --git a/src/dependencies/merkl/DistributorInterface.sol b/src/dependencies/merkl/DistributorInterface.sol new file mode 100644 index 0000000..65ae049 --- /dev/null +++ b/src/dependencies/merkl/DistributorInterface.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +// Based on implementation from https://github.com/AngleProtocol/merkl-contracts/blob/b7bd0e65a3f366e4041bc83494cbd981f8852b16/contracts/Distributor.sol#L202 +// All Rights Reserved © AaveCo + +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 index a7983da..58abcdf 100644 --- a/src/interfaces/IATokenVaultMerklRewardClaimer.sol +++ b/src/interfaces/IATokenVaultMerklRewardClaimer.sol @@ -13,7 +13,7 @@ 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 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 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) */ @@ -26,19 +26,6 @@ interface IATokenVaultMerklRewardClaimer { */ event MerklDistributorUpdated(address indexed oldMerklDistributor, address indexed newMerklDistributor); - /** - * @notice Getter for the contract address called to claim Merkl rewards - * @return Address of the Merkl distributor contract - */ - function getMerklDistributor() external view returns (address); - - /** - * @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 Claims Merkl rewards earned by deposits from this contract through the Merkl distributor contract * @dev Only callable by the owner @@ -51,4 +38,17 @@ interface IATokenVaultMerklRewardClaimer { */ function claimMerklRewards(address[] calldata rewardTokens, uint256[] calldata amounts, bytes32[][] calldata proofs) 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 Getter for the contract address called to claim Merkl rewards + * @return Address of the Merkl distributor contract + */ + function getMerklDistributor() external view returns (address); } From 99a413122ae826cbf523fd17da42bc23a13b9c89 Mon Sep 17 00:00:00 2001 From: Alpay Aldemir Date: Thu, 4 Dec 2025 20:02:43 -0800 Subject: [PATCH 03/12] test: add unit tests for ATokenVaultMerklRewardClaimer --- src/ATokenVaultMerklRewardClaimer.sol | 2 +- ...> ATokenVaultMerklRewardClaimerFork.t.sol} | 15 +- test/ATokenVaultMerklRewardsClaimer.t.sol | 155 ++++++++++++++++++ test/mocks/MockMerklDistributor.sol | 65 ++++++++ 4 files changed, 230 insertions(+), 7 deletions(-) rename test/{ATokenVaultMerklRewardClaimer.t.sol => ATokenVaultMerklRewardClaimerFork.t.sol} (94%) create mode 100644 test/ATokenVaultMerklRewardsClaimer.t.sol create mode 100644 test/mocks/MockMerklDistributor.sol diff --git a/src/ATokenVaultMerklRewardClaimer.sol b/src/ATokenVaultMerklRewardClaimer.sol index fdbabfd..c070251 100644 --- a/src/ATokenVaultMerklRewardClaimer.sol +++ b/src/ATokenVaultMerklRewardClaimer.sol @@ -53,8 +53,8 @@ contract ATokenVaultMerklRewardClaimer is ATokenVault, IATokenVaultMerklRewardCl } /// @inheritdoc IATokenVaultMerklRewardClaimer + /// @dev Allow setting address(0) to reset the Merkl distributor function setMerklDistributor(address merklDistributor) external override onlyOwner { - require(merklDistributor != address(0), "ZERO_ADDRESS_NOT_VALID"); address currentMerklDistributor = _s.merklDistributor; _s.merklDistributor = merklDistributor; emit MerklDistributorUpdated(currentMerklDistributor, merklDistributor); diff --git a/test/ATokenVaultMerklRewardClaimer.t.sol b/test/ATokenVaultMerklRewardClaimerFork.t.sol similarity index 94% rename from test/ATokenVaultMerklRewardClaimer.t.sol rename to test/ATokenVaultMerklRewardClaimerFork.t.sol index 2836766..5f61321 100644 --- a/test/ATokenVaultMerklRewardClaimer.t.sol +++ b/test/ATokenVaultMerklRewardClaimerFork.t.sol @@ -15,12 +15,12 @@ import {IATokenVaultMerklRewardClaimer} from "../src/interfaces/IATokenVaultMerk import {IATokenVault} from "../src/interfaces/IATokenVault.sol"; /** - * @title ATokenVaultMerklRewardClaimerTest - * @notice Test suite for claiming Merkl rewards from the ATokenVault - * @dev Forks Ethereum mainnet to etch the ATokenVault onto an address that has claimable rewards as of the forked block + * @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 ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { +contract ATokenVaultMerklRewardClaimerForkTest is ATokenVaultBaseTest { using stdStorage for StdStorage; uint256 ethereumFork; // The block before rewards claim in tx: https://etherscan.io/tx/0x42ef6b499d1b6e96a4250f2d5a005b60173386e2ac3a3e424aa407db3da802ea @@ -134,10 +134,13 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(MERKL_DISTRIBUTOR); } - function testSetMerklDistributorRevertsIfZeroAddress() public { + function testSetMerklDistributorAllowsZeroAddress() public { + vm.prank(OWNER); + IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(MERKL_DISTRIBUTOR); + assertEq(IATokenVaultMerklRewardClaimer(address(vault)).getMerklDistributor(), MERKL_DISTRIBUTOR); vm.prank(OWNER); - vm.expectRevert(bytes("ZERO_ADDRESS_NOT_VALID")); IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(address(0)); + assertEq(IATokenVaultMerklRewardClaimer(address(vault)).getMerklDistributor(), address(0)); } function testSetMerklDistributorRevertsIfNotOwner() public { diff --git a/test/ATokenVaultMerklRewardsClaimer.t.sol b/test/ATokenVaultMerklRewardsClaimer.t.sol new file mode 100644 index 0000000..1bc1f0e --- /dev/null +++ b/test/ATokenVaultMerklRewardsClaimer.t.sol @@ -0,0 +1,155 @@ +// 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 {MockAavePoolAddressesProvider} from "./mocks/MockAavePoolAddressesProvider.sol"; +import {MockAavePool} from "./mocks/MockAavePool.sol"; +import {MockAToken} from "./mocks/MockAToken.sol"; +import {MockDAI} from "./mocks/MockDAI.sol"; +import {MockMerklDistributor} from "./mocks/MockMerklDistributor.sol"; +import {ATokenVaultBaseTest} from "./ATokenVaultBaseTest.t.sol"; +import "./utils/Constants.sol"; + +/** + * @title ATokenVaultMerklRewardsClaimerTest + * @notice Unit test suite for claiming Merkl rewards from the ATokenVault + */ +contract ATokenVaultMerklRewardsClaimerTest is ATokenVaultBaseTest { + MockMerklDistributor merklDistributor; + MockAavePoolAddressesProvider poolAddrProvider; + MockAavePool pool; + MockAToken aDai; + MockDAI dai; + IATokenVaultMerklRewardClaimer vaultMerklRewardClaimer; + + function setUp() public override { + // NOTE: Real DAI has non-standard permit. These tests assume tokens with standard permit + dai = new MockDAI(); + + 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); + + vm.prank(OWNER); + vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); + + assertEq(merklDistributor.getLastUsers().length, 1); + assertEq(merklDistributor.getLastUsers()[0], address(vaultMerklRewardClaimer)); + assertEq(merklDistributor.getLastTokens().length, 1); + assertEq(merklDistributor.getLastTokens()[0], address(dai)); + assertEq(merklDistributor.getLastAmounts().length, 1); + assertEq(merklDistributor.getLastAmounts()[0], 1000); + assertEq(merklDistributor.getLastProofs().length, 1); + assertEq(merklDistributor.getLastProofs()[0][0], proof); + } + + function testClaimMerklRewardsEmitsEvent() public { + _setMerklDistributor(); + + bytes32 proof = keccak256("proof1"); + (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(dai), 1000, proof); + vm.prank(OWNER); + vm.expectEmit(true, false, false, true, address(vaultMerklRewardClaimer)); + emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(rewardTokens, amounts); + vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); + } + + function testClaimMerklRewardsRevertsIfMerklDistributorNotSet() public { + bytes32 proof = keccak256("proof1"); + (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(dai), 1000, proof); + vm.prank(OWNER); + vm.expectRevert(bytes("MERKL_DISTRIBUTOR_NOT_SET")); + vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); + } + + function testClaimMerklRewardsRevertsIfNotOwner() public { + bytes32 proof = keccak256("proof1"); + (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(dai), 1000, proof); + vm.expectRevert(bytes("Ownable: caller is not the owner")); + vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); + } + + function testClaimMerklRewardsRevertsIfMerklDistributorReverts() public { + _setMerklDistributor(); + + bytes32 proof = keccak256("proof1"); + (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(dai), 1000, proof); + string memory revertReason = "revert because of insufficient balance"; + merklDistributor.setShouldRevert(true, revertReason); + vm.expectRevert(bytes(revertReason)); + vm.prank(OWNER); + vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); + } + + function testSetMerklDistributor() public { + vm.prank(OWNER); + vaultMerklRewardClaimer.setMerklDistributor(address(merklDistributor)); + assertEq(vaultMerklRewardClaimer.getMerklDistributor(), address(merklDistributor)); + } + + function testSetMerklDistributorEmitsEvent() public { + vm.prank(OWNER); + vm.expectEmit(true, false, false, true, address(vaultMerklRewardClaimer)); + emit IATokenVaultMerklRewardClaimer.MerklDistributorUpdated(address(0), address(merklDistributor)); + vaultMerklRewardClaimer.setMerklDistributor(address(merklDistributor)); + + address newMerklDistributor = makeAddr("newMerklDistributor"); + vm.prank(OWNER); + vm.expectEmit(true, false, false, true, address(vaultMerklRewardClaimer)); + emit IATokenVaultMerklRewardClaimer.MerklDistributorUpdated(address(merklDistributor), newMerklDistributor); + vaultMerklRewardClaimer.setMerklDistributor(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 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; + } +} diff --git a/test/mocks/MockMerklDistributor.sol b/test/mocks/MockMerklDistributor.sol new file mode 100644 index 0000000..9144367 --- /dev/null +++ b/test/mocks/MockMerklDistributor.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.10; + +contract MockMerklDistributor { + bool public claimCalled = false; + address[] public lastUsers; + address[] public lastTokens; + uint256[] public lastAmounts; + bytes32[][] public lastProofs; + bool public shouldRevert = false; + string public revertReason = ""; + + function claim( + address[] calldata users, + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs + ) external { + if (shouldRevert) { + revert(revertReason); + } + + claimCalled = true; + + // Store the call data to be checked in the test + delete lastUsers; + delete lastTokens; + delete lastAmounts; + delete lastProofs; + for (uint256 i = 0; i < users.length; i++) { + lastUsers.push(users[i]); + } + for (uint256 i = 0; i < tokens.length; i++) { + lastTokens.push(tokens[i]); + } + for (uint256 i = 0; i < amounts.length; i++) { + lastAmounts.push(amounts[i]); + } + for (uint256 i = 0; i < proofs.length; i++) { + lastProofs.push(proofs[i]); + } + } + + function setShouldRevert(bool _shouldRevert, string memory _reason) external { + shouldRevert = _shouldRevert; + revertReason = _reason; + } + + function getLastUsers() external view returns (address[] memory) { + return lastUsers; + } + + function getLastTokens() external view returns (address[] memory) { + return lastTokens; + } + + function getLastAmounts() external view returns (uint256[] memory) { + return lastAmounts; + } + + function getLastProofs() external view returns (bytes32[][] memory) { + return lastProofs; + } +} From 75990a7b6e2dfae95ffd299f8957359a9bc8eafa Mon Sep 17 00:00:00 2001 From: Alpay Aldemir Date: Fri, 5 Dec 2025 16:47:31 -0800 Subject: [PATCH 04/12] feat: add function to merkl reward claimer vault to set operator - an operator will be set on the reward distributor contract - an operator can claim rewards on behalf of a user (rewards are still sent to the user i.e. the ATokenVault) --- src/ATokenVaultMerklRewardClaimer.sol | 8 ++++ .../merkl/DistributorInterface.sol | 2 + .../IATokenVaultMerklRewardClaimer.sol | 14 ++++++ test/ATokenVaultMerklRewardsClaimer.t.sol | 48 +++++++++++++++++-- test/mocks/MockMerklDistributor.sol | 15 +++++- 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/ATokenVaultMerklRewardClaimer.sol b/src/ATokenVaultMerklRewardClaimer.sol index c070251..b1e8b93 100644 --- a/src/ATokenVaultMerklRewardClaimer.sol +++ b/src/ATokenVaultMerklRewardClaimer.sol @@ -60,6 +60,14 @@ contract ATokenVaultMerklRewardClaimer is ATokenVault, IATokenVaultMerklRewardCl emit MerklDistributorUpdated(currentMerklDistributor, merklDistributor); } + /// @inheritdoc IATokenVaultMerklRewardClaimer + function toggleOperator(address operator) external override onlyOwner { + require(_s.merklDistributor != address(0), "MERKL_DISTRIBUTOR_NOT_SET"); + require(operator != address(0), "ZERO_ADDRESS_NOT_VALID"); + IMerklDistributor(_s.merklDistributor).toggleOperator(address(this), operator); + emit MerklRewardsOperatorToggled(operator); + } + /// @inheritdoc IATokenVaultMerklRewardClaimer function getMerklDistributor() external view override returns (address) { return _s.merklDistributor; diff --git a/src/dependencies/merkl/DistributorInterface.sol b/src/dependencies/merkl/DistributorInterface.sol index 65ae049..2b82d07 100644 --- a/src/dependencies/merkl/DistributorInterface.sol +++ b/src/dependencies/merkl/DistributorInterface.sol @@ -11,4 +11,6 @@ interface IMerklDistributor { uint256[] calldata amounts, bytes32[][] calldata proofs ) external; + + function toggleOperator(address user, address operator) external; } diff --git a/src/interfaces/IATokenVaultMerklRewardClaimer.sol b/src/interfaces/IATokenVaultMerklRewardClaimer.sol index 58abcdf..e6a66de 100644 --- a/src/interfaces/IATokenVaultMerklRewardClaimer.sol +++ b/src/interfaces/IATokenVaultMerklRewardClaimer.sol @@ -19,6 +19,12 @@ interface IATokenVaultMerklRewardClaimer { */ event MerklRewardsClaimed(address[] tokens, uint256[] amounts); + /** + * @dev Emitted when the operator status for the vault is toggled + * @param operator Address of the operator to toggle + */ + event MerklRewardsOperatorToggled(address indexed operator); + /** * @dev Emitted when the Merkl distributor address is updated * @param oldMerklDistributor The old address of the Merkl distributor contract @@ -46,6 +52,14 @@ interface IATokenVaultMerklRewardClaimer { */ function setMerklDistributor(address merklDistributor) external; + + /** + * @notice Toggles the operator status for the vault + * @dev Only callable by the owner + * @param operator Address of the operator to toggle + */ + function toggleOperator(address operator) external; + /** * @notice Getter for the contract address called to claim Merkl rewards * @return Address of the Merkl distributor contract diff --git a/test/ATokenVaultMerklRewardsClaimer.t.sol b/test/ATokenVaultMerklRewardsClaimer.t.sol index 1bc1f0e..a32e5f4 100644 --- a/test/ATokenVaultMerklRewardsClaimer.t.sol +++ b/test/ATokenVaultMerklRewardsClaimer.t.sol @@ -73,7 +73,7 @@ contract ATokenVaultMerklRewardsClaimerTest is ATokenVaultBaseTest { bytes32 proof = keccak256("proof1"); (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(dai), 1000, proof); vm.prank(OWNER); - vm.expectEmit(true, false, false, true, address(vaultMerklRewardClaimer)); + vm.expectEmit(true, true, false, true, address(vaultMerklRewardClaimer)); emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(rewardTokens, amounts); vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); } @@ -105,6 +105,42 @@ contract ATokenVaultMerklRewardsClaimerTest is ATokenVaultBaseTest { vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); } + function testToggleOperator() public { + _setMerklDistributor(); + address operator = makeAddr("newOperator"); + vm.prank(OWNER); + vaultMerklRewardClaimer.toggleOperator(operator); + + assertEq(merklDistributor.getOperator(address(vaultMerklRewardClaimer), operator), true); + + vm.prank(OWNER); + vaultMerklRewardClaimer.toggleOperator(operator); + assertEq(merklDistributor.getOperator(address(vaultMerklRewardClaimer), operator), false); + } + + function testToggleOperatorEmitsEvent() public { + _setMerklDistributor(); + address operator = makeAddr("newOperator"); + vm.prank(OWNER); + vm.expectEmit(true, true, false, true, address(vaultMerklRewardClaimer)); + emit IATokenVaultMerklRewardClaimer.MerklRewardsOperatorToggled(operator); + vaultMerklRewardClaimer.toggleOperator(operator); + } + + function testToggleOperatorRevertsIfOperatorIsZeroAddress() public { + _setMerklDistributor(); + vm.prank(OWNER); + vm.expectRevert(bytes("ZERO_ADDRESS_NOT_VALID")); + vaultMerklRewardClaimer.toggleOperator(address(0)); + } + + function testToggleOperatorRevertsIfMerklDistributorNotSet() public { + address operator = makeAddr("operator"); + vm.prank(OWNER); + vm.expectRevert(bytes("MERKL_DISTRIBUTOR_NOT_SET")); + vaultMerklRewardClaimer.toggleOperator(operator); + } + function testSetMerklDistributor() public { vm.prank(OWNER); vaultMerklRewardClaimer.setMerklDistributor(address(merklDistributor)); @@ -113,13 +149,13 @@ contract ATokenVaultMerklRewardsClaimerTest is ATokenVaultBaseTest { function testSetMerklDistributorEmitsEvent() public { vm.prank(OWNER); - vm.expectEmit(true, false, false, true, address(vaultMerklRewardClaimer)); + vm.expectEmit(true, true, false, true, address(vaultMerklRewardClaimer)); emit IATokenVaultMerklRewardClaimer.MerklDistributorUpdated(address(0), address(merklDistributor)); vaultMerklRewardClaimer.setMerklDistributor(address(merklDistributor)); address newMerklDistributor = makeAddr("newMerklDistributor"); vm.prank(OWNER); - vm.expectEmit(true, false, false, true, address(vaultMerklRewardClaimer)); + vm.expectEmit(true, true, false, true, address(vaultMerklRewardClaimer)); emit IATokenVaultMerklRewardClaimer.MerklDistributorUpdated(address(merklDistributor), newMerklDistributor); vaultMerklRewardClaimer.setMerklDistributor(newMerklDistributor); } @@ -143,7 +179,11 @@ contract ATokenVaultMerklRewardsClaimerTest is ATokenVaultBaseTest { vaultMerklRewardClaimer.setMerklDistributor(address(merklDistributor)); } - function _buildMerklRewardsClaimData(address token, uint256 amount, bytes32 proof) internal returns (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) { + 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); diff --git a/test/mocks/MockMerklDistributor.sol b/test/mocks/MockMerklDistributor.sol index 9144367..f36bf9a 100644 --- a/test/mocks/MockMerklDistributor.sol +++ b/test/mocks/MockMerklDistributor.sol @@ -2,12 +2,17 @@ pragma solidity ^0.8.10; -contract MockMerklDistributor { +import {IMerklDistributor} from "../../src/dependencies/merkl/DistributorInterface.sol"; + +contract MockMerklDistributor is IMerklDistributor { bool public claimCalled = false; address[] public lastUsers; address[] public lastTokens; uint256[] public lastAmounts; bytes32[][] public lastProofs; + + mapping(address => mapping(address => bool)) public operators; + bool public shouldRevert = false; string public revertReason = ""; @@ -42,6 +47,10 @@ contract MockMerklDistributor { } } + function toggleOperator(address user, address operator) external { + operators[user][operator] = !operators[user][operator]; + } + function setShouldRevert(bool _shouldRevert, string memory _reason) external { shouldRevert = _shouldRevert; revertReason = _reason; @@ -62,4 +71,8 @@ contract MockMerklDistributor { function getLastProofs() external view returns (bytes32[][] memory) { return lastProofs; } + + function getOperator(address user, address operator) external view returns (bool) { + return operators[user][operator]; + } } From ffc8063e17e5bdac6d4328d39c6474cb367aba29 Mon Sep 17 00:00:00 2001 From: Alpay Aldemir Date: Fri, 5 Dec 2025 16:49:33 -0800 Subject: [PATCH 05/12] enh: include the merkl distributor address in events --- src/ATokenVaultMerklRewardClaimer.sol | 4 ++-- src/interfaces/IATokenVaultMerklRewardClaimer.sol | 7 ++++--- test/ATokenVaultMerklRewardClaimerFork.t.sol | 6 +++--- test/ATokenVaultMerklRewardsClaimer.t.sol | 4 ++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/ATokenVaultMerklRewardClaimer.sol b/src/ATokenVaultMerklRewardClaimer.sol index b1e8b93..d4a8f14 100644 --- a/src/ATokenVaultMerklRewardClaimer.sol +++ b/src/ATokenVaultMerklRewardClaimer.sol @@ -49,7 +49,7 @@ contract ATokenVaultMerklRewardClaimer is ATokenVault, IATokenVaultMerklRewardCl // We do not need to accrue after claiming because any subsequent call will trigger an accrual before state updates // and preview functions read the balance of aTokens on the vault at runtime. - emit MerklRewardsClaimed(rewardTokens, amounts); + emit MerklRewardsClaimed(_s.merklDistributor, rewardTokens, amounts); } /// @inheritdoc IATokenVaultMerklRewardClaimer @@ -65,7 +65,7 @@ contract ATokenVaultMerklRewardClaimer is ATokenVault, IATokenVaultMerklRewardCl require(_s.merklDistributor != address(0), "MERKL_DISTRIBUTOR_NOT_SET"); require(operator != address(0), "ZERO_ADDRESS_NOT_VALID"); IMerklDistributor(_s.merklDistributor).toggleOperator(address(this), operator); - emit MerklRewardsOperatorToggled(operator); + emit MerklRewardsOperatorToggled(_s.merklDistributor, operator); } /// @inheritdoc IATokenVaultMerklRewardClaimer diff --git a/src/interfaces/IATokenVaultMerklRewardClaimer.sol b/src/interfaces/IATokenVaultMerklRewardClaimer.sol index e6a66de..c6a16b0 100644 --- a/src/interfaces/IATokenVaultMerklRewardClaimer.sol +++ b/src/interfaces/IATokenVaultMerklRewardClaimer.sol @@ -14,16 +14,18 @@ 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[] tokens, uint256[] amounts); + event MerklRewardsClaimed(address indexed distributor, address[] tokens, uint256[] amounts); /** * @dev Emitted when the operator status for the vault is toggled + * @param distributor Address of the Merkl distributor contract * @param operator Address of the operator to toggle */ - event MerklRewardsOperatorToggled(address indexed operator); + event MerklRewardsOperatorToggled(address indexed distributor, address indexed operator); /** * @dev Emitted when the Merkl distributor address is updated @@ -52,7 +54,6 @@ interface IATokenVaultMerklRewardClaimer { */ function setMerklDistributor(address merklDistributor) external; - /** * @notice Toggles the operator status for the vault * @dev Only callable by the owner diff --git a/test/ATokenVaultMerklRewardClaimerFork.t.sol b/test/ATokenVaultMerklRewardClaimerFork.t.sol index 5f61321..ffa6eeb 100644 --- a/test/ATokenVaultMerklRewardClaimerFork.t.sol +++ b/test/ATokenVaultMerklRewardClaimerFork.t.sol @@ -96,8 +96,8 @@ contract ATokenVaultMerklRewardClaimerForkTest is ATokenVaultBaseTest { (address[] memory tokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(); vm.prank(OWNER); - vm.expectEmit(true, false, false, true, ADDRESS_WITH_CLAIMABLE_REWARDS); - emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(tokens, amounts); + vm.expectEmit(true, true, false, true, ADDRESS_WITH_CLAIMABLE_REWARDS); + emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(MERKL_DISTRIBUTOR, tokens, amounts); IATokenVaultMerklRewardClaimer(ADDRESS_WITH_CLAIMABLE_REWARDS).claimMerklRewards(tokens, amounts, proofs); } @@ -129,7 +129,7 @@ contract ATokenVaultMerklRewardClaimerForkTest is ATokenVaultBaseTest { function testSetMerklDistributorEmitsEvent() public { vm.prank(OWNER); - vm.expectEmit(true, false, false, true, address(vault)); + vm.expectEmit(true, true, false, true, address(vault)); emit IATokenVaultMerklRewardClaimer.MerklDistributorUpdated(address(0), MERKL_DISTRIBUTOR); IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(MERKL_DISTRIBUTOR); } diff --git a/test/ATokenVaultMerklRewardsClaimer.t.sol b/test/ATokenVaultMerklRewardsClaimer.t.sol index a32e5f4..d52add4 100644 --- a/test/ATokenVaultMerklRewardsClaimer.t.sol +++ b/test/ATokenVaultMerklRewardsClaimer.t.sol @@ -74,7 +74,7 @@ contract ATokenVaultMerklRewardsClaimerTest is ATokenVaultBaseTest { (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(dai), 1000, proof); vm.prank(OWNER); vm.expectEmit(true, true, false, true, address(vaultMerklRewardClaimer)); - emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(rewardTokens, amounts); + emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(address(merklDistributor), rewardTokens, amounts); vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); } @@ -123,7 +123,7 @@ contract ATokenVaultMerklRewardsClaimerTest is ATokenVaultBaseTest { address operator = makeAddr("newOperator"); vm.prank(OWNER); vm.expectEmit(true, true, false, true, address(vaultMerklRewardClaimer)); - emit IATokenVaultMerklRewardClaimer.MerklRewardsOperatorToggled(operator); + emit IATokenVaultMerklRewardClaimer.MerklRewardsOperatorToggled(address(merklDistributor), operator); vaultMerklRewardClaimer.toggleOperator(operator); } From adabf303ce53713d998fe857ef51756c5b9d3041 Mon Sep 17 00:00:00 2001 From: Alpay Aldemir Date: Fri, 5 Dec 2025 16:50:16 -0800 Subject: [PATCH 06/12] chore: rename test contract --- ...wardsClaimer.t.sol => ATokenVaultMerklRewardClaimer.t.sol} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename test/{ATokenVaultMerklRewardsClaimer.t.sol => ATokenVaultMerklRewardClaimer.t.sol} (98%) diff --git a/test/ATokenVaultMerklRewardsClaimer.t.sol b/test/ATokenVaultMerklRewardClaimer.t.sol similarity index 98% rename from test/ATokenVaultMerklRewardsClaimer.t.sol rename to test/ATokenVaultMerklRewardClaimer.t.sol index d52add4..8d61c6c 100644 --- a/test/ATokenVaultMerklRewardsClaimer.t.sol +++ b/test/ATokenVaultMerklRewardClaimer.t.sol @@ -17,10 +17,10 @@ import {ATokenVaultBaseTest} from "./ATokenVaultBaseTest.t.sol"; import "./utils/Constants.sol"; /** - * @title ATokenVaultMerklRewardsClaimerTest + * @title ATokenVaultMerklRewardClaimerTest * @notice Unit test suite for claiming Merkl rewards from the ATokenVault */ -contract ATokenVaultMerklRewardsClaimerTest is ATokenVaultBaseTest { +contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { MockMerklDistributor merklDistributor; MockAavePoolAddressesProvider poolAddrProvider; MockAavePool pool; From 7d5c2f0ce8185715a509923965282a5542b52286 Mon Sep 17 00:00:00 2001 From: Alpay Aldemir Date: Fri, 5 Dec 2025 16:52:59 -0800 Subject: [PATCH 07/12] test: add check for non owner call to toggle operator --- test/ATokenVaultMerklRewardClaimer.t.sol | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/ATokenVaultMerklRewardClaimer.t.sol b/test/ATokenVaultMerklRewardClaimer.t.sol index 8d61c6c..bd9b48f 100644 --- a/test/ATokenVaultMerklRewardClaimer.t.sol +++ b/test/ATokenVaultMerklRewardClaimer.t.sol @@ -141,6 +141,12 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { vaultMerklRewardClaimer.toggleOperator(operator); } + function testToggleOperatorRevertsIfNotOwner() public { + address operator = makeAddr("operator"); + vm.expectRevert(bytes("Ownable: caller is not the owner")); + vaultMerklRewardClaimer.toggleOperator(operator); + } + function testSetMerklDistributor() public { vm.prank(OWNER); vaultMerklRewardClaimer.setMerklDistributor(address(merklDistributor)); From 558c9efbf9f5f9f77f4c049217ab03b94faf8e6c Mon Sep 17 00:00:00 2001 From: Alpay Aldemir Date: Thu, 11 Dec 2025 09:06:57 -0800 Subject: [PATCH 08/12] refactor(ATokenVaultMerklRewardClaimer): address feedback --- src/ATokenVaultMerklRewardClaimer.sol | 19 +- .../merkl/DistributorInterface.sol | 6 +- .../IATokenVaultMerklRewardClaimer.sol | 20 +- test/ATokenVaultMerklRewardClaimer.t.sol | 295 ++++++++++++------ test/mocks/MockMerklDistributor.sol | 87 ++---- 5 files changed, 237 insertions(+), 190 deletions(-) diff --git a/src/ATokenVaultMerklRewardClaimer.sol b/src/ATokenVaultMerklRewardClaimer.sol index d4a8f14..631143e 100644 --- a/src/ATokenVaultMerklRewardClaimer.sol +++ b/src/ATokenVaultMerklRewardClaimer.sol @@ -33,22 +33,13 @@ contract ATokenVaultMerklRewardClaimer is ATokenVault, IATokenVaultMerklRewardCl onlyOwner { require(_s.merklDistributor != address(0), "MERKL_DISTRIBUTOR_NOT_SET"); + require(rewardTokens.length == amounts.length && rewardTokens.length == proofs.length, "ARRAY_LENGTH_MISMATCH"); address[] memory users = new address[](rewardTokens.length); for (uint256 i = 0; i < rewardTokens.length; i++) { - // users represent depositors into Aave which is this contract users[i] = address(this); } - - // The claim function does not return a list of tokens and amounts actually received. - // It is possible for rewards to be in aTokens, the underlying asset or some other token. - // If necessary the owner can use IATokenVault.emergencyRescue(...) to rescue the non-aToken rewards and non-native rewards. IMerklDistributor(_s.merklDistributor).claim(users, rewardTokens, amounts, proofs); - // Do not attempt to accrue yield as it can be delegated to subsequent calls to this contract. - // We do not need to accrue before claiming because new shares are not granted anywhere (rewards are socialized across all current share holders). - // We do not need to accrue after claiming because any subsequent call will trigger an accrual before state updates - // and preview functions read the balance of aTokens on the vault at runtime. - emit MerklRewardsClaimed(_s.merklDistributor, rewardTokens, amounts); } @@ -60,14 +51,6 @@ contract ATokenVaultMerklRewardClaimer is ATokenVault, IATokenVaultMerklRewardCl emit MerklDistributorUpdated(currentMerklDistributor, merklDistributor); } - /// @inheritdoc IATokenVaultMerklRewardClaimer - function toggleOperator(address operator) external override onlyOwner { - require(_s.merklDistributor != address(0), "MERKL_DISTRIBUTOR_NOT_SET"); - require(operator != address(0), "ZERO_ADDRESS_NOT_VALID"); - IMerklDistributor(_s.merklDistributor).toggleOperator(address(this), operator); - emit MerklRewardsOperatorToggled(_s.merklDistributor, operator); - } - /// @inheritdoc IATokenVaultMerklRewardClaimer function getMerklDistributor() external view override returns (address) { return _s.merklDistributor; diff --git a/src/dependencies/merkl/DistributorInterface.sol b/src/dependencies/merkl/DistributorInterface.sol index 2b82d07..e364257 100644 --- a/src/dependencies/merkl/DistributorInterface.sol +++ b/src/dependencies/merkl/DistributorInterface.sol @@ -1,7 +1,5 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: BUSL-1.1 // Based on implementation from https://github.com/AngleProtocol/merkl-contracts/blob/b7bd0e65a3f366e4041bc83494cbd981f8852b16/contracts/Distributor.sol#L202 -// All Rights Reserved © AaveCo - pragma solidity ^0.8.10; interface IMerklDistributor { @@ -11,6 +9,4 @@ interface IMerklDistributor { uint256[] calldata amounts, bytes32[][] calldata proofs ) external; - - function toggleOperator(address user, address operator) external; } diff --git a/src/interfaces/IATokenVaultMerklRewardClaimer.sol b/src/interfaces/IATokenVaultMerklRewardClaimer.sol index c6a16b0..2eb6603 100644 --- a/src/interfaces/IATokenVaultMerklRewardClaimer.sol +++ b/src/interfaces/IATokenVaultMerklRewardClaimer.sol @@ -20,13 +20,6 @@ interface IATokenVaultMerklRewardClaimer { */ event MerklRewardsClaimed(address indexed distributor, address[] tokens, uint256[] amounts); - /** - * @dev Emitted when the operator status for the vault is toggled - * @param distributor Address of the Merkl distributor contract - * @param operator Address of the operator to toggle - */ - event MerklRewardsOperatorToggled(address indexed distributor, address indexed operator); - /** * @dev Emitted when the Merkl distributor address is updated * @param oldMerklDistributor The old address of the Merkl distributor contract @@ -35,7 +28,7 @@ interface IATokenVaultMerklRewardClaimer { event MerklDistributorUpdated(address indexed oldMerklDistributor, address indexed newMerklDistributor); /** - * @notice Claims Merkl rewards earned by deposits from this contract through the Merkl distributor contract + * @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 @@ -55,15 +48,8 @@ interface IATokenVaultMerklRewardClaimer { function setMerklDistributor(address merklDistributor) external; /** - * @notice Toggles the operator status for the vault - * @dev Only callable by the owner - * @param operator Address of the operator to toggle - */ - function toggleOperator(address operator) external; - - /** - * @notice Getter for the contract address called to claim Merkl rewards - * @return Address of the Merkl distributor contract + * @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/ATokenVaultMerklRewardClaimer.t.sol b/test/ATokenVaultMerklRewardClaimer.t.sol index bd9b48f..5863b43 100644 --- a/test/ATokenVaultMerklRewardClaimer.t.sol +++ b/test/ATokenVaultMerklRewardClaimer.t.sol @@ -7,6 +7,7 @@ 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"; @@ -21,168 +22,257 @@ import "./utils/Constants.sol"; * @notice Unit test suite for claiming Merkl rewards from the ATokenVault */ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { - MockMerklDistributor merklDistributor; - MockAavePoolAddressesProvider poolAddrProvider; - MockAavePool pool; - MockAToken aDai; - MockDAI dai; - IATokenVaultMerklRewardClaimer vaultMerklRewardClaimer; + MockMerklDistributor internal _merklDistributor; + MockAavePoolAddressesProvider internal __poolAddrProvider; + MockAavePool internal _pool; + MockAToken internal _aDai; + MockDAI internal _dai; + IATokenVaultMerklRewardClaimer internal _vaultMerklRewardClaimer; function setUp() public override { // NOTE: Real DAI has non-standard permit. These tests assume tokens with standard permit - dai = new MockDAI(); + _dai = new MockDAI(); - aDai = new MockAToken(address(dai)); - pool = new MockAavePool(); - pool.mockReserve(address(dai), aDai); - poolAddrProvider = new MockAavePoolAddressesProvider(address(pool)); + _aDai = new MockAToken(address(_dai)); + _pool = new MockAavePool(); + _pool.mockReserve(address(_dai), _aDai); + __poolAddrProvider = new MockAavePoolAddressesProvider(address(_pool)); - vaultAssetAddress = address(aDai); + vaultAssetAddress = address(_aDai); - pool.setReserveConfigMap(RESERVE_CONFIG_MAP_UNCAPPED_ACTIVE); + _pool.setReserveConfigMap(RESERVE_CONFIG_MAP_UNCAPPED_ACTIVE); - merklDistributor = new MockMerklDistributor(); + _merklDistributor = new MockMerklDistributor(); // Sets the `vault`, but we will not use the vault deployment - _deployATokenVaultMerklRewardClaimer(address(dai), address(poolAddrProvider)); - vaultMerklRewardClaimer = IATokenVaultMerklRewardClaimer(address(vault)); + _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 rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(_dai), 1000, proof); + + 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); - - assertEq(merklDistributor.getLastUsers().length, 1); - assertEq(merklDistributor.getLastUsers()[0], address(vaultMerklRewardClaimer)); - assertEq(merklDistributor.getLastTokens().length, 1); - assertEq(merklDistributor.getLastTokens()[0], address(dai)); - assertEq(merklDistributor.getLastAmounts().length, 1); - assertEq(merklDistributor.getLastAmounts()[0], 1000); - assertEq(merklDistributor.getLastProofs().length, 1); - assertEq(merklDistributor.getLastProofs()[0][0], proof); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); } - function testClaimMerklRewardsEmitsEvent() public { + function testClaimMerklRewardsIfATokenIsRewarded() public { _setMerklDistributor(); - - bytes32 proof = keccak256("proof1"); - (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(dai), 1000, proof); - vm.prank(OWNER); - vm.expectEmit(true, true, false, true, address(vaultMerklRewardClaimer)); - emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(address(merklDistributor), rewardTokens, amounts); - vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); + + 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); } - function testClaimMerklRewardsRevertsIfMerklDistributorNotSet() public { - bytes32 proof = keccak256("proof1"); - (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(dai), 1000, proof); + 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); - vm.expectRevert(bytes("MERKL_DISTRIBUTOR_NOT_SET")); - vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); - } - - function testClaimMerklRewardsRevertsIfNotOwner() public { - bytes32 proof = keccak256("proof1"); - (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(dai), 1000, proof); - vm.expectRevert(bytes("Ownable: caller is not the owner")); - vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); + vault.emergencyRescue(address(_dai), address(this), amountOfUnderlyingTokenRewarded); + assertEq(_dai.balanceOf(address(this)), amountOfUnderlyingTokenRewarded); + assertEq(_dai.balanceOf(address(vault)), 0); } - function testClaimMerklRewardsRevertsIfMerklDistributorReverts() public { + 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(dai), 1000, proof); - string memory revertReason = "revert because of insufficient balance"; - merklDistributor.setShouldRevert(true, revertReason); - vm.expectRevert(bytes(revertReason)); + (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(_dai), 1000, proof); + vm.expectRevert(); vm.prank(OWNER); - vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); } - function testToggleOperator() public { - _setMerklDistributor(); - address operator = makeAddr("newOperator"); - vm.prank(OWNER); - vaultMerklRewardClaimer.toggleOperator(operator); - - assertEq(merklDistributor.getOperator(address(vaultMerklRewardClaimer), operator), true); - + function testClaimMerklRewardsRevertsIfMerklDistributorNotSet() public { + address[] memory rewardTokens = new address[](0); + uint256[] memory amounts = new uint256[](0); + bytes32[][] memory proofs = new bytes32[][](0); vm.prank(OWNER); - vaultMerklRewardClaimer.toggleOperator(operator); - assertEq(merklDistributor.getOperator(address(vaultMerklRewardClaimer), operator), false); + vm.expectRevert(bytes("MERKL_DISTRIBUTOR_NOT_SET")); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); } - function testToggleOperatorEmitsEvent() public { + function testClaimMerklRewardsRevertsIfArrayLengthMismatchFromTokens() public { _setMerklDistributor(); - address operator = makeAddr("newOperator"); + 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; + vm.expectRevert(bytes("ARRAY_LENGTH_MISMATCH")); vm.prank(OWNER); - vm.expectEmit(true, true, false, true, address(vaultMerklRewardClaimer)); - emit IATokenVaultMerklRewardClaimer.MerklRewardsOperatorToggled(address(merklDistributor), operator); - vaultMerklRewardClaimer.toggleOperator(operator); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); } - function testToggleOperatorRevertsIfOperatorIsZeroAddress() public { + 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; + vm.expectRevert(bytes("ARRAY_LENGTH_MISMATCH")); vm.prank(OWNER); - vm.expectRevert(bytes("ZERO_ADDRESS_NOT_VALID")); - vaultMerklRewardClaimer.toggleOperator(address(0)); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); } - function testToggleOperatorRevertsIfMerklDistributorNotSet() public { - address operator = makeAddr("operator"); + 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; + vm.expectRevert(bytes("ARRAY_LENGTH_MISMATCH")); vm.prank(OWNER); - vm.expectRevert(bytes("MERKL_DISTRIBUTOR_NOT_SET")); - vaultMerklRewardClaimer.toggleOperator(operator); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); } - function testToggleOperatorRevertsIfNotOwner() public { - address operator = makeAddr("operator"); + function testClaimMerklRewardsRevertsIfNotOwner() public { + address[] memory rewardTokens = new address[](0); + uint256[] memory amounts = new uint256[](0); + bytes32[][] memory proofs = new bytes32[][](0); vm.expectRevert(bytes("Ownable: caller is not the owner")); - vaultMerklRewardClaimer.toggleOperator(operator); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); } - function testSetMerklDistributor() public { + function testClaimMerklRewardsRevertsIfMerklDistributorReverts() public { + _setMerklDistributor(); + + bytes32 proof = keccak256("proof1"); + (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(_dai), 1000, proof); + string memory revertReason = "revert because of insufficient balance"; + _merklDistributor.setShouldRevert(true, revertReason); + vm.expectRevert(bytes(revertReason)); vm.prank(OWNER); - vaultMerklRewardClaimer.setMerklDistributor(address(merklDistributor)); - assertEq(vaultMerklRewardClaimer.getMerklDistributor(), address(merklDistributor)); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); } - function testSetMerklDistributorEmitsEvent() public { + function testSetMerklDistributor() public { + vm.expectEmit(true, true, false, true, address(_vaultMerklRewardClaimer)); + emit IATokenVaultMerklRewardClaimer.MerklDistributorUpdated(address(0), address(_merklDistributor)); vm.prank(OWNER); - vm.expectEmit(true, true, false, true, address(vaultMerklRewardClaimer)); - emit IATokenVaultMerklRewardClaimer.MerklDistributorUpdated(address(0), address(merklDistributor)); - vaultMerklRewardClaimer.setMerklDistributor(address(merklDistributor)); + _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); - vm.expectEmit(true, true, false, true, address(vaultMerklRewardClaimer)); - emit IATokenVaultMerklRewardClaimer.MerklDistributorUpdated(address(merklDistributor), newMerklDistributor); - vaultMerklRewardClaimer.setMerklDistributor(newMerklDistributor); + _vaultMerklRewardClaimer.setMerklDistributor(newMerklDistributor); + assertEq(_vaultMerklRewardClaimer.getMerklDistributor(), newMerklDistributor); } function testSetMerklDistributorAllowsZeroAddress() public { vm.prank(OWNER); - vaultMerklRewardClaimer.setMerklDistributor(address(merklDistributor)); - assertEq(vaultMerklRewardClaimer.getMerklDistributor(), address(merklDistributor)); + _vaultMerklRewardClaimer.setMerklDistributor(address(_merklDistributor)); + assertEq(_vaultMerklRewardClaimer.getMerklDistributor(), address(_merklDistributor)); vm.prank(OWNER); - vaultMerklRewardClaimer.setMerklDistributor(address(0)); - assertEq(vaultMerklRewardClaimer.getMerklDistributor(), address(0)); + _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)); + _vaultMerklRewardClaimer.setMerklDistributor(address(_merklDistributor)); } function _setMerklDistributor() internal { vm.prank(OWNER); - vaultMerklRewardClaimer.setMerklDistributor(address(merklDistributor)); + _vaultMerklRewardClaimer.setMerklDistributor(address(_merklDistributor)); } function _buildMerklRewardsClaimData( @@ -198,4 +288,19 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { 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); + vm.prank(OWNER); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); + } + + 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/mocks/MockMerklDistributor.sol b/test/mocks/MockMerklDistributor.sol index f36bf9a..1d1da83 100644 --- a/test/mocks/MockMerklDistributor.sol +++ b/test/mocks/MockMerklDistributor.sol @@ -2,19 +2,17 @@ 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 { - bool public claimCalled = false; - address[] public lastUsers; - address[] public lastTokens; - uint256[] public lastAmounts; - bytes32[][] public lastProofs; - - mapping(address => mapping(address => bool)) public operators; + address[] internal _recipients; + address[] internal _tokens; + uint256[] internal _amounts; - bool public shouldRevert = false; - string public revertReason = ""; + bool internal _shouldRevert = false; + string internal _revertReason = ""; function claim( address[] calldata users, @@ -22,57 +20,36 @@ contract MockMerklDistributor is IMerklDistributor { uint256[] calldata amounts, bytes32[][] calldata proofs ) external { - if (shouldRevert) { - revert(revertReason); - } - - claimCalled = true; - - // Store the call data to be checked in the test - delete lastUsers; - delete lastTokens; - delete lastAmounts; - delete lastProofs; - for (uint256 i = 0; i < users.length; i++) { - lastUsers.push(users[i]); + if (_shouldRevert) { + revert(_revertReason); } - for (uint256 i = 0; i < tokens.length; i++) { - lastTokens.push(tokens[i]); + require(users.length == tokens.length && users.length == amounts.length && users.length == proofs.length, "ARRAY_LENGTH_MISMATCH"); + for (uint256 i = 0; i < _recipients.length; i++) { + for (uint256 j = 0; j < _tokens.length; j++) { + if (_tokens[j] == address(0)) { + payable(_recipients[i]).transfer(_amounts[j]); + } else { + ERC20(_tokens[j]).transfer(_recipients[i], _amounts[j]); + } + } } - for (uint256 i = 0; i < amounts.length; i++) { - lastAmounts.push(amounts[i]); - } - for (uint256 i = 0; i < proofs.length; i++) { - lastProofs.push(proofs[i]); - } - } - - function toggleOperator(address user, address operator) external { - operators[user][operator] = !operators[user][operator]; + _recipients = new address[](0); + _tokens = new address[](0); + _amounts = new uint256[](0); } - function setShouldRevert(bool _shouldRevert, string memory _reason) external { - shouldRevert = _shouldRevert; - revertReason = _reason; - } - - function getLastUsers() external view returns (address[] memory) { - return lastUsers; - } - - function getLastTokens() external view returns (address[] memory) { - return lastTokens; - } - - function getLastAmounts() external view returns (uint256[] memory) { - return lastAmounts; - } - - function getLastProofs() external view returns (bytes32[][] memory) { - return lastProofs; + function mockTokensToSend( + address[] memory recipients, + address[] memory tokens, + uint256[] memory amounts + ) external { + _recipients = recipients; + _tokens = tokens; + _amounts = amounts; } - function getOperator(address user, address operator) external view returns (bool) { - return operators[user][operator]; + function setShouldRevert(bool shouldRevert, string memory revertReason) external { + _shouldRevert = shouldRevert; + _revertReason = revertReason; } } From 510fc9c14484e59a1e312b45bb8821ca618e0628 Mon Sep 17 00:00:00 2001 From: Alpay Aldemir Date: Fri, 12 Dec 2025 09:48:19 -0800 Subject: [PATCH 09/12] enh(ATokenVaultMerklRewardClaimer): address feedback on additional tests and cleanup --- test/ATokenVaultMerklRewardClaimer.t.sol | 5 ++ test/ATokenVaultMerklRewardClaimerFork.t.sol | 85 +------------------- test/mocks/MockMerklDistributor.sol | 3 - 3 files changed, 8 insertions(+), 85 deletions(-) diff --git a/test/ATokenVaultMerklRewardClaimer.t.sol b/test/ATokenVaultMerklRewardClaimer.t.sol index 5863b43..462d2a5 100644 --- a/test/ATokenVaultMerklRewardClaimer.t.sol +++ b/test/ATokenVaultMerklRewardClaimer.t.sol @@ -111,6 +111,11 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { 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 { diff --git a/test/ATokenVaultMerklRewardClaimerFork.t.sol b/test/ATokenVaultMerklRewardClaimerFork.t.sol index ffa6eeb..f19e225 100644 --- a/test/ATokenVaultMerklRewardClaimerFork.t.sol +++ b/test/ATokenVaultMerklRewardClaimerFork.t.sol @@ -45,27 +45,7 @@ contract ATokenVaultMerklRewardClaimerForkTest is ATokenVaultBaseTest { _deployATokenVaultMerklRewardClaimer(ETHEREUM_RLUSD, ETHEREUM_HORIZON_POOL_ADDRESSES_PROVIDER); } - /*////////////////////////////////////////////////////////////// - ETHEREUM FORK TESTS - //////////////////////////////////////////////////////////////*/ - - function testEthereumForkWorks() public { - assertEq(vm.activeFork(), ethereumFork); - } - - function testEthereumForkAtExpectedBlock() public { - assertEq(block.number, ETHEREUM_FORK_BLOCK); - } - - function testEthereumForkBalanceOfAddressWithClaimableRewards() public { - assertEq(ADDRESS_WITH_CLAIMABLE_REWARDS.balance, 154288817306978598); - } - - /*////////////////////////////////////////////////////////////// - MERKL REWARDS CLAIM TESTS - //////////////////////////////////////////////////////////////*/ - - function testOwnerCanClaimMerklRewards() public { + function testOwnerCanClaimMerklRewards() 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 @@ -76,6 +56,8 @@ contract ATokenVaultMerklRewardClaimerForkTest is ATokenVaultBaseTest { (address[] memory tokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(); + 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); @@ -87,67 +69,6 @@ contract ATokenVaultMerklRewardClaimerForkTest is ATokenVaultBaseTest { assertEq(IERC20(WRAPPED_A_HOR_RWA_RLUSD).balanceOf(ADDRESS_WITH_CLAIMABLE_REWARDS), beforeBalanceOfWrappedAHorRwaRLUSD); } - function testClaimMerklRewardsEmitsEvent() 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); - - (address[] memory tokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(); - - vm.prank(OWNER); - vm.expectEmit(true, true, false, true, ADDRESS_WITH_CLAIMABLE_REWARDS); - emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(MERKL_DISTRIBUTOR, tokens, amounts); - IATokenVaultMerklRewardClaimer(ADDRESS_WITH_CLAIMABLE_REWARDS).claimMerklRewards(tokens, amounts, proofs); - } - - function testClaimMerklRewardsRevertsIfMerklDistributorNotSet() public { - (address[] memory tokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(); - - vm.prank(OWNER); - vm.expectRevert(bytes("MERKL_DISTRIBUTOR_NOT_SET")); - IATokenVaultMerklRewardClaimer(address(vault)).claimMerklRewards(tokens, amounts, proofs); - } - - function testClaimMerklRewardsRevertsIfNotOwner() public { - (address[] memory tokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(); - - vm.expectRevert(bytes("Ownable: caller is not the owner")); - IATokenVaultMerklRewardClaimer(address(vault)).claimMerklRewards(tokens, amounts, proofs); - } - - function testSetMerklDistributor() public { - vm.prank(OWNER); - IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(MERKL_DISTRIBUTOR); - assertEq(IATokenVaultMerklRewardClaimer(address(vault)).getMerklDistributor(), MERKL_DISTRIBUTOR); - - address newMerklDistributor = makeAddr("newMerklDistributor"); - vm.prank(OWNER); - IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(newMerklDistributor); - assertEq(IATokenVaultMerklRewardClaimer(address(vault)).getMerklDistributor(), newMerklDistributor); - } - - function testSetMerklDistributorEmitsEvent() public { - vm.prank(OWNER); - vm.expectEmit(true, true, false, true, address(vault)); - emit IATokenVaultMerklRewardClaimer.MerklDistributorUpdated(address(0), MERKL_DISTRIBUTOR); - IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(MERKL_DISTRIBUTOR); - } - - function testSetMerklDistributorAllowsZeroAddress() public { - vm.prank(OWNER); - IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(MERKL_DISTRIBUTOR); - assertEq(IATokenVaultMerklRewardClaimer(address(vault)).getMerklDistributor(), MERKL_DISTRIBUTOR); - vm.prank(OWNER); - IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(address(0)); - assertEq(IATokenVaultMerklRewardClaimer(address(vault)).getMerklDistributor(), address(0)); - } - - function testSetMerklDistributorRevertsIfNotOwner() public { - vm.expectRevert(bytes("Ownable: caller is not the owner")); - IATokenVaultMerklRewardClaimer(address(vault)).setMerklDistributor(MERKL_DISTRIBUTOR); - } - 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); diff --git a/test/mocks/MockMerklDistributor.sol b/test/mocks/MockMerklDistributor.sol index 1d1da83..20489cf 100644 --- a/test/mocks/MockMerklDistributor.sol +++ b/test/mocks/MockMerklDistributor.sol @@ -33,9 +33,6 @@ contract MockMerklDistributor is IMerklDistributor { } } } - _recipients = new address[](0); - _tokens = new address[](0); - _amounts = new uint256[](0); } function mockTokensToSend( From 4232f8c86d1dc5f8b35748f6fa57cf2ab284dc58 Mon Sep 17 00:00:00 2001 From: Alpay Aldemir Date: Tue, 16 Dec 2025 19:40:53 -0800 Subject: [PATCH 10/12] feat(ATokenVaultMerklRewardClaimer): allow setting destination to forward reward tokens to --- src/ATokenVaultMerklRewardClaimer.sol | 38 ++++- .../IATokenVaultMerklRewardClaimer.sol | 21 ++- test/ATokenVaultMerklRewardClaimer.t.sol | 141 ++++++++++++++++-- test/ATokenVaultMerklRewardClaimerFork.t.sol | 37 ++++- test/mocks/MockMerklDistributor.sol | 10 +- 5 files changed, 220 insertions(+), 27 deletions(-) diff --git a/src/ATokenVaultMerklRewardClaimer.sol b/src/ATokenVaultMerklRewardClaimer.sol index 631143e..1d74382 100644 --- a/src/ATokenVaultMerklRewardClaimer.sol +++ b/src/ATokenVaultMerklRewardClaimer.sol @@ -4,7 +4,9 @@ pragma solidity ^0.8.10; import {IPoolAddressesProvider} from "@aave-v3-core/interfaces/IPoolAddressesProvider.sol"; -import {IERC20} from "@openzeppelin/interfaces/IERC20.sol"; + +import {IERC20Upgradeable} from "@openzeppelin-upgradeable/interfaces/IERC20Upgradeable.sol"; +import {SafeERC20Upgradeable} from "@openzeppelin-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; import {ATokenVault} from "./ATokenVault.sol"; import {IATokenVaultMerklRewardClaimer} from "./interfaces/IATokenVaultMerklRewardClaimer.sol"; @@ -16,6 +18,8 @@ import {IMerklDistributor} from "./dependencies/merkl/DistributorInterface.sol"; * @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 @@ -27,20 +31,40 @@ contract ATokenVaultMerklRewardClaimer is ATokenVault, IATokenVaultMerklRewardCl {} /// @inheritdoc IATokenVaultMerklRewardClaimer - function claimMerklRewards(address[] calldata rewardTokens, uint256[] calldata amounts, bytes32[][] calldata proofs) + 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(rewardTokens.length == amounts.length && rewardTokens.length == proofs.length, "ARRAY_LENGTH_MISMATCH"); + 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[](rewardTokens.length); - for (uint256 i = 0; i < rewardTokens.length; i++) { + address[] memory users = new address[](tokens.length); + for (uint256 i = 0; i < tokens.length; i++) { users[i] = address(this); } - IMerklDistributor(_s.merklDistributor).claim(users, rewardTokens, amounts, proofs); - emit MerklRewardsClaimed(_s.merklDistributor, rewardTokens, amounts); + 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 diff --git a/src/interfaces/IATokenVaultMerklRewardClaimer.sol b/src/interfaces/IATokenVaultMerklRewardClaimer.sol index 2eb6603..53bfd8b 100644 --- a/src/interfaces/IATokenVaultMerklRewardClaimer.sol +++ b/src/interfaces/IATokenVaultMerklRewardClaimer.sol @@ -20,6 +20,14 @@ interface IATokenVaultMerklRewardClaimer { */ 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 @@ -33,12 +41,19 @@ interface IATokenVaultMerklRewardClaimer { * @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 rewardTokens Addresses of the ERC-20 reward tokens to claim (the tokens passed as params to the Merkl distributor contract) + * @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 rewardTokens, uint256[] calldata amounts, bytes32[][] calldata proofs) - external; + 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. diff --git a/test/ATokenVaultMerklRewardClaimer.t.sol b/test/ATokenVaultMerklRewardClaimer.t.sol index 462d2a5..1e7754a 100644 --- a/test/ATokenVaultMerklRewardClaimer.t.sol +++ b/test/ATokenVaultMerklRewardClaimer.t.sol @@ -54,6 +54,8 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { 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++) { @@ -68,7 +70,112 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { vm.expectEmit(true, true, false, true, address(_vaultMerklRewardClaimer)); emit IATokenVaultMerklRewardClaimer.MerklRewardsClaimed(address(_merklDistributor), rewardTokens, amounts); vm.prank(OWNER); - _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); + _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 { @@ -163,18 +270,22 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { 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.expectRevert(); vm.prank(OWNER); - _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs); + _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); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); } function testClaimMerklRewardsRevertsIfArrayLengthMismatchFromTokens() public { @@ -188,9 +299,11 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { 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); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); } function testClaimMerklRewardsRevertsIfArrayLengthMismatchFromAmounts() public { @@ -204,9 +317,11 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { 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); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); } function testClaimMerklRewardsRevertsIfArrayLengthMismatchFromProofs() public { @@ -221,17 +336,21 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { 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); + _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); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); } function testClaimMerklRewardsRevertsIfMerklDistributorReverts() public { @@ -239,11 +358,13 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { 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); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); } function testSetMerklDistributor() public { @@ -297,8 +418,10 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { 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); + _vaultMerklRewardClaimer.claimMerklRewards(rewardTokens, amounts, proofs, rewardTokensToForward, destination); } function _depositFromUser(address user, uint256 amount) internal { diff --git a/test/ATokenVaultMerklRewardClaimerFork.t.sol b/test/ATokenVaultMerklRewardClaimerFork.t.sol index f19e225..337c710 100644 --- a/test/ATokenVaultMerklRewardClaimerFork.t.sol +++ b/test/ATokenVaultMerklRewardClaimerFork.t.sol @@ -45,7 +45,7 @@ contract ATokenVaultMerklRewardClaimerForkTest is ATokenVaultBaseTest { _deployATokenVaultMerklRewardClaimer(ETHEREUM_RLUSD, ETHEREUM_HORIZON_POOL_ADDRESSES_PROVIDER); } - function testOwnerCanClaimMerklRewards() public { + 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 @@ -55,11 +55,12 @@ contract ATokenVaultMerklRewardClaimerForkTest is ATokenVaultBaseTest { 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); + 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); @@ -69,6 +70,38 @@ contract ATokenVaultMerklRewardClaimerForkTest is ATokenVaultBaseTest { 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); diff --git a/test/mocks/MockMerklDistributor.sol b/test/mocks/MockMerklDistributor.sol index 20489cf..e2dd3aa 100644 --- a/test/mocks/MockMerklDistributor.sol +++ b/test/mocks/MockMerklDistributor.sol @@ -25,12 +25,10 @@ contract MockMerklDistributor is IMerklDistributor { } require(users.length == tokens.length && users.length == amounts.length && users.length == proofs.length, "ARRAY_LENGTH_MISMATCH"); for (uint256 i = 0; i < _recipients.length; i++) { - for (uint256 j = 0; j < _tokens.length; j++) { - if (_tokens[j] == address(0)) { - payable(_recipients[i]).transfer(_amounts[j]); - } else { - ERC20(_tokens[j]).transfer(_recipients[i], _amounts[j]); - } + if (_tokens[i] == address(0)) { + payable(_recipients[i]).transfer(_amounts[i]); + } else { + ERC20(_tokens[i]).transfer(_recipients[i], _amounts[i]); } } } From 4db183abc5825feb979a70fd64ffaac7fceed56e Mon Sep 17 00:00:00 2001 From: Alpay Aldemir Date: Wed, 17 Dec 2025 16:08:22 -0800 Subject: [PATCH 11/12] test(ATokenVaultMerklRewardClaimer): use weth mock token to test native reward claim --- src/ATokenVaultMerklRewardClaimer.sol | 4 +--- test/ATokenVaultMerklRewardClaimer.t.sol | 6 ++++-- test/mocks/MockWETH.sol | 9 +++++++++ 3 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 test/mocks/MockWETH.sol diff --git a/src/ATokenVaultMerklRewardClaimer.sol b/src/ATokenVaultMerklRewardClaimer.sol index 1d74382..385a074 100644 --- a/src/ATokenVaultMerklRewardClaimer.sol +++ b/src/ATokenVaultMerklRewardClaimer.sol @@ -4,13 +4,11 @@ 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"; -import {IMerklDistributor} from "./dependencies/merkl/DistributorInterface.sol"; /** * @title ATokenVaultMerklRewardClaimer diff --git a/test/ATokenVaultMerklRewardClaimer.t.sol b/test/ATokenVaultMerklRewardClaimer.t.sol index 1e7754a..a3dc82f 100644 --- a/test/ATokenVaultMerklRewardClaimer.t.sol +++ b/test/ATokenVaultMerklRewardClaimer.t.sol @@ -13,6 +13,7 @@ import {MockAavePoolAddressesProvider} from "./mocks/MockAavePoolAddressesProvid 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"; @@ -27,12 +28,13 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { 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); @@ -269,7 +271,7 @@ contract ATokenVaultMerklRewardClaimerTest is ATokenVaultBaseTest { vm.deal(address(_merklDistributor), amountOfNativeToken); bytes32 proof = keccak256("proof1"); - (address[] memory rewardTokens, uint256[] memory amounts, bytes32[][] memory proofs) = _buildMerklRewardsClaimData(address(_dai), 1000, proof); + (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(); 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") {} +} From 7fedcd14473efbb99e1dedd419122080d3d7a718 Mon Sep 17 00:00:00 2001 From: Alpay Aldemir Date: Thu, 18 Dec 2025 12:03:06 -0800 Subject: [PATCH 12/12] chore(IATokenVaultMerklRewardClaimer): format docs --- src/interfaces/IATokenVaultMerklRewardClaimer.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/src/interfaces/IATokenVaultMerklRewardClaimer.sol b/src/interfaces/IATokenVaultMerklRewardClaimer.sol index 53bfd8b..b29039f 100644 --- a/src/interfaces/IATokenVaultMerklRewardClaimer.sol +++ b/src/interfaces/IATokenVaultMerklRewardClaimer.sol @@ -6,7 +6,6 @@ pragma solidity ^0.8.10; /** * @title IATokenVaultMerklRewardClaimer * @author Aave Protocol - * * @notice Defines the basic interface of the ATokenVaultMerklRewardClaimer */ interface IATokenVaultMerklRewardClaimer {