From 75e9f5591065cb4bc9d96950eb381570f7ee1398 Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Mon, 16 Feb 2026 18:49:36 -0700 Subject: [PATCH 01/26] feat: Simple contract to mint fee shares --- src/hub/FeeSharesMinterBase.sol | 105 +++++++++++++ tests/unit/Hub/FeeSharesMinterBase.t.sol | 191 +++++++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 src/hub/FeeSharesMinterBase.sol create mode 100644 tests/unit/Hub/FeeSharesMinterBase.t.sol diff --git a/src/hub/FeeSharesMinterBase.sol b/src/hub/FeeSharesMinterBase.sol new file mode 100644 index 000000000..ce7680089 --- /dev/null +++ b/src/hub/FeeSharesMinterBase.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol'; + +/** + * @title FeeSharesMinterBase + * @notice Contract to mint fee shares on the Hub when specific conditions are met. + */ +contract FeeSharesMinterBase is Ownable { + struct MintConfig { + uint256 minTimeInterval; + uint256 minUnrealizedFeePercent; // 1e4 = 100% (basis points) + } + + IHub public immutable HUB; + + mapping(uint256 => MintConfig) internal _configs; + mapping(uint256 => uint256) public lastMintTime; + + event ConfigUpdated(uint256 indexed assetId, MintConfig config); + event FeeSharesMinted(uint256 indexed assetId, uint256 shares); + + error ConditionsNotMet(); + + constructor(address owner, IHub hub) Ownable(owner) { + HUB = hub; + } + + /** + * @notice Sets the automation configuration for a specific asset. + * @param assetId The identifier of the asset. + * @param config The new configuration. + */ + function setConfig(uint256 assetId, MintConfig memory config) external onlyOwner { + _configs[assetId] = config; + emit ConfigUpdated(assetId, config); + } + + /** + * @notice Executes the fee minting if conditions are met. + * @param assetId The identifier of the asset. + */ + function execute(uint256 assetId) external { + if (!_checkExecute(assetId)) { + revert ConditionsNotMet(); + } + + lastMintTime[assetId] = block.timestamp; + + uint256 shares = HUB.mintFeeShares(assetId); + emit FeeSharesMinted(assetId, shares); + } + + /** + * @notice Returns the automation configuration for a specific asset. + * @param assetId The identifier of the asset. + * @return The configuration struct. + */ + function getConfig(uint256 assetId) external view returns (MintConfig memory) { + return _configs[assetId]; + } + + /** + * @notice Checks if the conditions to mint fee shares are met. + * @param assetId The identifier of the asset. + * @return True if conditions are met, false otherwise. + */ + function checkExecute(uint256 assetId) external view returns (bool) { + return _checkExecute(assetId); + } + + /** + * @dev Internal function to check execution conditions. + * @param assetId The identifier of the asset. + * @return True if conditions are met, false otherwise. + */ + function _checkExecute(uint256 assetId) internal view returns (bool) { + MintConfig memory config = _configs[assetId]; + + // Check mint interval + if (block.timestamp - lastMintTime[assetId] < config.minTimeInterval) { + return false; + } + + uint256 accruedFees = HUB.getAssetAccruedFees(assetId); + + uint256 totalAddedAssets = HUB.getAddedAssets(assetId); + if (totalAddedAssets == 0) return false; + + // Check if accruedFees / totalAddedAssets >= minUnrealizedFeePercent (in bps) + if ((accruedFees * 10000) / totalAddedAssets < config.minUnrealizedFeePercent) { + return false; + } + + // Ensure at least 1 fee share is minted + uint256 expectedShares = HUB.previewAddByAssets(assetId, accruedFees); + if (expectedShares < 1) { + return false; + } + + return true; + } +} diff --git a/tests/unit/Hub/FeeSharesMinterBase.t.sol b/tests/unit/Hub/FeeSharesMinterBase.t.sol new file mode 100644 index 000000000..145a4f8d9 --- /dev/null +++ b/tests/unit/Hub/FeeSharesMinterBase.t.sol @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import 'tests/unit/Hub/HubBase.t.sol'; +import {FeeSharesMinterBase} from 'src/hub/FeeSharesMinterBase.sol'; + +contract FeeSharesMinterBaseTest is HubBase { + FeeSharesMinterBase internal minter; + + function setUp() public override { + super.setUp(); + minter = new FeeSharesMinterBase(ADMIN, hub1); + + // Grant minter the HUB_ADMIN_ROLE so it can call mintFeeShares + vm.prank(ADMIN); + accessManager.grantRole(Roles.HUB_ADMIN_ROLE, address(minter), 0); + } + + function test_setConfig_revertsWith_OwnableUnauthorized() public { + FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + minTimeInterval: 1 days, + minUnrealizedFeePercent: 100 // 1% + }); + + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, bob)); + minter.setConfig(daiAssetId, config); + } + + function test_execute_success() public { + FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + minTimeInterval: 1 days, + minUnrealizedFeePercent: 10 // 0.1% + }); + vm.prank(ADMIN); + minter.setConfig(daiAssetId, config); + + // Generate fees + // Add 1000 DAI, borrow 100 DAI + _addAndDrawLiquidity({ + hub: hub1, + assetId: daiAssetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: 1000e18, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: 900e18, + skipTime: 365 days // Skip enough time for interval and fee accrual + }); + + assertTrue(minter.checkExecute(daiAssetId), 'Should be executable'); + + vm.expectEmit(address(minter)); + emit FeeSharesMinterBase.FeeSharesMinted( + daiAssetId, + hub1.previewAddByAssets(daiAssetId, hub1.getAssetAccruedFees(daiAssetId)) + ); + + minter.execute(daiAssetId); + + assertEq(minter.lastMintTime(daiAssetId), block.timestamp); + assertFalse(minter.checkExecute(daiAssetId), 'Should not be executable immediately after'); + } + + function test_execute_revertsWith_TimeIntervalNotMet() public { + FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + minTimeInterval: 7 days, + minUnrealizedFeePercent: 0 + }); + vm.prank(ADMIN); + minter.setConfig(daiAssetId, config); + + _addAndDrawLiquidity({ + hub: hub1, + assetId: daiAssetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: 1000e18, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: 100e18, + skipTime: 8 days + }); + + minter.execute(daiAssetId); // Success, sets lastMintTime = block.timestamp + + vm.warp(block.timestamp + 1 days); // Only 1 day passed, config needs 7 + + vm.expectRevert(FeeSharesMinterBase.ConditionsNotMet.selector); + minter.execute(daiAssetId); + } + + function test_execute_revertsWith_MinShareNotMet() public { + FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + minTimeInterval: 0, + minUnrealizedFeePercent: 0 + }); + vm.prank(ADMIN); + minter.setConfig(daiAssetId, config); + + // Add liquidity but NO borrow -> No fees + Utils.add(hub1, daiAssetId, address(spoke1), 1000e18, bob); + + skip(365 days); // Time passes + + uint256 accruedFees = hub1.getAssetAccruedFees(daiAssetId); + assertEq(accruedFees, 0, 'No fees should be accrued'); + + assertFalse(minter.checkExecute(daiAssetId)); + + vm.expectRevert(FeeSharesMinterBase.ConditionsNotMet.selector); + minter.execute(daiAssetId); + } + + function test_execute_revertsWith_PercentThresholdNotMet() public { + FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + minTimeInterval: 0, + minUnrealizedFeePercent: 5000 // 50% threshold + }); + vm.prank(ADMIN); + minter.setConfig(daiAssetId, config); + + _addAndDrawLiquidity({ + hub: hub1, + assetId: daiAssetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: 1000e18, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: 100e18, + skipTime: 1 days + }); + + assertFalse(minter.checkExecute(daiAssetId)); + + vm.expectRevert(FeeSharesMinterBase.ConditionsNotMet.selector); + minter.execute(daiAssetId); + } + + function test_execute_largeScalePrecision() public { + // 1 billion assets (1e9 * 1e18 = 1e27) + uint256 hugeAssets = 1_000_000_000e18; + // 1 bps of that (1e27 / 10000 = 1e23) + uint256 oneBpsFees = hugeAssets / 10000; + + // Config: 1 bps min + FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + minTimeInterval: 0, + minUnrealizedFeePercent: 1 // 1 BPS + }); + vm.prank(ADMIN); + minter.setConfig(daiAssetId, config); + + // Mock Hub calls to simulate this exact state + vm.mockCall( + address(hub1), + abi.encodeWithSelector(IHubBase.getAddedAssets.selector, daiAssetId), + abi.encode(hugeAssets) + ); + vm.mockCall( + address(hub1), + abi.encodeWithSelector(IHub.getAssetAccruedFees.selector, daiAssetId), + abi.encode(oneBpsFees) + ); + // Also mock previewAddByAssets to ensure min shares check passes (1e23 fees > 1 share) + vm.mockCall( + address(hub1), + abi.encodeWithSelector(IHubBase.previewAddByAssets.selector, daiAssetId, oneBpsFees), + abi.encode(100e18) // Just needs to be >= 1 + ); + + assertTrue(minter.checkExecute(daiAssetId), 'Should pass at exactly 1 bps'); + + // Test just below 1 bps + vm.mockCall( + address(hub1), + abi.encodeWithSelector(IHub.getAssetAccruedFees.selector, daiAssetId), + abi.encode(oneBpsFees - 1) + ); + // Mock the preview call for the new fee amount as well + vm.mockCall( + address(hub1), + abi.encodeWithSelector(IHubBase.previewAddByAssets.selector, daiAssetId, oneBpsFees - 1), + abi.encode(100e18) + ); + + assertFalse(minter.checkExecute(daiAssetId), 'Should fail just below 1 bps'); + } +} From 9ec1d118752d3c35406b28681291d76df0b9b0ff Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Mon, 16 Feb 2026 19:00:03 -0700 Subject: [PATCH 02/26] chore: Cleanup --- src/hub/FeeSharesMinterBase.sol | 56 ++++++++++-------------- tests/unit/Hub/FeeSharesMinterBase.t.sol | 6 --- 2 files changed, 23 insertions(+), 39 deletions(-) diff --git a/src/hub/FeeSharesMinterBase.sol b/src/hub/FeeSharesMinterBase.sol index ce7680089..ad89e7284 100644 --- a/src/hub/FeeSharesMinterBase.sol +++ b/src/hub/FeeSharesMinterBase.sol @@ -1,13 +1,13 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs pragma solidity ^0.8.0; import {IHub} from 'src/hub/interfaces/IHub.sol'; import {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol'; -/** - * @title FeeSharesMinterBase - * @notice Contract to mint fee shares on the Hub when specific conditions are met. - */ +/// @title FeeSharesMinterBase +/// @author Aave Labs +/// @notice Contract to mint fee shares on the Hub when specific conditions are met. contract FeeSharesMinterBase is Ownable { struct MintConfig { uint256 minTimeInterval; @@ -20,62 +20,52 @@ contract FeeSharesMinterBase is Ownable { mapping(uint256 => uint256) public lastMintTime; event ConfigUpdated(uint256 indexed assetId, MintConfig config); - event FeeSharesMinted(uint256 indexed assetId, uint256 shares); error ConditionsNotMet(); + /// @dev Constructor. + /// @param owner The owner of the contract. + /// @param hub The hub contract. constructor(address owner, IHub hub) Ownable(owner) { HUB = hub; } - /** - * @notice Sets the automation configuration for a specific asset. - * @param assetId The identifier of the asset. - * @param config The new configuration. - */ + /// @notice Sets the automation configuration for a specific asset. + /// @param assetId The identifier of the asset. + /// @param config The new configuration. function setConfig(uint256 assetId, MintConfig memory config) external onlyOwner { _configs[assetId] = config; emit ConfigUpdated(assetId, config); } - /** - * @notice Executes the fee minting if conditions are met. - * @param assetId The identifier of the asset. - */ + /// @notice Executes the fee minting if conditions are met. + /// @param assetId The identifier of the asset. function execute(uint256 assetId) external { if (!_checkExecute(assetId)) { revert ConditionsNotMet(); } lastMintTime[assetId] = block.timestamp; - - uint256 shares = HUB.mintFeeShares(assetId); - emit FeeSharesMinted(assetId, shares); + HUB.mintFeeShares(assetId); } - /** - * @notice Returns the automation configuration for a specific asset. - * @param assetId The identifier of the asset. - * @return The configuration struct. - */ + /// @notice Returns the automation configuration for a specific asset. + /// @param assetId The identifier of the asset. + /// @return The configuration struct. function getConfig(uint256 assetId) external view returns (MintConfig memory) { return _configs[assetId]; } - /** - * @notice Checks if the conditions to mint fee shares are met. - * @param assetId The identifier of the asset. - * @return True if conditions are met, false otherwise. - */ + /// @notice Checks if the conditions to mint fee shares are met. + /// @param assetId The identifier of the asset. + /// @return True if conditions are met, false otherwise. function checkExecute(uint256 assetId) external view returns (bool) { return _checkExecute(assetId); } - /** - * @dev Internal function to check execution conditions. - * @param assetId The identifier of the asset. - * @return True if conditions are met, false otherwise. - */ + /// @dev Internal function to check execution conditions. + /// @param assetId The identifier of the asset. + /// @return True if conditions are met, false otherwise. function _checkExecute(uint256 assetId) internal view returns (bool) { MintConfig memory config = _configs[assetId]; diff --git a/tests/unit/Hub/FeeSharesMinterBase.t.sol b/tests/unit/Hub/FeeSharesMinterBase.t.sol index 145a4f8d9..76361530e 100644 --- a/tests/unit/Hub/FeeSharesMinterBase.t.sol +++ b/tests/unit/Hub/FeeSharesMinterBase.t.sol @@ -51,12 +51,6 @@ contract FeeSharesMinterBaseTest is HubBase { assertTrue(minter.checkExecute(daiAssetId), 'Should be executable'); - vm.expectEmit(address(minter)); - emit FeeSharesMinterBase.FeeSharesMinted( - daiAssetId, - hub1.previewAddByAssets(daiAssetId, hub1.getAssetAccruedFees(daiAssetId)) - ); - minter.execute(daiAssetId); assertEq(minter.lastMintTime(daiAssetId), block.timestamp); From 4dc1448a2d4dbe81a0ac64b6799636ceca31b1cc Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Mon, 16 Feb 2026 19:11:04 -0700 Subject: [PATCH 03/26] feat: Make contract usable with all hubs --- src/hub/FeeSharesMinterBase.sol | 53 ++++++++++++------------ tests/unit/Hub/FeeSharesMinterBase.t.sol | 41 +++++++++--------- 2 files changed, 49 insertions(+), 45 deletions(-) diff --git a/src/hub/FeeSharesMinterBase.sol b/src/hub/FeeSharesMinterBase.sol index ad89e7284..d5d029472 100644 --- a/src/hub/FeeSharesMinterBase.sol +++ b/src/hub/FeeSharesMinterBase.sol @@ -14,69 +14,70 @@ contract FeeSharesMinterBase is Ownable { uint256 minUnrealizedFeePercent; // 1e4 = 100% (basis points) } - IHub public immutable HUB; + mapping(address => mapping(uint256 => MintConfig)) internal _configs; + mapping(address => mapping(uint256 => uint256)) public lastMintTime; - mapping(uint256 => MintConfig) internal _configs; - mapping(uint256 => uint256) public lastMintTime; - - event ConfigUpdated(uint256 indexed assetId, MintConfig config); + event ConfigUpdated(address indexed hub, uint256 indexed assetId, MintConfig config); error ConditionsNotMet(); /// @dev Constructor. /// @param owner The owner of the contract. - /// @param hub The hub contract. - constructor(address owner, IHub hub) Ownable(owner) { - HUB = hub; - } + constructor(address owner) Ownable(owner) {} /// @notice Sets the automation configuration for a specific asset. + /// @param hub The address of the hub. /// @param assetId The identifier of the asset. /// @param config The new configuration. - function setConfig(uint256 assetId, MintConfig memory config) external onlyOwner { - _configs[assetId] = config; - emit ConfigUpdated(assetId, config); + function setConfig(address hub, uint256 assetId, MintConfig memory config) external onlyOwner { + _configs[hub][assetId] = config; + emit ConfigUpdated(hub, assetId, config); } /// @notice Executes the fee minting if conditions are met. + /// @param hub The address of the hub. /// @param assetId The identifier of the asset. - function execute(uint256 assetId) external { - if (!_checkExecute(assetId)) { + function execute(address hub, uint256 assetId) external { + if (!_checkExecute(hub, assetId)) { revert ConditionsNotMet(); } - lastMintTime[assetId] = block.timestamp; - HUB.mintFeeShares(assetId); + lastMintTime[hub][assetId] = block.timestamp; + IHub(hub).mintFeeShares(assetId); } /// @notice Returns the automation configuration for a specific asset. + /// @param hub The address of the hub. /// @param assetId The identifier of the asset. /// @return The configuration struct. - function getConfig(uint256 assetId) external view returns (MintConfig memory) { - return _configs[assetId]; + function getConfig(address hub, uint256 assetId) external view returns (MintConfig memory) { + return _configs[hub][assetId]; } /// @notice Checks if the conditions to mint fee shares are met. + /// @param hub The address of the hub. /// @param assetId The identifier of the asset. /// @return True if conditions are met, false otherwise. - function checkExecute(uint256 assetId) external view returns (bool) { - return _checkExecute(assetId); + function checkExecute(address hub, uint256 assetId) external view returns (bool) { + return _checkExecute(hub, assetId); } /// @dev Internal function to check execution conditions. + /// @param hub The address of the hub. /// @param assetId The identifier of the asset. /// @return True if conditions are met, false otherwise. - function _checkExecute(uint256 assetId) internal view returns (bool) { - MintConfig memory config = _configs[assetId]; + function _checkExecute(address hub, uint256 assetId) internal view returns (bool) { + MintConfig memory config = _configs[hub][assetId]; // Check mint interval - if (block.timestamp - lastMintTime[assetId] < config.minTimeInterval) { + if (block.timestamp - lastMintTime[hub][assetId] < config.minTimeInterval) { return false; } - uint256 accruedFees = HUB.getAssetAccruedFees(assetId); + IHub hubContract = IHub(hub); + uint256 accruedFees = hubContract.getAssetAccruedFees(assetId); - uint256 totalAddedAssets = HUB.getAddedAssets(assetId); + uint256 totalAddedAssets = hubContract.getAddedAssets(assetId); if (totalAddedAssets == 0) return false; // Check if accruedFees / totalAddedAssets >= minUnrealizedFeePercent (in bps) @@ -85,7 +86,7 @@ contract FeeSharesMinterBase is Ownable { } // Ensure at least 1 fee share is minted - uint256 expectedShares = HUB.previewAddByAssets(assetId, accruedFees); + uint256 expectedShares = hubContract.previewAddByAssets(assetId, accruedFees); if (expectedShares < 1) { return false; } diff --git a/tests/unit/Hub/FeeSharesMinterBase.t.sol b/tests/unit/Hub/FeeSharesMinterBase.t.sol index 76361530e..eb36ec0cd 100644 --- a/tests/unit/Hub/FeeSharesMinterBase.t.sol +++ b/tests/unit/Hub/FeeSharesMinterBase.t.sol @@ -9,7 +9,7 @@ contract FeeSharesMinterBaseTest is HubBase { function setUp() public override { super.setUp(); - minter = new FeeSharesMinterBase(ADMIN, hub1); + minter = new FeeSharesMinterBase(ADMIN); // Grant minter the HUB_ADMIN_ROLE so it can call mintFeeShares vm.prank(ADMIN); @@ -24,7 +24,7 @@ contract FeeSharesMinterBaseTest is HubBase { vm.prank(bob); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, bob)); - minter.setConfig(daiAssetId, config); + minter.setConfig(address(hub1), daiAssetId, config); } function test_execute_success() public { @@ -33,7 +33,7 @@ contract FeeSharesMinterBaseTest is HubBase { minUnrealizedFeePercent: 10 // 0.1% }); vm.prank(ADMIN); - minter.setConfig(daiAssetId, config); + minter.setConfig(address(hub1), daiAssetId, config); // Generate fees // Add 1000 DAI, borrow 100 DAI @@ -49,12 +49,15 @@ contract FeeSharesMinterBaseTest is HubBase { skipTime: 365 days // Skip enough time for interval and fee accrual }); - assertTrue(minter.checkExecute(daiAssetId), 'Should be executable'); + assertTrue(minter.checkExecute(address(hub1), daiAssetId), 'Should be executable'); - minter.execute(daiAssetId); + minter.execute(address(hub1), daiAssetId); - assertEq(minter.lastMintTime(daiAssetId), block.timestamp); - assertFalse(minter.checkExecute(daiAssetId), 'Should not be executable immediately after'); + assertEq(minter.lastMintTime(address(hub1), daiAssetId), block.timestamp); + assertFalse( + minter.checkExecute(address(hub1), daiAssetId), + 'Should not be executable immediately after' + ); } function test_execute_revertsWith_TimeIntervalNotMet() public { @@ -63,7 +66,7 @@ contract FeeSharesMinterBaseTest is HubBase { minUnrealizedFeePercent: 0 }); vm.prank(ADMIN); - minter.setConfig(daiAssetId, config); + minter.setConfig(address(hub1), daiAssetId, config); _addAndDrawLiquidity({ hub: hub1, @@ -77,12 +80,12 @@ contract FeeSharesMinterBaseTest is HubBase { skipTime: 8 days }); - minter.execute(daiAssetId); // Success, sets lastMintTime = block.timestamp + minter.execute(address(hub1), daiAssetId); // Success, sets lastMintTime = block.timestamp vm.warp(block.timestamp + 1 days); // Only 1 day passed, config needs 7 vm.expectRevert(FeeSharesMinterBase.ConditionsNotMet.selector); - minter.execute(daiAssetId); + minter.execute(address(hub1), daiAssetId); } function test_execute_revertsWith_MinShareNotMet() public { @@ -91,7 +94,7 @@ contract FeeSharesMinterBaseTest is HubBase { minUnrealizedFeePercent: 0 }); vm.prank(ADMIN); - minter.setConfig(daiAssetId, config); + minter.setConfig(address(hub1), daiAssetId, config); // Add liquidity but NO borrow -> No fees Utils.add(hub1, daiAssetId, address(spoke1), 1000e18, bob); @@ -101,10 +104,10 @@ contract FeeSharesMinterBaseTest is HubBase { uint256 accruedFees = hub1.getAssetAccruedFees(daiAssetId); assertEq(accruedFees, 0, 'No fees should be accrued'); - assertFalse(minter.checkExecute(daiAssetId)); + assertFalse(minter.checkExecute(address(hub1), daiAssetId)); vm.expectRevert(FeeSharesMinterBase.ConditionsNotMet.selector); - minter.execute(daiAssetId); + minter.execute(address(hub1), daiAssetId); } function test_execute_revertsWith_PercentThresholdNotMet() public { @@ -113,7 +116,7 @@ contract FeeSharesMinterBaseTest is HubBase { minUnrealizedFeePercent: 5000 // 50% threshold }); vm.prank(ADMIN); - minter.setConfig(daiAssetId, config); + minter.setConfig(address(hub1), daiAssetId, config); _addAndDrawLiquidity({ hub: hub1, @@ -127,10 +130,10 @@ contract FeeSharesMinterBaseTest is HubBase { skipTime: 1 days }); - assertFalse(minter.checkExecute(daiAssetId)); + assertFalse(minter.checkExecute(address(hub1), daiAssetId)); vm.expectRevert(FeeSharesMinterBase.ConditionsNotMet.selector); - minter.execute(daiAssetId); + minter.execute(address(hub1), daiAssetId); } function test_execute_largeScalePrecision() public { @@ -145,7 +148,7 @@ contract FeeSharesMinterBaseTest is HubBase { minUnrealizedFeePercent: 1 // 1 BPS }); vm.prank(ADMIN); - minter.setConfig(daiAssetId, config); + minter.setConfig(address(hub1), daiAssetId, config); // Mock Hub calls to simulate this exact state vm.mockCall( @@ -165,7 +168,7 @@ contract FeeSharesMinterBaseTest is HubBase { abi.encode(100e18) // Just needs to be >= 1 ); - assertTrue(minter.checkExecute(daiAssetId), 'Should pass at exactly 1 bps'); + assertTrue(minter.checkExecute(address(hub1), daiAssetId), 'Should pass at exactly 1 bps'); // Test just below 1 bps vm.mockCall( @@ -180,6 +183,6 @@ contract FeeSharesMinterBaseTest is HubBase { abi.encode(100e18) ); - assertFalse(minter.checkExecute(daiAssetId), 'Should fail just below 1 bps'); + assertFalse(minter.checkExecute(address(hub1), daiAssetId), 'Should fail just below 1 bps'); } } From 0f6dab435aa83d5a16203609ee7411af2e67ec45 Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Tue, 17 Feb 2026 13:51:06 -0700 Subject: [PATCH 04/26] fix: Pr comments --- src/hub/FeeSharesMinterBase.sol | 31 +++-- tests/unit/Hub/FeeSharesMinterBase.t.sol | 142 +++++++++++++++++++++++ 2 files changed, 164 insertions(+), 9 deletions(-) diff --git a/src/hub/FeeSharesMinterBase.sol b/src/hub/FeeSharesMinterBase.sol index d5d029472..fae969254 100644 --- a/src/hub/FeeSharesMinterBase.sol +++ b/src/hub/FeeSharesMinterBase.sol @@ -2,24 +2,30 @@ // Copyright (c) 2025 Aave Labs pragma solidity ^0.8.0; -import {IHub} from 'src/hub/interfaces/IHub.sol'; import {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol'; +import {Ownable2Step} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; +import {Rescuable} from 'src/utils/Rescuable.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; /// @title FeeSharesMinterBase /// @author Aave Labs /// @notice Contract to mint fee shares on the Hub when specific conditions are met. -contract FeeSharesMinterBase is Ownable { +contract FeeSharesMinterBase is Ownable2Step, Rescuable { struct MintConfig { uint256 minTimeInterval; - uint256 minUnrealizedFeePercent; // 1e4 = 100% (basis points) + uint16 minUnrealizedFeePercent; // 1e4 = 100% (basis points) } + uint256 public constant MAX_BPS = 1e4; + uint256 public constant MAX_TIME_INTERVAL = 365 days; + mapping(address => mapping(uint256 => MintConfig)) internal _configs; mapping(address => mapping(uint256 => uint256)) public lastMintTime; event ConfigUpdated(address indexed hub, uint256 indexed assetId, MintConfig config); error ConditionsNotMet(); + error InvalidConfig(); /// @dev Constructor. /// @param owner The owner of the contract. @@ -30,6 +36,10 @@ contract FeeSharesMinterBase is Ownable { /// @param assetId The identifier of the asset. /// @param config The new configuration. function setConfig(address hub, uint256 assetId, MintConfig memory config) external onlyOwner { + require( + config.minUnrealizedFeePercent <= MAX_BPS && config.minTimeInterval <= MAX_TIME_INTERVAL, + InvalidConfig() + ); _configs[hub][assetId] = config; emit ConfigUpdated(hub, assetId, config); } @@ -38,9 +48,7 @@ contract FeeSharesMinterBase is Ownable { /// @param hub The address of the hub. /// @param assetId The identifier of the asset. function execute(address hub, uint256 assetId) external { - if (!_checkExecute(hub, assetId)) { - revert ConditionsNotMet(); - } + require(_checkExecute(hub, assetId), ConditionsNotMet()); lastMintTime[hub][assetId] = block.timestamp; IHub(hub).mintFeeShares(assetId); @@ -66,7 +74,7 @@ contract FeeSharesMinterBase is Ownable { /// @param hub The address of the hub. /// @param assetId The identifier of the asset. /// @return True if conditions are met, false otherwise. - function _checkExecute(address hub, uint256 assetId) internal view returns (bool) { + function _checkExecute(address hub, uint256 assetId) internal view virtual returns (bool) { MintConfig memory config = _configs[hub][assetId]; // Check mint interval @@ -81,16 +89,21 @@ contract FeeSharesMinterBase is Ownable { if (totalAddedAssets == 0) return false; // Check if accruedFees / totalAddedAssets >= minUnrealizedFeePercent (in bps) - if ((accruedFees * 10000) / totalAddedAssets < config.minUnrealizedFeePercent) { + if ((accruedFees * MAX_BPS) / totalAddedAssets < config.minUnrealizedFeePercent) { return false; } // Ensure at least 1 fee share is minted uint256 expectedShares = hubContract.previewAddByAssets(assetId, accruedFees); - if (expectedShares < 1) { + if (expectedShares == 0) { return false; } return true; } + + /// @inheritdoc Rescuable + function _rescueGuardian() internal view override returns (address) { + return owner(); + } } diff --git a/tests/unit/Hub/FeeSharesMinterBase.t.sol b/tests/unit/Hub/FeeSharesMinterBase.t.sol index eb36ec0cd..d8908cb40 100644 --- a/tests/unit/Hub/FeeSharesMinterBase.t.sol +++ b/tests/unit/Hub/FeeSharesMinterBase.t.sol @@ -2,9 +2,12 @@ pragma solidity ^0.8.0; import 'tests/unit/Hub/HubBase.t.sol'; +import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; import {FeeSharesMinterBase} from 'src/hub/FeeSharesMinterBase.sol'; contract FeeSharesMinterBaseTest is HubBase { + using SafeCast for uint256; + FeeSharesMinterBase internal minter; function setUp() public override { @@ -60,6 +63,54 @@ contract FeeSharesMinterBaseTest is HubBase { ); } + function test_fuzz_execute( + uint256 addAmount, + uint256 drawAmount, + uint256 skipTime, + uint256 minTimeInterval, + uint16 minUnrealizedFeePercent + ) public { + addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); + drawAmount = bound(drawAmount, 1, addAmount / 2); + skipTime = bound(skipTime, 1, MAX_SKIP_TIME); + minTimeInterval = bound(minTimeInterval, 0, 365 days); + minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, 10000).toUint16(); + + FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + minTimeInterval: minTimeInterval, + minUnrealizedFeePercent: minUnrealizedFeePercent + }); + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, config); + + _addAndDrawLiquidity({ + hub: hub1, + assetId: daiAssetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: addAmount, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: drawAmount, + skipTime: skipTime + }); + + bool shouldExecute = minter.checkExecute(address(hub1), daiAssetId); + + if (shouldExecute) { + minter.execute(address(hub1), daiAssetId); + + assertEq(minter.lastMintTime(address(hub1), daiAssetId), block.timestamp); + assertFalse( + minter.checkExecute(address(hub1), daiAssetId), + 'Should not be executable immediately after' + ); + } else { + vm.expectRevert(FeeSharesMinterBase.ConditionsNotMet.selector); + minter.execute(address(hub1), daiAssetId); + } + } + function test_execute_revertsWith_TimeIntervalNotMet() public { FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ minTimeInterval: 7 days, @@ -185,4 +236,95 @@ contract FeeSharesMinterBaseTest is HubBase { assertFalse(minter.checkExecute(address(hub1), daiAssetId), 'Should fail just below 1 bps'); } + + function test_fuzz_setConfig_success( + uint256 minTimeInterval, + uint16 minUnrealizedFeePercent + ) public { + minTimeInterval = bound(minTimeInterval, 0, 365 days); + minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, 10000).toUint16(); + + FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + minTimeInterval: minTimeInterval, + minUnrealizedFeePercent: minUnrealizedFeePercent + }); + + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, config); + + FeeSharesMinterBase.MintConfig memory savedConfig = minter.getConfig(address(hub1), daiAssetId); + assertEq(savedConfig.minTimeInterval, minTimeInterval); + assertEq(savedConfig.minUnrealizedFeePercent, minUnrealizedFeePercent); + } + + function test_fuzz_setConfig_revertsWith_InvalidConfig_TimeInterval( + uint256 minTimeInterval + ) public { + minTimeInterval = bound(minTimeInterval, 365 days + 1, UINT256_MAX); + + FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + minTimeInterval: minTimeInterval, + minUnrealizedFeePercent: 0 + }); + + vm.prank(ADMIN); + vm.expectRevert(FeeSharesMinterBase.InvalidConfig.selector); + minter.setConfig(address(hub1), daiAssetId, config); + } + + function test_fuzz_setConfig_revertsWith_InvalidConfig_FeePercent( + uint16 minUnrealizedFeePercent + ) public { + minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 10001, type(uint16).max).toUint16(); + + FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + minTimeInterval: 0, + minUnrealizedFeePercent: minUnrealizedFeePercent + }); + + vm.prank(ADMIN); + vm.expectRevert(FeeSharesMinterBase.InvalidConfig.selector); + minter.setConfig(address(hub1), daiAssetId, config); + } + + function test_rescueToken() public { + // Mint some dummy tokens to FeeSharesMinterBase + MockERC20 token = new MockERC20(); + token.mint(address(minter), 1000e18); + + assertEq(token.balanceOf(address(minter)), 1000e18, 'Minter should have tokens'); + + // Attempt rescue by non-owner (should fail) + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(IRescuable.OnlyRescueGuardian.selector)); + minter.rescueToken(address(token), bob, 1000e18); + + // Rescue by owner (should succeed) + vm.prank(ADMIN); + minter.rescueToken(address(token), ADMIN, 1000e18); + + assertEq(token.balanceOf(address(minter)), 0, 'Minter should be empty'); + assertEq(token.balanceOf(ADMIN), 1000e18, 'Admin should have tokens'); + } + + function test_transferOwnership_2Step() public { + address newOwner = makeAddr('newOwner'); + + // Transfer ownership (starts 2-step process) + vm.prank(ADMIN); + minter.transferOwnership(newOwner); + + // Verify owner hasn't changed yet + assertEq(minter.owner(), ADMIN, 'Owner should still be ADMIN'); + // Verify pending owner + assertEq(minter.pendingOwner(), newOwner, 'Pending owner should be newOwner'); + + // Accept ownership + vm.prank(newOwner); + minter.acceptOwnership(); + + // Verify owner changed + assertEq(minter.owner(), newOwner, 'Owner should now be newOwner'); + assertEq(minter.pendingOwner(), address(0), 'Pending owner should be cleared'); + } } From 4f0492e1dcb7de06d3e65a365a03921e82857966 Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Tue, 17 Feb 2026 13:56:03 -0700 Subject: [PATCH 05/26] fix: test comment --- tests/unit/Hub/FeeSharesMinterBase.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/Hub/FeeSharesMinterBase.t.sol b/tests/unit/Hub/FeeSharesMinterBase.t.sol index d8908cb40..f5d80ef99 100644 --- a/tests/unit/Hub/FeeSharesMinterBase.t.sol +++ b/tests/unit/Hub/FeeSharesMinterBase.t.sol @@ -39,7 +39,7 @@ contract FeeSharesMinterBaseTest is HubBase { minter.setConfig(address(hub1), daiAssetId, config); // Generate fees - // Add 1000 DAI, borrow 100 DAI + // Add 1000 DAI, borrow 900 DAI _addAndDrawLiquidity({ hub: hub1, assetId: daiAssetId, From d9bfd8d055be013611a1be7b792b9526014b1c69 Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Wed, 25 Feb 2026 00:05:24 -0800 Subject: [PATCH 06/26] fix: Address pr comments --- src/{hub => utils}/FeeSharesMinterBase.sol | 54 ++++++------------- src/utils/IFeeSharesMinterBase.sol | 60 ++++++++++++++++++++++ tests/unit/Hub/FeeSharesMinterBase.t.sol | 52 ++++++++++--------- 3 files changed, 105 insertions(+), 61 deletions(-) rename src/{hub => utils}/FeeSharesMinterBase.sol (60%) create mode 100644 src/utils/IFeeSharesMinterBase.sol diff --git a/src/hub/FeeSharesMinterBase.sol b/src/utils/FeeSharesMinterBase.sol similarity index 60% rename from src/hub/FeeSharesMinterBase.sol rename to src/utils/FeeSharesMinterBase.sol index fae969254..8f4d182ef 100644 --- a/src/hub/FeeSharesMinterBase.sol +++ b/src/utils/FeeSharesMinterBase.sol @@ -2,51 +2,40 @@ // Copyright (c) 2025 Aave Labs pragma solidity ^0.8.0; -import {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol'; -import {Ownable2Step} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; +import {Ownable2Step, Ownable} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; +import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; import {Rescuable} from 'src/utils/Rescuable.sol'; +import {IFeeSharesMinterBase} from 'src/utils/IFeeSharesMinterBase.sol'; import {IHub} from 'src/hub/interfaces/IHub.sol'; /// @title FeeSharesMinterBase /// @author Aave Labs /// @notice Contract to mint fee shares on the Hub when specific conditions are met. -contract FeeSharesMinterBase is Ownable2Step, Rescuable { - struct MintConfig { - uint256 minTimeInterval; - uint16 minUnrealizedFeePercent; // 1e4 = 100% (basis points) - } - - uint256 public constant MAX_BPS = 1e4; +contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { + /// @inheritdoc IFeeSharesMinterBase uint256 public constant MAX_TIME_INTERVAL = 365 days; - mapping(address => mapping(uint256 => MintConfig)) internal _configs; + /// @inheritdoc IFeeSharesMinterBase mapping(address => mapping(uint256 => uint256)) public lastMintTime; - event ConfigUpdated(address indexed hub, uint256 indexed assetId, MintConfig config); - - error ConditionsNotMet(); - error InvalidConfig(); + mapping(address => mapping(uint256 => MintConfig)) internal _configs; /// @dev Constructor. /// @param owner The owner of the contract. constructor(address owner) Ownable(owner) {} - /// @notice Sets the automation configuration for a specific asset. - /// @param hub The address of the hub. - /// @param assetId The identifier of the asset. - /// @param config The new configuration. + /// @inheritdoc IFeeSharesMinterBase function setConfig(address hub, uint256 assetId, MintConfig memory config) external onlyOwner { require( - config.minUnrealizedFeePercent <= MAX_BPS && config.minTimeInterval <= MAX_TIME_INTERVAL, + config.minUnrealizedFeePercent <= PercentageMath.PERCENTAGE_FACTOR && + config.minTimeInterval <= MAX_TIME_INTERVAL, InvalidConfig() ); _configs[hub][assetId] = config; emit ConfigUpdated(hub, assetId, config); } - /// @notice Executes the fee minting if conditions are met. - /// @param hub The address of the hub. - /// @param assetId The identifier of the asset. + /// @inheritdoc IFeeSharesMinterBase function execute(address hub, uint256 assetId) external { require(_checkExecute(hub, assetId), ConditionsNotMet()); @@ -54,18 +43,12 @@ contract FeeSharesMinterBase is Ownable2Step, Rescuable { IHub(hub).mintFeeShares(assetId); } - /// @notice Returns the automation configuration for a specific asset. - /// @param hub The address of the hub. - /// @param assetId The identifier of the asset. - /// @return The configuration struct. + /// @inheritdoc IFeeSharesMinterBase function getConfig(address hub, uint256 assetId) external view returns (MintConfig memory) { return _configs[hub][assetId]; } - /// @notice Checks if the conditions to mint fee shares are met. - /// @param hub The address of the hub. - /// @param assetId The identifier of the asset. - /// @return True if conditions are met, false otherwise. + /// @inheritdoc IFeeSharesMinterBase function checkExecute(address hub, uint256 assetId) external view returns (bool) { return _checkExecute(hub, assetId); } @@ -84,22 +67,19 @@ contract FeeSharesMinterBase is Ownable2Step, Rescuable { IHub hubContract = IHub(hub); uint256 accruedFees = hubContract.getAssetAccruedFees(assetId); - uint256 totalAddedAssets = hubContract.getAddedAssets(assetId); - if (totalAddedAssets == 0) return false; // Check if accruedFees / totalAddedAssets >= minUnrealizedFeePercent (in bps) - if ((accruedFees * MAX_BPS) / totalAddedAssets < config.minUnrealizedFeePercent) { + if ( + PercentageMath.percentDivDown(accruedFees, totalAddedAssets) < config.minUnrealizedFeePercent + ) { return false; } // Ensure at least 1 fee share is minted uint256 expectedShares = hubContract.previewAddByAssets(assetId, accruedFees); - if (expectedShares == 0) { - return false; - } - return true; + return expectedShares > 0; } /// @inheritdoc Rescuable diff --git a/src/utils/IFeeSharesMinterBase.sol b/src/utils/IFeeSharesMinterBase.sol new file mode 100644 index 000000000..2feb9e216 --- /dev/null +++ b/src/utils/IFeeSharesMinterBase.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +/// @title IFeeSharesMinterBase +/// @author Aave Labs +/// @notice Interface for the FeeSharesMinterBase contract +interface IFeeSharesMinterBase { + /// @notice Configuration for automated fee share minting on a specific asset. + /// @param minTimeInterval Minimum number of seconds that must elapse between mint executions. + /// @param minUnrealizedFeePercent Minimum ratio of accrued fees to total assets, in bps. + struct MintConfig { + uint32 minTimeInterval; + uint16 minUnrealizedFeePercent; + } + + /// @notice Emitted when the mint configuration for an asset is updated. + /// @param hub The address of the hub. + /// @param assetId The identifier of the asset. + /// @param config The new configuration. + event ConfigUpdated(address indexed hub, uint256 indexed assetId, MintConfig config); + + /// @notice Thrown when `execute` is called but the required conditions are not met. + error ConditionsNotMet(); + + /// @notice Thrown when `setConfig` is called with invalid parameter values. + error InvalidConfig(); + + /// @notice Sets the automation configuration for a specific asset. + /// @param hub The address of the hub. + /// @param assetId The identifier of the asset. + /// @param config The new configuration to apply. + function setConfig(address hub, uint256 assetId, MintConfig memory config) external; + + /// @notice Executes fee share minting if all conditions are met. + /// @param hub The address of the hub. + /// @param assetId The identifier of the asset. + function execute(address hub, uint256 assetId) external; + + /// @notice Returns the current automation configuration for a specific asset. + /// @param hub The address of the hub. + /// @param assetId The identifier of the asset. + /// @return The stored `MintConfig` struct. + function getConfig(address hub, uint256 assetId) external view returns (MintConfig memory); + + /// @notice Returns the last timestamp at which fee shares were minted for a given asset. + /// @param hub The address of the hub. + /// @param assetId The identifier of the asset. + /// @return The block timestamp of the last successful `execute` call. + function lastMintTime(address hub, uint256 assetId) external view returns (uint256); + + /// @notice Checks whether the conditions to mint fee shares are currently met. + /// @param hub The address of the hub. + /// @param assetId The identifier of the asset. + /// @return True if `execute` would succeed, false otherwise. + function checkExecute(address hub, uint256 assetId) external view returns (bool); + + /// @notice The maximum allowed value for enforcing the elapsed time between mint executions. + function MAX_TIME_INTERVAL() external view returns (uint256); +} diff --git a/tests/unit/Hub/FeeSharesMinterBase.t.sol b/tests/unit/Hub/FeeSharesMinterBase.t.sol index f5d80ef99..3361328ef 100644 --- a/tests/unit/Hub/FeeSharesMinterBase.t.sol +++ b/tests/unit/Hub/FeeSharesMinterBase.t.sol @@ -3,7 +3,8 @@ pragma solidity ^0.8.0; import 'tests/unit/Hub/HubBase.t.sol'; import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; -import {FeeSharesMinterBase} from 'src/hub/FeeSharesMinterBase.sol'; +import {FeeSharesMinterBase} from 'src/utils/FeeSharesMinterBase.sol'; +import {IFeeSharesMinterBase} from 'src/utils/IFeeSharesMinterBase.sol'; contract FeeSharesMinterBaseTest is HubBase { using SafeCast for uint256; @@ -20,7 +21,7 @@ contract FeeSharesMinterBaseTest is HubBase { } function test_setConfig_revertsWith_OwnableUnauthorized() public { - FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 1 days, minUnrealizedFeePercent: 100 // 1% }); @@ -31,7 +32,7 @@ contract FeeSharesMinterBaseTest is HubBase { } function test_execute_success() public { - FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 1 days, minUnrealizedFeePercent: 10 // 0.1% }); @@ -67,16 +68,16 @@ contract FeeSharesMinterBaseTest is HubBase { uint256 addAmount, uint256 drawAmount, uint256 skipTime, - uint256 minTimeInterval, + uint32 minTimeInterval, uint16 minUnrealizedFeePercent ) public { addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); drawAmount = bound(drawAmount, 1, addAmount / 2); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); - minTimeInterval = bound(minTimeInterval, 0, 365 days); + minTimeInterval = bound(minTimeInterval, 0, 365 days).toUint32(); minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, 10000).toUint16(); - FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: minTimeInterval, minUnrealizedFeePercent: minUnrealizedFeePercent }); @@ -106,13 +107,13 @@ contract FeeSharesMinterBaseTest is HubBase { 'Should not be executable immediately after' ); } else { - vm.expectRevert(FeeSharesMinterBase.ConditionsNotMet.selector); + vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); minter.execute(address(hub1), daiAssetId); } } function test_execute_revertsWith_TimeIntervalNotMet() public { - FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 7 days, minUnrealizedFeePercent: 0 }); @@ -135,12 +136,12 @@ contract FeeSharesMinterBaseTest is HubBase { vm.warp(block.timestamp + 1 days); // Only 1 day passed, config needs 7 - vm.expectRevert(FeeSharesMinterBase.ConditionsNotMet.selector); + vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); minter.execute(address(hub1), daiAssetId); } function test_execute_revertsWith_MinShareNotMet() public { - FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, minUnrealizedFeePercent: 0 }); @@ -157,12 +158,12 @@ contract FeeSharesMinterBaseTest is HubBase { assertFalse(minter.checkExecute(address(hub1), daiAssetId)); - vm.expectRevert(FeeSharesMinterBase.ConditionsNotMet.selector); + vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); minter.execute(address(hub1), daiAssetId); } function test_execute_revertsWith_PercentThresholdNotMet() public { - FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, minUnrealizedFeePercent: 5000 // 50% threshold }); @@ -183,7 +184,7 @@ contract FeeSharesMinterBaseTest is HubBase { assertFalse(minter.checkExecute(address(hub1), daiAssetId)); - vm.expectRevert(FeeSharesMinterBase.ConditionsNotMet.selector); + vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); minter.execute(address(hub1), daiAssetId); } @@ -194,7 +195,7 @@ contract FeeSharesMinterBaseTest is HubBase { uint256 oneBpsFees = hugeAssets / 10000; // Config: 1 bps min - FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, minUnrealizedFeePercent: 1 // 1 BPS }); @@ -238,13 +239,13 @@ contract FeeSharesMinterBaseTest is HubBase { } function test_fuzz_setConfig_success( - uint256 minTimeInterval, + uint32 minTimeInterval, uint16 minUnrealizedFeePercent ) public { - minTimeInterval = bound(minTimeInterval, 0, 365 days); + minTimeInterval = bound(minTimeInterval, 0, 365 days).toUint32(); minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, 10000).toUint16(); - FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: minTimeInterval, minUnrealizedFeePercent: minUnrealizedFeePercent }); @@ -252,23 +253,26 @@ contract FeeSharesMinterBaseTest is HubBase { vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); - FeeSharesMinterBase.MintConfig memory savedConfig = minter.getConfig(address(hub1), daiAssetId); + IFeeSharesMinterBase.MintConfig memory savedConfig = minter.getConfig( + address(hub1), + daiAssetId + ); assertEq(savedConfig.minTimeInterval, minTimeInterval); assertEq(savedConfig.minUnrealizedFeePercent, minUnrealizedFeePercent); } function test_fuzz_setConfig_revertsWith_InvalidConfig_TimeInterval( - uint256 minTimeInterval + uint32 minTimeInterval ) public { - minTimeInterval = bound(minTimeInterval, 365 days + 1, UINT256_MAX); + minTimeInterval = bound(minTimeInterval, 365 days + 1, type(uint32).max).toUint32(); - FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: minTimeInterval, minUnrealizedFeePercent: 0 }); vm.prank(ADMIN); - vm.expectRevert(FeeSharesMinterBase.InvalidConfig.selector); + vm.expectRevert(IFeeSharesMinterBase.InvalidConfig.selector); minter.setConfig(address(hub1), daiAssetId, config); } @@ -277,13 +281,13 @@ contract FeeSharesMinterBaseTest is HubBase { ) public { minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 10001, type(uint16).max).toUint16(); - FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, minUnrealizedFeePercent: minUnrealizedFeePercent }); vm.prank(ADMIN); - vm.expectRevert(FeeSharesMinterBase.InvalidConfig.selector); + vm.expectRevert(IFeeSharesMinterBase.InvalidConfig.selector); minter.setConfig(address(hub1), daiAssetId, config); } From ba91946762a1d286c053de7a7a555b570d5f27a0 Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Wed, 25 Feb 2026 21:43:16 -0800 Subject: [PATCH 07/26] fix: address pr comments --- src/utils/FeeSharesMinterBase.sol | 2 +- src/utils/IFeeSharesMinterBase.sol | 4 ++-- tests/unit/{Hub => }/FeeSharesMinterBase.t.sol | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) rename tests/unit/{Hub => }/FeeSharesMinterBase.t.sol (99%) diff --git a/src/utils/FeeSharesMinterBase.sol b/src/utils/FeeSharesMinterBase.sol index 8f4d182ef..36a3538be 100644 --- a/src/utils/FeeSharesMinterBase.sol +++ b/src/utils/FeeSharesMinterBase.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED // Copyright (c) 2025 Aave Labs -pragma solidity ^0.8.0; +pragma solidity 0.8.28; import {Ownable2Step, Ownable} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; diff --git a/src/utils/IFeeSharesMinterBase.sol b/src/utils/IFeeSharesMinterBase.sol index 2feb9e216..f35e8c74c 100644 --- a/src/utils/IFeeSharesMinterBase.sol +++ b/src/utils/IFeeSharesMinterBase.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED // Copyright (c) 2025 Aave Labs -pragma solidity ^0.8.0; +pragma solidity 0.8.28; /// @title IFeeSharesMinterBase /// @author Aave Labs @@ -10,7 +10,7 @@ interface IFeeSharesMinterBase { /// @param minTimeInterval Minimum number of seconds that must elapse between mint executions. /// @param minUnrealizedFeePercent Minimum ratio of accrued fees to total assets, in bps. struct MintConfig { - uint32 minTimeInterval; + uint48 minTimeInterval; uint16 minUnrealizedFeePercent; } diff --git a/tests/unit/Hub/FeeSharesMinterBase.t.sol b/tests/unit/FeeSharesMinterBase.t.sol similarity index 99% rename from tests/unit/Hub/FeeSharesMinterBase.t.sol rename to tests/unit/FeeSharesMinterBase.t.sol index 3361328ef..0ce38bff9 100644 --- a/tests/unit/Hub/FeeSharesMinterBase.t.sol +++ b/tests/unit/FeeSharesMinterBase.t.sol @@ -1,4 +1,5 @@ // SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs pragma solidity ^0.8.0; import 'tests/unit/Hub/HubBase.t.sol'; From 729a05ff5f7eec5d4fffd36ef2af89b7b2b32dea Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Thu, 26 Feb 2026 15:18:09 -0800 Subject: [PATCH 08/26] feat: Integrate with chainlink automation --- .../AutomationCompatibleInterface.sol | 47 ++++ src/utils/FeeSharesMinterBase.sol | 18 ++ src/utils/IFeeSharesMinterBase.sol | 18 +- tests/unit/FeeSharesMinterBase.t.sol | 231 ++++++++++++++++++ 4 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 src/dependencies/chainlink/AutomationCompatibleInterface.sol diff --git a/src/dependencies/chainlink/AutomationCompatibleInterface.sol b/src/dependencies/chainlink/AutomationCompatibleInterface.sol new file mode 100644 index 000000000..3df531980 --- /dev/null +++ b/src/dependencies/chainlink/AutomationCompatibleInterface.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +// Imported from https://github.com/smartcontractkit/chainlink/blob/v2.22.0/contracts/src/v0.8/automation/interfaces/AutomationCompatibleInterface.sol +pragma solidity ^0.8.0; + +// solhint-disable-next-line interface-starts-with-i +interface AutomationCompatibleInterface { + /** + * @notice method that is simulated by the keepers to see if any work actually + * needs to be performed. This method does does not actually need to be + * executable, and since it is only ever simulated it can consume lots of gas. + * @dev To ensure that it is never called, you may want to add the + * cannotExecute modifier from KeeperBase to your implementation of this + * method. + * @param checkData specified in the upkeep registration so it is always the + * same for a registered upkeep. This can easily be broken down into specific + * arguments using `abi.decode`, so multiple upkeeps can be registered on the + * same contract and easily differentiated by the contract. + * @return upkeepNeeded boolean to indicate whether the keeper should call + * performUpkeep or not. + * @return performData bytes that the keeper should call performUpkeep with, if + * upkeep is needed. If you would like to encode data to decode later, try + * `abi.encode`. + */ + function checkUpkeep( + bytes calldata checkData + ) external returns (bool upkeepNeeded, bytes memory performData); + + /** + * @notice method that is actually executed by the keepers, via the registry. + * The data returned by the checkUpkeep simulation will be passed into + * this method to actually be executed. + * @dev The input to this method should not be trusted, and the caller of the + * method should not even be restricted to any single registry. Anyone should + * be able call it, and the input should be validated, there is no guarantee + * that the data passed in is the performData returned from checkUpkeep. This + * could happen due to malicious keepers, racing keepers, or simply a state + * change while the performUpkeep transaction is waiting for confirmation. + * Always validate the data passed in. + * @param performData is the data which was passed back from the checkData + * simulation. If it is encoded, it can easily be decoded into other types by + * calling `abi.decode`. This data should not be trusted, and should be + * validated against the contract's current state. + */ + function performUpkeep( + bytes calldata performData + ) external; +} diff --git a/src/utils/FeeSharesMinterBase.sol b/src/utils/FeeSharesMinterBase.sol index 36a3538be..53da3fdf7 100644 --- a/src/utils/FeeSharesMinterBase.sol +++ b/src/utils/FeeSharesMinterBase.sol @@ -43,6 +43,24 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { IHub(hub).mintFeeShares(assetId); } + /// @inheritdoc IFeeSharesMinterBase + function performUpkeep(bytes calldata performData) external override { + (address hub, uint256 assetId) = abi.decode(performData, (address, uint256)); + require(_checkExecute(hub, assetId), ConditionsNotMet()); + + lastMintTime[hub][assetId] = block.timestamp; + IHub(hub).mintFeeShares(assetId); + } + + /// @inheritdoc IFeeSharesMinterBase + function checkUpkeep( + bytes calldata checkData + ) external view override returns (bool upkeepNeeded, bytes memory performData) { + (address hub, uint256 assetId) = abi.decode(checkData, (address, uint256)); + upkeepNeeded = _checkExecute(hub, assetId); + performData = checkData; + } + /// @inheritdoc IFeeSharesMinterBase function getConfig(address hub, uint256 assetId) external view returns (MintConfig memory) { return _configs[hub][assetId]; diff --git a/src/utils/IFeeSharesMinterBase.sol b/src/utils/IFeeSharesMinterBase.sol index f35e8c74c..68c9215ad 100644 --- a/src/utils/IFeeSharesMinterBase.sol +++ b/src/utils/IFeeSharesMinterBase.sol @@ -2,10 +2,12 @@ // Copyright (c) 2025 Aave Labs pragma solidity 0.8.28; +import {AutomationCompatibleInterface} from 'src/dependencies/chainlink/AutomationCompatibleInterface.sol'; + /// @title IFeeSharesMinterBase /// @author Aave Labs /// @notice Interface for the FeeSharesMinterBase contract -interface IFeeSharesMinterBase { +interface IFeeSharesMinterBase is AutomationCompatibleInterface { /// @notice Configuration for automated fee share minting on a specific asset. /// @param minTimeInterval Minimum number of seconds that must elapse between mint executions. /// @param minUnrealizedFeePercent Minimum ratio of accrued fees to total assets, in bps. @@ -37,6 +39,20 @@ interface IFeeSharesMinterBase { /// @param assetId The identifier of the asset. function execute(address hub, uint256 assetId) external; + /// @notice Chainlink Automation on-chain execution entry point. + /// @dev performData must be abi.encoded as (address hub, uint256 assetId). + /// Conditions are re-validated on-chain before minting. + /// @inheritdoc AutomationCompatibleInterface + function performUpkeep(bytes calldata performData) external; + + /// @notice Chainlink Automation off-chain simulation check. + /// @dev checkData must be abi.encoded as (address hub, uint256 assetId). + /// Returns upkeepNeeded=true and the same bytes as performData when conditions are met. + /// @inheritdoc AutomationCompatibleInterface + function checkUpkeep( + bytes calldata checkData + ) external view returns (bool upkeepNeeded, bytes memory performData); + /// @notice Returns the current automation configuration for a specific asset. /// @param hub The address of the hub. /// @param assetId The identifier of the asset. diff --git a/tests/unit/FeeSharesMinterBase.t.sol b/tests/unit/FeeSharesMinterBase.t.sol index 0ce38bff9..8f215e001 100644 --- a/tests/unit/FeeSharesMinterBase.t.sol +++ b/tests/unit/FeeSharesMinterBase.t.sol @@ -332,4 +332,235 @@ contract FeeSharesMinterBaseTest is HubBase { assertEq(minter.owner(), newOwner, 'Owner should now be newOwner'); assertEq(minter.pendingOwner(), address(0), 'Pending owner should be cleared'); } + + function test_checkUpkeep_returnsTrue_whenConditionsMet() public { + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ + minTimeInterval: 1 days, + minUnrealizedFeePercent: 10 // 0.1% + }); + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, config); + + _addAndDrawLiquidity({ + hub: hub1, + assetId: daiAssetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: 1000e18, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: 900e18, + skipTime: 365 days + }); + + bytes memory checkData = abi.encode(address(hub1), daiAssetId); + (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); + + assertTrue( + minter.checkExecute(address(hub1), daiAssetId), + 'checkExecute should also return true' + ); + assertTrue(upkeepNeeded, 'checkUpkeep should return true when conditions are met'); + assertEq(performData, checkData, 'performData should echo checkData'); + } + + function test_checkUpkeep_timeInterval_boundary() public { + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ + minTimeInterval: 7 days, + minUnrealizedFeePercent: 0 + }); + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, config); + + _addAndDrawLiquidity({ + hub: hub1, + assetId: daiAssetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: 1000e18, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: 100e18, + skipTime: 6 days + }); + + bytes memory checkData = abi.encode(address(hub1), daiAssetId); + (bool upkeepNeeded, ) = minter.checkUpkeep(checkData); + assertFalse( + minter.checkExecute(address(hub1), daiAssetId), + 'checkExecute should be false at 6 days' + ); + assertFalse(upkeepNeeded, 'checkUpkeep should be false at 6 days'); + + vm.warp(block.timestamp + 1 days); + + (bool upkeepNeededAfter, bytes memory performData) = minter.checkUpkeep(checkData); + assertTrue( + minter.checkExecute(address(hub1), daiAssetId), + 'checkExecute should be true at 7 days' + ); + assertTrue(upkeepNeededAfter, 'checkUpkeep should be true at 7 days'); + assertEq(performData, checkData, 'performData should echo checkData'); + } + + function test_checkUpkeep_returnsFalse_whenNoFees() public { + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ + minTimeInterval: 0, + minUnrealizedFeePercent: 0 + }); + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, config); + + // Liquidity added, but no fees accrued + Utils.add(hub1, daiAssetId, address(spoke1), 1000e18, bob); + skip(365 days); + + bytes memory checkData = abi.encode(address(hub1), daiAssetId); + (bool upkeepNeeded, ) = minter.checkUpkeep(checkData); + + assertFalse(upkeepNeeded, 'checkUpkeep should return false with no fees'); + } + + function test_performUpkeep_success() public { + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ + minTimeInterval: 1 days, + minUnrealizedFeePercent: 10 // 0.1% + }); + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, config); + + _addAndDrawLiquidity({ + hub: hub1, + assetId: daiAssetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: 1000e18, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: 900e18, + skipTime: 365 days + }); + + bytes memory performData = abi.encode(address(hub1), daiAssetId); + minter.performUpkeep(performData); + + assertEq( + minter.lastMintTime(address(hub1), daiAssetId), + block.timestamp, + 'lastMintTime should be updated' + ); + // Conditions should no longer be met (interval resets) + assertFalse( + minter.checkExecute(address(hub1), daiAssetId), + 'Should not be executable immediately after' + ); + } + + function test_performUpkeep_timeInterval_boundary() public { + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ + minTimeInterval: 7 days, + minUnrealizedFeePercent: 0 + }); + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, config); + + _addAndDrawLiquidity({ + hub: hub1, + assetId: daiAssetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: 1000e18, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: 100e18, + skipTime: 6 days + }); + + bytes memory performData = abi.encode(address(hub1), daiAssetId); + vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); + minter.performUpkeep(performData); + + vm.warp(block.timestamp + 1 days); + minter.performUpkeep(performData); + + assertEq( + minter.lastMintTime(address(hub1), daiAssetId), + block.timestamp, + 'lastMintTime should be updated' + ); + } + + function test_performUpkeep_revertsWith_ConditionsNotMet_noFees() public { + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ + minTimeInterval: 0, + minUnrealizedFeePercent: 0 + }); + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, config); + + Utils.add(hub1, daiAssetId, address(spoke1), 1000e18, bob); + skip(365 days); + + bytes memory performData = abi.encode(address(hub1), daiAssetId); + vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); + minter.performUpkeep(performData); + } + + function test_fuzz_performUpkeep_and_checkUpkeep_areConsistent( + uint256 addAmount, + uint256 drawAmount, + uint256 skipTime, + uint32 minTimeInterval, + uint16 minUnrealizedFeePercent + ) public { + addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); + drawAmount = bound(drawAmount, 1, addAmount / 2); + skipTime = bound(skipTime, 1, MAX_SKIP_TIME); + minTimeInterval = bound(minTimeInterval, 0, 365 days).toUint32(); + minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, 10000).toUint16(); + + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ + minTimeInterval: minTimeInterval, + minUnrealizedFeePercent: minUnrealizedFeePercent + }); + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, config); + + _addAndDrawLiquidity({ + hub: hub1, + assetId: daiAssetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: addAmount, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: drawAmount, + skipTime: skipTime + }); + + bytes memory checkData = abi.encode(address(hub1), daiAssetId); + (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); + + assertEq( + upkeepNeeded, + minter.checkExecute(address(hub1), daiAssetId), + 'checkUpkeep and checkExecute must be consistent' + ); + + if (upkeepNeeded) { + minter.performUpkeep(performData); + + assertEq(minter.lastMintTime(address(hub1), daiAssetId), block.timestamp); + + (bool upkeepNeededAfter, ) = minter.checkUpkeep(checkData); + assertFalse(upkeepNeededAfter, 'checkUpkeep should return false after performUpkeep'); + assertFalse( + minter.checkExecute(address(hub1), daiAssetId), + 'checkExecute should return false after performUpkeep' + ); + } else { + vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); + minter.performUpkeep(performData); + } + } } From 89d75b43d5387dad105a01a879071c8c009d4bb8 Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Thu, 26 Feb 2026 19:33:06 -0800 Subject: [PATCH 09/26] fix: test suite --- tests/unit/FeeSharesMinterBase.t.sol | 358 ++++++++++++--------------- 1 file changed, 154 insertions(+), 204 deletions(-) diff --git a/tests/unit/FeeSharesMinterBase.t.sol b/tests/unit/FeeSharesMinterBase.t.sol index 8f215e001..40fa6e854 100644 --- a/tests/unit/FeeSharesMinterBase.t.sol +++ b/tests/unit/FeeSharesMinterBase.t.sol @@ -32,37 +32,14 @@ contract FeeSharesMinterBaseTest is HubBase { minter.setConfig(address(hub1), daiAssetId, config); } - function test_execute_success() public { - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 1 days, - minUnrealizedFeePercent: 10 // 0.1% - }); - vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); - - // Generate fees - // Add 1000 DAI, borrow 900 DAI - _addAndDrawLiquidity({ - hub: hub1, - assetId: daiAssetId, - addUser: bob, - addSpoke: address(spoke1), + function test_execute() public { + test_fuzz_execute({ addAmount: 1000e18, - drawUser: bob, - drawSpoke: address(spoke1), drawAmount: 900e18, - skipTime: 365 days // Skip enough time for interval and fee accrual + skipTime: 365 days, + minTimeInterval: 1 days, + minUnrealizedFeePercent: 10 }); - - assertTrue(minter.checkExecute(address(hub1), daiAssetId), 'Should be executable'); - - minter.execute(address(hub1), daiAssetId); - - assertEq(minter.lastMintTime(address(hub1), daiAssetId), block.timestamp); - assertFalse( - minter.checkExecute(address(hub1), daiAssetId), - 'Should not be executable immediately after' - ); } function test_fuzz_execute( @@ -97,9 +74,7 @@ contract FeeSharesMinterBaseTest is HubBase { skipTime: skipTime }); - bool shouldExecute = minter.checkExecute(address(hub1), daiAssetId); - - if (shouldExecute) { + if (minter.checkExecute(address(hub1), daiAssetId)) { minter.execute(address(hub1), daiAssetId); assertEq(minter.lastMintTime(address(hub1), daiAssetId), block.timestamp); @@ -113,7 +88,7 @@ contract FeeSharesMinterBaseTest is HubBase { } } - function test_execute_revertsWith_TimeIntervalNotMet() public { + function test_execute_revertsWith_ConditionsNotMet_TimeIntervalNotMet() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 7 days, minUnrealizedFeePercent: 0 @@ -130,18 +105,22 @@ contract FeeSharesMinterBaseTest is HubBase { drawUser: bob, drawSpoke: address(spoke1), drawAmount: 100e18, - skipTime: 8 days + skipTime: 6 days }); - minter.execute(address(hub1), daiAssetId); // Success, sets lastMintTime = block.timestamp - - vm.warp(block.timestamp + 1 days); // Only 1 day passed, config needs 7 - + assertFalse(minter.checkExecute(address(hub1), daiAssetId), 'Not enough time elapsed'); vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); minter.execute(address(hub1), daiAssetId); + + vm.warp(block.timestamp + 1 days); + assertTrue(minter.checkExecute(address(hub1), daiAssetId), 'Sufficient conditions for execute'); + minter.execute(address(hub1), daiAssetId); + + assertEq(minter.lastMintTime(address(hub1), daiAssetId), block.timestamp, 'Just minted'); + assertFalse(minter.checkExecute(address(hub1), daiAssetId), 'Cannot mint again immediately'); } - function test_execute_revertsWith_MinShareNotMet() public { + function test_execute_revertsWith_ConditionsNotMet_zeroFees() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, minUnrealizedFeePercent: 0 @@ -149,10 +128,10 @@ contract FeeSharesMinterBaseTest is HubBase { vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); - // Add liquidity but NO borrow -> No fees + // Add liquidity, but no borrow, so no fees Utils.add(hub1, daiAssetId, address(spoke1), 1000e18, bob); - skip(365 days); // Time passes + skip(365 days); uint256 accruedFees = hub1.getAssetAccruedFees(daiAssetId); assertEq(accruedFees, 0, 'No fees should be accrued'); @@ -163,7 +142,7 @@ contract FeeSharesMinterBaseTest is HubBase { minter.execute(address(hub1), daiAssetId); } - function test_execute_revertsWith_PercentThresholdNotMet() public { + function test_execute_revertsWith_ConditionsNotMet_PercentThresholdNotMet() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, minUnrealizedFeePercent: 5000 // 50% threshold @@ -180,63 +159,57 @@ contract FeeSharesMinterBaseTest is HubBase { drawUser: bob, drawSpoke: address(spoke1), drawAmount: 100e18, - skipTime: 1 days + skipTime: 365 days }); + uint256 fees = hub1.getAssetAccruedFees(daiAssetId); + uint256 totalAssets = hub1.getAddedAssets(daiAssetId); + + assertGt(fees, 0, 'Fees must be nonzero'); + assertGt(hub1.previewAddByAssets(daiAssetId, fees), 0, 'At least 1 share would be minted'); + assertLt(fees, totalAssets / 2, 'Fees must be < 50% of total'); + assertFalse(minter.checkExecute(address(hub1), daiAssetId)); vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); minter.execute(address(hub1), daiAssetId); } - function test_execute_largeScalePrecision() public { - // 1 billion assets (1e9 * 1e18 = 1e27) - uint256 hugeAssets = 1_000_000_000e18; - // 1 bps of that (1e27 / 10000 = 1e23) - uint256 oneBpsFees = hugeAssets / 10000; - - // Config: 1 bps min + function test_execute_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, - minUnrealizedFeePercent: 1 // 1 BPS + minUnrealizedFeePercent: 0 }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); - // Mock Hub calls to simulate this exact state - vm.mockCall( - address(hub1), - abi.encodeWithSelector(IHubBase.getAddedAssets.selector, daiAssetId), - abi.encode(hugeAssets) - ); - vm.mockCall( - address(hub1), - abi.encodeWithSelector(IHub.getAssetAccruedFees.selector, daiAssetId), - abi.encode(oneBpsFees) - ); - // Also mock previewAddByAssets to ensure min shares check passes (1e23 fees > 1 share) - vm.mockCall( - address(hub1), - abi.encodeWithSelector(IHubBase.previewAddByAssets.selector, daiAssetId, oneBpsFees), - abi.encode(100e18) // Just needs to be >= 1 - ); + // Inflate exhange rate + _addAndDrawLiquidity({ + hub: hub1, + assetId: daiAssetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: 300 wei, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: 200 wei, + skipTime: MAX_SKIP_TIME - 110 days + }); + + // Clear accrued fees + minter.execute(address(hub1), daiAssetId); - assertTrue(minter.checkExecute(address(hub1), daiAssetId), 'Should pass at exactly 1 bps'); + // Accrue some fees + skip(110 days); - // Test just below 1 bps - vm.mockCall( - address(hub1), - abi.encodeWithSelector(IHub.getAssetAccruedFees.selector, daiAssetId), - abi.encode(oneBpsFees - 1) - ); - // Mock the preview call for the new fee amount as well - vm.mockCall( - address(hub1), - abi.encodeWithSelector(IHubBase.previewAddByAssets.selector, daiAssetId, oneBpsFees - 1), - abi.encode(100e18) - ); + uint256 fees = hub1.getAssetAccruedFees(daiAssetId); + assertGt(fees, 0, 'Fees must be nonzero'); + assertEq(hub1.previewAddByAssets(daiAssetId, fees), 0, 'Shares must round to zero'); + + assertFalse(minter.checkExecute(address(hub1), daiAssetId)); - assertFalse(minter.checkExecute(address(hub1), daiAssetId), 'Should fail just below 1 bps'); + vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); + minter.execute(address(hub1), daiAssetId); } function test_fuzz_setConfig_success( @@ -333,10 +306,32 @@ contract FeeSharesMinterBaseTest is HubBase { assertEq(minter.pendingOwner(), address(0), 'Pending owner should be cleared'); } - function test_checkUpkeep_returnsTrue_whenConditionsMet() public { - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ + function test_performUpkeep() public { + test_fuzz_performUpkeep({ + addAmount: 1000e18, + drawAmount: 900e18, + skipTime: 365 days, minTimeInterval: 1 days, - minUnrealizedFeePercent: 10 // 0.1% + minUnrealizedFeePercent: 10 + }); + } + + function test_fuzz_performUpkeep( + uint256 addAmount, + uint256 drawAmount, + uint256 skipTime, + uint32 minTimeInterval, + uint16 minUnrealizedFeePercent + ) public { + addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); + drawAmount = bound(drawAmount, 1, addAmount / 2); + skipTime = bound(skipTime, 1, MAX_SKIP_TIME); + minTimeInterval = bound(minTimeInterval, 0, 365 days).toUint32(); + minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, 10000).toUint16(); + + IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ + minTimeInterval: minTimeInterval, + minUnrealizedFeePercent: minUnrealizedFeePercent }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); @@ -346,25 +341,40 @@ contract FeeSharesMinterBaseTest is HubBase { assetId: daiAssetId, addUser: bob, addSpoke: address(spoke1), - addAmount: 1000e18, + addAmount: addAmount, drawUser: bob, drawSpoke: address(spoke1), - drawAmount: 900e18, - skipTime: 365 days + drawAmount: drawAmount, + skipTime: skipTime }); bytes memory checkData = abi.encode(address(hub1), daiAssetId); (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); - assertTrue( + assertEq( + upkeepNeeded, minter.checkExecute(address(hub1), daiAssetId), - 'checkExecute should also return true' + 'checkUpkeep and checkExecute must be consistent' ); - assertTrue(upkeepNeeded, 'checkUpkeep should return true when conditions are met'); - assertEq(performData, checkData, 'performData should echo checkData'); + + if (upkeepNeeded) { + minter.performUpkeep(performData); + + assertEq(minter.lastMintTime(address(hub1), daiAssetId), block.timestamp); + + (bool upkeepNeededAfter, ) = minter.checkUpkeep(checkData); + assertFalse(upkeepNeededAfter, 'checkUpkeep should return false after performUpkeep'); + assertFalse( + minter.checkExecute(address(hub1), daiAssetId), + 'checkExecute should return false after performUpkeep' + ); + } else { + vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); + minter.performUpkeep(performData); + } } - function test_checkUpkeep_timeInterval_boundary() public { + function test_performUpkeep_revertsWith_ConditionsNotMet_timeIntervalNotMet() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 7 days, minUnrealizedFeePercent: 0 @@ -385,25 +395,28 @@ contract FeeSharesMinterBaseTest is HubBase { }); bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, ) = minter.checkUpkeep(checkData); - assertFalse( - minter.checkExecute(address(hub1), daiAssetId), - 'checkExecute should be false at 6 days' - ); + (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); + assertFalse(upkeepNeeded, 'checkUpkeep should be false at 6 days'); + vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); + minter.performUpkeep(performData); vm.warp(block.timestamp + 1 days); - (bool upkeepNeededAfter, bytes memory performData) = minter.checkUpkeep(checkData); - assertTrue( - minter.checkExecute(address(hub1), daiAssetId), - 'checkExecute should be true at 7 days' - ); + (bool upkeepNeededAfter, bytes memory performDataAfter) = minter.checkUpkeep(checkData); assertTrue(upkeepNeededAfter, 'checkUpkeep should be true at 7 days'); - assertEq(performData, checkData, 'performData should echo checkData'); + minter.performUpkeep(performDataAfter); + + assertEq( + minter.lastMintTime(address(hub1), daiAssetId), + block.timestamp, + 'lastMintTime should be updated' + ); + (upkeepNeeded, ) = minter.checkUpkeep(checkData); + assertFalse(upkeepNeeded, 'checkUpkeep should be false after performUpkeep'); } - function test_checkUpkeep_returnsFalse_whenNoFees() public { + function test_performUpkeep_revertsWith_ConditionsNotMet_noFees() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, minUnrealizedFeePercent: 0 @@ -415,51 +428,22 @@ contract FeeSharesMinterBaseTest is HubBase { Utils.add(hub1, daiAssetId, address(spoke1), 1000e18, bob); skip(365 days); - bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, ) = minter.checkUpkeep(checkData); + assertEq(hub1.getAssetAccruedFees(daiAssetId), 0, 'Fees should be zero'); + bytes memory checkData = abi.encode(address(hub1), daiAssetId); + (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); assertFalse(upkeepNeeded, 'checkUpkeep should return false with no fees'); - } - - function test_performUpkeep_success() public { - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 1 days, - minUnrealizedFeePercent: 10 // 0.1% - }); - vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); - - _addAndDrawLiquidity({ - hub: hub1, - assetId: daiAssetId, - addUser: bob, - addSpoke: address(spoke1), - addAmount: 1000e18, - drawUser: bob, - drawSpoke: address(spoke1), - drawAmount: 900e18, - skipTime: 365 days - }); - bytes memory performData = abi.encode(address(hub1), daiAssetId); + vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); minter.performUpkeep(performData); - - assertEq( - minter.lastMintTime(address(hub1), daiAssetId), - block.timestamp, - 'lastMintTime should be updated' - ); - // Conditions should no longer be met (interval resets) - assertFalse( - minter.checkExecute(address(hub1), daiAssetId), - 'Should not be executable immediately after' - ); } - function test_performUpkeep_timeInterval_boundary() public { + function test_performUpkeep_revertsWith_ConditionsNotMet_percentThresholdNotMet_withMinShares() + public + { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 7 days, - minUnrealizedFeePercent: 0 + minTimeInterval: 0, + minUnrealizedFeePercent: 5000 }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); @@ -473,94 +457,60 @@ contract FeeSharesMinterBaseTest is HubBase { drawUser: bob, drawSpoke: address(spoke1), drawAmount: 100e18, - skipTime: 6 days + skipTime: 365 days }); - bytes memory performData = abi.encode(address(hub1), daiAssetId); - vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - minter.performUpkeep(performData); - - vm.warp(block.timestamp + 1 days); - minter.performUpkeep(performData); + uint256 fees = hub1.getAssetAccruedFees(daiAssetId); + uint256 totalAssets = hub1.getAddedAssets(daiAssetId); - assertEq( - minter.lastMintTime(address(hub1), daiAssetId), - block.timestamp, - 'lastMintTime should be updated' - ); - } + assertGt(fees, 0, 'Fees must be nonzero'); + assertGt(hub1.previewAddByAssets(daiAssetId, fees), 0, 'At least 1 share would be minted'); + assertLt(fees, totalAssets / 2, 'Fees must be < 50% of total'); - function test_performUpkeep_revertsWith_ConditionsNotMet_noFees() public { - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 0, - minUnrealizedFeePercent: 0 - }); - vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); - - Utils.add(hub1, daiAssetId, address(spoke1), 1000e18, bob); - skip(365 days); + bytes memory checkData = abi.encode(address(hub1), daiAssetId); + (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); + assertFalse(upkeepNeeded, 'checkUpkeep should be false: ratio below threshold'); - bytes memory performData = abi.encode(address(hub1), daiAssetId); vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); minter.performUpkeep(performData); } - function test_fuzz_performUpkeep_and_checkUpkeep_areConsistent( - uint256 addAmount, - uint256 drawAmount, - uint256 skipTime, - uint32 minTimeInterval, - uint16 minUnrealizedFeePercent - ) public { - addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); - drawAmount = bound(drawAmount, 1, addAmount / 2); - skipTime = bound(skipTime, 1, MAX_SKIP_TIME); - minTimeInterval = bound(minTimeInterval, 0, 365 days).toUint32(); - minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, 10000).toUint16(); - + function test_performUpkeep_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: minTimeInterval, - minUnrealizedFeePercent: minUnrealizedFeePercent + minTimeInterval: 0, + minUnrealizedFeePercent: 0 }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); + // Inflate exhange rate _addAndDrawLiquidity({ hub: hub1, assetId: daiAssetId, addUser: bob, addSpoke: address(spoke1), - addAmount: addAmount, + addAmount: 300 wei, drawUser: bob, drawSpoke: address(spoke1), - drawAmount: drawAmount, - skipTime: skipTime + drawAmount: 200 wei, + skipTime: MAX_SKIP_TIME - 110 days }); - bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); + // Clear accrued fees + minter.execute(address(hub1), daiAssetId); - assertEq( - upkeepNeeded, - minter.checkExecute(address(hub1), daiAssetId), - 'checkUpkeep and checkExecute must be consistent' - ); + // Accrue some fees + skip(110 days); - if (upkeepNeeded) { - minter.performUpkeep(performData); + uint256 fees = hub1.getAssetAccruedFees(daiAssetId); + assertGt(fees, 0, 'Fees must be nonzero'); + assertEq(hub1.previewAddByAssets(daiAssetId, fees), 0, 'Shares must round to zero'); - assertEq(minter.lastMintTime(address(hub1), daiAssetId), block.timestamp); + bytes memory checkData = abi.encode(address(hub1), daiAssetId); + (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); + assertFalse(upkeepNeeded, 'checkUpkeep should be false when 0 shares minted'); - (bool upkeepNeededAfter, ) = minter.checkUpkeep(checkData); - assertFalse(upkeepNeededAfter, 'checkUpkeep should return false after performUpkeep'); - assertFalse( - minter.checkExecute(address(hub1), daiAssetId), - 'checkExecute should return false after performUpkeep' - ); - } else { - vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - minter.performUpkeep(performData); - } + vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); + minter.performUpkeep(performData); } } From 521c61c0c85c4a1aa343a77a268f30c61d65115b Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Thu, 26 Feb 2026 19:44:24 -0800 Subject: [PATCH 10/26] chore: cleanup --- tests/unit/FeeSharesMinterBase.t.sol | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/unit/FeeSharesMinterBase.t.sol b/tests/unit/FeeSharesMinterBase.t.sol index 40fa6e854..99b1341af 100644 --- a/tests/unit/FeeSharesMinterBase.t.sol +++ b/tests/unit/FeeSharesMinterBase.t.sol @@ -6,6 +6,7 @@ import 'tests/unit/Hub/HubBase.t.sol'; import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; import {FeeSharesMinterBase} from 'src/utils/FeeSharesMinterBase.sol'; import {IFeeSharesMinterBase} from 'src/utils/IFeeSharesMinterBase.sol'; +import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; contract FeeSharesMinterBaseTest is HubBase { using SafeCast for uint256; @@ -52,8 +53,9 @@ contract FeeSharesMinterBaseTest is HubBase { addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); drawAmount = bound(drawAmount, 1, addAmount / 2); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); - minTimeInterval = bound(minTimeInterval, 0, 365 days).toUint32(); - minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, 10000).toUint16(); + minTimeInterval = bound(minTimeInterval, 0, minter.MAX_TIME_INTERVAL()).toUint32(); + minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, PercentageMath.PERCENTAGE_FACTOR) + .toUint16(); IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: minTimeInterval, @@ -216,8 +218,9 @@ contract FeeSharesMinterBaseTest is HubBase { uint32 minTimeInterval, uint16 minUnrealizedFeePercent ) public { - minTimeInterval = bound(minTimeInterval, 0, 365 days).toUint32(); - minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, 10000).toUint16(); + minTimeInterval = bound(minTimeInterval, 0, minter.MAX_TIME_INTERVAL()).toUint32(); + minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, PercentageMath.PERCENTAGE_FACTOR) + .toUint16(); IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: minTimeInterval, @@ -238,7 +241,8 @@ contract FeeSharesMinterBaseTest is HubBase { function test_fuzz_setConfig_revertsWith_InvalidConfig_TimeInterval( uint32 minTimeInterval ) public { - minTimeInterval = bound(minTimeInterval, 365 days + 1, type(uint32).max).toUint32(); + minTimeInterval = bound(minTimeInterval, minter.MAX_TIME_INTERVAL() + 1, type(uint32).max) + .toUint32(); IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: minTimeInterval, @@ -253,7 +257,11 @@ contract FeeSharesMinterBaseTest is HubBase { function test_fuzz_setConfig_revertsWith_InvalidConfig_FeePercent( uint16 minUnrealizedFeePercent ) public { - minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 10001, type(uint16).max).toUint16(); + minUnrealizedFeePercent = bound( + minUnrealizedFeePercent, + PercentageMath.PERCENTAGE_FACTOR + 1, + type(uint16).max + ).toUint16(); IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, @@ -326,8 +334,9 @@ contract FeeSharesMinterBaseTest is HubBase { addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); drawAmount = bound(drawAmount, 1, addAmount / 2); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); - minTimeInterval = bound(minTimeInterval, 0, 365 days).toUint32(); - minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, 10000).toUint16(); + minTimeInterval = bound(minTimeInterval, 0, minter.MAX_TIME_INTERVAL()).toUint32(); + minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, PercentageMath.PERCENTAGE_FACTOR) + .toUint16(); IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: minTimeInterval, From 3a54457105ba66132a4ae46563731a704b35e7b4 Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Sat, 28 Feb 2026 15:15:19 -0800 Subject: [PATCH 11/26] fix: Cleanup natspec --- src/utils/FeeSharesMinterBase.sol | 2 +- src/utils/IFeeSharesMinterBase.sol | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/utils/FeeSharesMinterBase.sol b/src/utils/FeeSharesMinterBase.sol index 53da3fdf7..cb3942044 100644 --- a/src/utils/FeeSharesMinterBase.sol +++ b/src/utils/FeeSharesMinterBase.sol @@ -94,7 +94,7 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { return false; } - // Ensure at least 1 fee share is minted + // Ensure at least 1 fee share would be minted uint256 expectedShares = hubContract.previewAddByAssets(assetId, accruedFees); return expectedShares > 0; diff --git a/src/utils/IFeeSharesMinterBase.sol b/src/utils/IFeeSharesMinterBase.sol index 68c9215ad..5adec9f42 100644 --- a/src/utils/IFeeSharesMinterBase.sol +++ b/src/utils/IFeeSharesMinterBase.sol @@ -22,7 +22,7 @@ interface IFeeSharesMinterBase is AutomationCompatibleInterface { /// @param config The new configuration. event ConfigUpdated(address indexed hub, uint256 indexed assetId, MintConfig config); - /// @notice Thrown when `execute` is called but the required conditions are not met. + /// @notice Thrown upon minting when the required conditions are not met. error ConditionsNotMet(); /// @notice Thrown when `setConfig` is called with invalid parameter values. @@ -41,13 +41,12 @@ interface IFeeSharesMinterBase is AutomationCompatibleInterface { /// @notice Chainlink Automation on-chain execution entry point. /// @dev performData must be abi.encoded as (address hub, uint256 assetId). - /// Conditions are re-validated on-chain before minting. /// @inheritdoc AutomationCompatibleInterface function performUpkeep(bytes calldata performData) external; /// @notice Chainlink Automation off-chain simulation check. /// @dev checkData must be abi.encoded as (address hub, uint256 assetId). - /// Returns upkeepNeeded=true and the same bytes as performData when conditions are met. + /// @dev Returns upkeepNeeded=true and the same bytes as performData when conditions are met. /// @inheritdoc AutomationCompatibleInterface function checkUpkeep( bytes calldata checkData From 05bcd6c9a015e70e421d6a50fa3a50db8d87b2af Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Tue, 3 Mar 2026 19:14:58 -0800 Subject: [PATCH 12/26] fix: typo --- tests/unit/FeeSharesMinterBase.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/FeeSharesMinterBase.t.sol b/tests/unit/FeeSharesMinterBase.t.sol index 99b1341af..797190233 100644 --- a/tests/unit/FeeSharesMinterBase.t.sol +++ b/tests/unit/FeeSharesMinterBase.t.sol @@ -185,7 +185,7 @@ contract FeeSharesMinterBaseTest is HubBase { vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); - // Inflate exhange rate + // Inflate exchange rate _addAndDrawLiquidity({ hub: hub1, assetId: daiAssetId, @@ -492,7 +492,7 @@ contract FeeSharesMinterBaseTest is HubBase { vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); - // Inflate exhange rate + // Inflate exchange rate _addAndDrawLiquidity({ hub: hub1, assetId: daiAssetId, From d6c0ac0ec00ec4f939628131c0d4ddca03502b8c Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Fri, 27 Mar 2026 17:48:50 -0700 Subject: [PATCH 13/26] fix: Address pr comments --- src/utils/FeeSharesMinterBase.sol | 37 +++++++------ src/utils/IFeeSharesMinterBase.sol | 4 +- .../contracts/utils/FeeSharesMinterBase.t.sol | 52 +++++++++---------- 3 files changed, 49 insertions(+), 44 deletions(-) diff --git a/src/utils/FeeSharesMinterBase.sol b/src/utils/FeeSharesMinterBase.sol index cb3942044..e5158af09 100644 --- a/src/utils/FeeSharesMinterBase.sol +++ b/src/utils/FeeSharesMinterBase.sol @@ -16,9 +16,9 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { uint256 public constant MAX_TIME_INTERVAL = 365 days; /// @inheritdoc IFeeSharesMinterBase - mapping(address => mapping(uint256 => uint256)) public lastMintTime; + mapping(address hub => mapping(uint256 assetId => uint256)) public lastMintTime; - mapping(address => mapping(uint256 => MintConfig)) internal _configs; + mapping(address hub => mapping(uint256 assetId => MintConfig)) internal _configs; /// @dev Constructor. /// @param owner The owner of the contract. @@ -27,7 +27,7 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { /// @inheritdoc IFeeSharesMinterBase function setConfig(address hub, uint256 assetId, MintConfig memory config) external onlyOwner { require( - config.minUnrealizedFeePercent <= PercentageMath.PERCENTAGE_FACTOR && + config.minAccruedFeesPercent <= PercentageMath.PERCENTAGE_FACTOR && config.minTimeInterval <= MAX_TIME_INTERVAL, InvalidConfig() ); @@ -37,28 +37,23 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { /// @inheritdoc IFeeSharesMinterBase function execute(address hub, uint256 assetId) external { - require(_checkExecute(hub, assetId), ConditionsNotMet()); - - lastMintTime[hub][assetId] = block.timestamp; - IHub(hub).mintFeeShares(assetId); + _execute(hub, assetId); } /// @inheritdoc IFeeSharesMinterBase function performUpkeep(bytes calldata performData) external override { (address hub, uint256 assetId) = abi.decode(performData, (address, uint256)); - require(_checkExecute(hub, assetId), ConditionsNotMet()); - - lastMintTime[hub][assetId] = block.timestamp; - IHub(hub).mintFeeShares(assetId); + _execute(hub, assetId); } /// @inheritdoc IFeeSharesMinterBase function checkUpkeep( bytes calldata checkData - ) external view override returns (bool upkeepNeeded, bytes memory performData) { + ) external view override returns (bool, bytes memory) { (address hub, uint256 assetId) = abi.decode(checkData, (address, uint256)); - upkeepNeeded = _checkExecute(hub, assetId); - performData = checkData; + bool upkeepNeeded = _checkExecute(hub, assetId); + bytes memory performData = checkData; + return (upkeepNeeded, performData); } /// @inheritdoc IFeeSharesMinterBase @@ -71,6 +66,16 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { return _checkExecute(hub, assetId); } + /// @dev Internal function to execute fee share minting. + /// @param hub The address of the hub. + /// @param assetId The identifier of the asset. + function _execute(address hub, uint256 assetId) internal virtual { + require(_checkExecute(hub, assetId), ConditionsNotMet()); + + lastMintTime[hub][assetId] = block.timestamp; + IHub(hub).mintFeeShares(assetId); + } + /// @dev Internal function to check execution conditions. /// @param hub The address of the hub. /// @param assetId The identifier of the asset. @@ -87,9 +92,9 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { uint256 accruedFees = hubContract.getAssetAccruedFees(assetId); uint256 totalAddedAssets = hubContract.getAddedAssets(assetId); - // Check if accruedFees / totalAddedAssets >= minUnrealizedFeePercent (in bps) + // Check if accruedFees / totalAddedAssets >= minAccruedFeesPercent (in BPS) if ( - PercentageMath.percentDivDown(accruedFees, totalAddedAssets) < config.minUnrealizedFeePercent + PercentageMath.percentDivDown(accruedFees, totalAddedAssets) < config.minAccruedFeesPercent ) { return false; } diff --git a/src/utils/IFeeSharesMinterBase.sol b/src/utils/IFeeSharesMinterBase.sol index 5adec9f42..6f2dbcf47 100644 --- a/src/utils/IFeeSharesMinterBase.sol +++ b/src/utils/IFeeSharesMinterBase.sol @@ -10,10 +10,10 @@ import {AutomationCompatibleInterface} from 'src/dependencies/chainlink/Automati interface IFeeSharesMinterBase is AutomationCompatibleInterface { /// @notice Configuration for automated fee share minting on a specific asset. /// @param minTimeInterval Minimum number of seconds that must elapse between mint executions. - /// @param minUnrealizedFeePercent Minimum ratio of accrued fees to total assets, in bps. + /// @param minAccruedFeesPercent Minimum ratio of accrued fees to total assets, in bps. struct MintConfig { uint48 minTimeInterval; - uint16 minUnrealizedFeePercent; + uint16 minAccruedFeesPercent; } /// @notice Emitted when the mint configuration for an asset is updated. diff --git a/tests/contracts/utils/FeeSharesMinterBase.t.sol b/tests/contracts/utils/FeeSharesMinterBase.t.sol index eb1ca2973..d77280839 100644 --- a/tests/contracts/utils/FeeSharesMinterBase.t.sol +++ b/tests/contracts/utils/FeeSharesMinterBase.t.sol @@ -23,7 +23,7 @@ contract FeeSharesMinterBaseTest is Base { function test_setConfig_revertsWith_OwnableUnauthorized() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 1 days, - minUnrealizedFeePercent: 100 // 1% + minAccruedFeesPercent: 100 // 1% }); vm.prank(bob); @@ -37,7 +37,7 @@ contract FeeSharesMinterBaseTest is Base { drawAmount: 900e18, skipTime: 365 days, minTimeInterval: 1 days, - minUnrealizedFeePercent: 10 + minAccruedFeesPercent: 10 }); } @@ -46,18 +46,18 @@ contract FeeSharesMinterBaseTest is Base { uint256 drawAmount, uint256 skipTime, uint32 minTimeInterval, - uint16 minUnrealizedFeePercent + uint16 minAccruedFeesPercent ) public { addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); drawAmount = bound(drawAmount, 1, addAmount / 2); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); minTimeInterval = bound(minTimeInterval, 0, minter.MAX_TIME_INTERVAL()).toUint32(); - minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, PercentageMath.PERCENTAGE_FACTOR) + minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: minTimeInterval, - minUnrealizedFeePercent: minUnrealizedFeePercent + minAccruedFeesPercent: minAccruedFeesPercent }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); @@ -91,7 +91,7 @@ contract FeeSharesMinterBaseTest is Base { function test_execute_revertsWith_ConditionsNotMet_TimeIntervalNotMet() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 7 days, - minUnrealizedFeePercent: 0 + minAccruedFeesPercent: 0 }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); @@ -123,7 +123,7 @@ contract FeeSharesMinterBaseTest is Base { function test_execute_revertsWith_ConditionsNotMet_zeroFees() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, - minUnrealizedFeePercent: 0 + minAccruedFeesPercent: 0 }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); @@ -151,7 +151,7 @@ contract FeeSharesMinterBaseTest is Base { function test_execute_revertsWith_ConditionsNotMet_PercentThresholdNotMet() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, - minUnrealizedFeePercent: 5000 // 50% threshold + minAccruedFeesPercent: 5000 // 50% threshold }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); @@ -184,7 +184,7 @@ contract FeeSharesMinterBaseTest is Base { function test_execute_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, - minUnrealizedFeePercent: 0 + minAccruedFeesPercent: 0 }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); @@ -220,15 +220,15 @@ contract FeeSharesMinterBaseTest is Base { function test_fuzz_setConfig_success( uint32 minTimeInterval, - uint16 minUnrealizedFeePercent + uint16 minAccruedFeesPercent ) public { minTimeInterval = bound(minTimeInterval, 0, minter.MAX_TIME_INTERVAL()).toUint32(); - minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, PercentageMath.PERCENTAGE_FACTOR) + minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: minTimeInterval, - minUnrealizedFeePercent: minUnrealizedFeePercent + minAccruedFeesPercent: minAccruedFeesPercent }); vm.prank(ADMIN); @@ -239,7 +239,7 @@ contract FeeSharesMinterBaseTest is Base { daiAssetId ); assertEq(savedConfig.minTimeInterval, minTimeInterval); - assertEq(savedConfig.minUnrealizedFeePercent, minUnrealizedFeePercent); + assertEq(savedConfig.minAccruedFeesPercent, minAccruedFeesPercent); } function test_fuzz_setConfig_revertsWith_InvalidConfig_TimeInterval( @@ -250,7 +250,7 @@ contract FeeSharesMinterBaseTest is Base { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: minTimeInterval, - minUnrealizedFeePercent: 0 + minAccruedFeesPercent: 0 }); vm.prank(ADMIN); @@ -259,17 +259,17 @@ contract FeeSharesMinterBaseTest is Base { } function test_fuzz_setConfig_revertsWith_InvalidConfig_FeePercent( - uint16 minUnrealizedFeePercent + uint16 minAccruedFeesPercent ) public { - minUnrealizedFeePercent = bound( - minUnrealizedFeePercent, + minAccruedFeesPercent = bound( + minAccruedFeesPercent, PercentageMath.PERCENTAGE_FACTOR + 1, type(uint16).max ).toUint16(); IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, - minUnrealizedFeePercent: minUnrealizedFeePercent + minAccruedFeesPercent: minAccruedFeesPercent }); vm.prank(ADMIN); @@ -324,7 +324,7 @@ contract FeeSharesMinterBaseTest is Base { drawAmount: 900e18, skipTime: 365 days, minTimeInterval: 1 days, - minUnrealizedFeePercent: 10 + minAccruedFeesPercent: 10 }); } @@ -333,18 +333,18 @@ contract FeeSharesMinterBaseTest is Base { uint256 drawAmount, uint256 skipTime, uint32 minTimeInterval, - uint16 minUnrealizedFeePercent + uint16 minAccruedFeesPercent ) public { addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); drawAmount = bound(drawAmount, 1, addAmount / 2); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); minTimeInterval = bound(minTimeInterval, 0, minter.MAX_TIME_INTERVAL()).toUint32(); - minUnrealizedFeePercent = bound(minUnrealizedFeePercent, 0, PercentageMath.PERCENTAGE_FACTOR) + minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: minTimeInterval, - minUnrealizedFeePercent: minUnrealizedFeePercent + minAccruedFeesPercent: minAccruedFeesPercent }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); @@ -390,7 +390,7 @@ contract FeeSharesMinterBaseTest is Base { function test_performUpkeep_revertsWith_ConditionsNotMet_timeIntervalNotMet() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 7 days, - minUnrealizedFeePercent: 0 + minAccruedFeesPercent: 0 }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); @@ -432,7 +432,7 @@ contract FeeSharesMinterBaseTest is Base { function test_performUpkeep_revertsWith_ConditionsNotMet_noFees() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, - minUnrealizedFeePercent: 0 + minAccruedFeesPercent: 0 }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); @@ -462,7 +462,7 @@ contract FeeSharesMinterBaseTest is Base { { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, - minUnrealizedFeePercent: 5000 + minAccruedFeesPercent: 5000 }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); @@ -497,7 +497,7 @@ contract FeeSharesMinterBaseTest is Base { function test_performUpkeep_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, - minUnrealizedFeePercent: 0 + minAccruedFeesPercent: 0 }); vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, config); From 9a0ce16aed207903a55bbe30915b4578ec96ab08 Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Fri, 27 Mar 2026 18:15:56 -0700 Subject: [PATCH 14/26] fix: Address pr comments --- src/utils/FeeSharesMinterBase.sol | 3 +- src/utils/IFeeSharesMinterBase.sol | 5 +- .../contracts/utils/FeeSharesMinterBase.t.sol | 161 +++++++++--------- 3 files changed, 88 insertions(+), 81 deletions(-) diff --git a/src/utils/FeeSharesMinterBase.sol b/src/utils/FeeSharesMinterBase.sol index e5158af09..2fd399a16 100644 --- a/src/utils/FeeSharesMinterBase.sol +++ b/src/utils/FeeSharesMinterBase.sol @@ -1,5 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED -// Copyright (c) 2025 Aave Labs +// SPDX-License-Identifier: LicenseRef-BUSL pragma solidity 0.8.28; import {Ownable2Step, Ownable} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; diff --git a/src/utils/IFeeSharesMinterBase.sol b/src/utils/IFeeSharesMinterBase.sol index 6f2dbcf47..7501f204e 100644 --- a/src/utils/IFeeSharesMinterBase.sol +++ b/src/utils/IFeeSharesMinterBase.sol @@ -1,5 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED -// Copyright (c) 2025 Aave Labs +// SPDX-License-Identifier: LicenseRef-BUSL pragma solidity 0.8.28; import {AutomationCompatibleInterface} from 'src/dependencies/chainlink/AutomationCompatibleInterface.sol'; @@ -46,7 +45,7 @@ interface IFeeSharesMinterBase is AutomationCompatibleInterface { /// @notice Chainlink Automation off-chain simulation check. /// @dev checkData must be abi.encoded as (address hub, uint256 assetId). - /// @dev Returns upkeepNeeded=true and the same bytes as performData when conditions are met. + /// @dev Returns whether upkeep is needed and the performData in bytes when conditions are met. /// @inheritdoc AutomationCompatibleInterface function checkUpkeep( bytes calldata checkData diff --git a/tests/contracts/utils/FeeSharesMinterBase.t.sol b/tests/contracts/utils/FeeSharesMinterBase.t.sol index d77280839..96707e3c7 100644 --- a/tests/contracts/utils/FeeSharesMinterBase.t.sol +++ b/tests/contracts/utils/FeeSharesMinterBase.t.sol @@ -1,5 +1,4 @@ -// SPDX-License-Identifier: UNLICENSED -// Copyright (c) 2025 Aave Labs +// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import 'tests/setup/Base.t.sol'; @@ -8,16 +7,17 @@ import {IFeeSharesMinterBase} from 'src/utils/IFeeSharesMinterBase.sol'; contract FeeSharesMinterBaseTest is Base { using SafeCast for uint256; + using PercentageMath for uint256; - FeeSharesMinterBase internal minter; + FeeSharesMinterBase internal _minter; function setUp() public override { super.setUp(); - minter = new FeeSharesMinterBase(ADMIN); + _minter = new FeeSharesMinterBase(ADMIN); - // Grant minter the HUB_ADMIN_ROLE so it can call mintFeeShares + // Grant _minter the HUB_ADMIN_ROLE so it can call mintFeeShares vm.prank(ADMIN); - accessManager.grantRole(Roles.HUB_ADMIN_ROLE, address(minter), 0); + accessManager.grantRole(Roles.HUB_ADMIN_ROLE, address(_minter), 0); } function test_setConfig_revertsWith_OwnableUnauthorized() public { @@ -28,7 +28,7 @@ contract FeeSharesMinterBaseTest is Base { vm.prank(bob); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, bob)); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); } function test_execute() public { @@ -51,7 +51,7 @@ contract FeeSharesMinterBaseTest is Base { addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); drawAmount = bound(drawAmount, 1, addAmount / 2); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); - minTimeInterval = bound(minTimeInterval, 0, minter.MAX_TIME_INTERVAL()).toUint32(); + minTimeInterval = bound(minTimeInterval, 0, _minter.MAX_TIME_INTERVAL()).toUint32(); minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); @@ -60,7 +60,7 @@ contract FeeSharesMinterBaseTest is Base { minAccruedFeesPercent: minAccruedFeesPercent }); vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); _addAndDrawLiquidity({ hub: hub1, @@ -74,17 +74,17 @@ contract FeeSharesMinterBaseTest is Base { skipTime: skipTime }); - if (minter.checkExecute(address(hub1), daiAssetId)) { - minter.execute(address(hub1), daiAssetId); + if (_minter.checkExecute(address(hub1), daiAssetId)) { + _minter.execute(address(hub1), daiAssetId); - assertEq(minter.lastMintTime(address(hub1), daiAssetId), block.timestamp); + assertEq(_minter.lastMintTime(address(hub1), daiAssetId), block.timestamp); assertFalse( - minter.checkExecute(address(hub1), daiAssetId), + _minter.checkExecute(address(hub1), daiAssetId), 'Should not be executable immediately after' ); } else { vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - minter.execute(address(hub1), daiAssetId); + _minter.execute(address(hub1), daiAssetId); } } @@ -94,7 +94,7 @@ contract FeeSharesMinterBaseTest is Base { minAccruedFeesPercent: 0 }); vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); _addAndDrawLiquidity({ hub: hub1, @@ -108,16 +108,19 @@ contract FeeSharesMinterBaseTest is Base { skipTime: 6 days }); - assertFalse(minter.checkExecute(address(hub1), daiAssetId), 'Not enough time elapsed'); + assertFalse(_minter.checkExecute(address(hub1), daiAssetId), 'Not enough time elapsed'); vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - minter.execute(address(hub1), daiAssetId); + _minter.execute(address(hub1), daiAssetId); vm.warp(block.timestamp + 1 days); - assertTrue(minter.checkExecute(address(hub1), daiAssetId), 'Sufficient conditions for execute'); - minter.execute(address(hub1), daiAssetId); + assertTrue( + _minter.checkExecute(address(hub1), daiAssetId), + 'Sufficient conditions for execute' + ); + _minter.execute(address(hub1), daiAssetId); - assertEq(minter.lastMintTime(address(hub1), daiAssetId), block.timestamp, 'Just minted'); - assertFalse(minter.checkExecute(address(hub1), daiAssetId), 'Cannot mint again immediately'); + assertEq(_minter.lastMintTime(address(hub1), daiAssetId), block.timestamp, 'Just minted'); + assertFalse(_minter.checkExecute(address(hub1), daiAssetId), 'Cannot mint again immediately'); } function test_execute_revertsWith_ConditionsNotMet_zeroFees() public { @@ -126,7 +129,7 @@ contract FeeSharesMinterBaseTest is Base { minAccruedFeesPercent: 0 }); vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); // Add liquidity, but no borrow, so no fees HubActions.add({ @@ -142,10 +145,10 @@ contract FeeSharesMinterBaseTest is Base { uint256 accruedFees = hub1.getAssetAccruedFees(daiAssetId); assertEq(accruedFees, 0, 'No fees should be accrued'); - assertFalse(minter.checkExecute(address(hub1), daiAssetId)); + assertFalse(_minter.checkExecute(address(hub1), daiAssetId)); vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - minter.execute(address(hub1), daiAssetId); + _minter.execute(address(hub1), daiAssetId); } function test_execute_revertsWith_ConditionsNotMet_PercentThresholdNotMet() public { @@ -154,7 +157,7 @@ contract FeeSharesMinterBaseTest is Base { minAccruedFeesPercent: 5000 // 50% threshold }); vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); _addAndDrawLiquidity({ hub: hub1, @@ -175,10 +178,10 @@ contract FeeSharesMinterBaseTest is Base { assertGt(hub1.previewAddByAssets(daiAssetId, fees), 0, 'At least 1 share would be minted'); assertLt(fees, totalAssets / 2, 'Fees must be < 50% of total'); - assertFalse(minter.checkExecute(address(hub1), daiAssetId)); + assertFalse(_minter.checkExecute(address(hub1), daiAssetId)); vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - minter.execute(address(hub1), daiAssetId); + _minter.execute(address(hub1), daiAssetId); } function test_execute_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { @@ -187,7 +190,7 @@ contract FeeSharesMinterBaseTest is Base { minAccruedFeesPercent: 0 }); vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); // Inflate exchange rate _addAndDrawLiquidity({ @@ -203,7 +206,7 @@ contract FeeSharesMinterBaseTest is Base { }); // Clear accrued fees - minter.execute(address(hub1), daiAssetId); + _minter.execute(address(hub1), daiAssetId); // Accrue some fees skip(110 days); @@ -212,17 +215,17 @@ contract FeeSharesMinterBaseTest is Base { assertGt(fees, 0, 'Fees must be nonzero'); assertEq(hub1.previewAddByAssets(daiAssetId, fees), 0, 'Shares must round to zero'); - assertFalse(minter.checkExecute(address(hub1), daiAssetId)); + assertFalse(_minter.checkExecute(address(hub1), daiAssetId)); vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - minter.execute(address(hub1), daiAssetId); + _minter.execute(address(hub1), daiAssetId); } function test_fuzz_setConfig_success( uint32 minTimeInterval, uint16 minAccruedFeesPercent ) public { - minTimeInterval = bound(minTimeInterval, 0, minter.MAX_TIME_INTERVAL()).toUint32(); + minTimeInterval = bound(minTimeInterval, 0, _minter.MAX_TIME_INTERVAL()).toUint32(); minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); @@ -232,9 +235,9 @@ contract FeeSharesMinterBaseTest is Base { }); vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); - IFeeSharesMinterBase.MintConfig memory savedConfig = minter.getConfig( + IFeeSharesMinterBase.MintConfig memory savedConfig = _minter.getConfig( address(hub1), daiAssetId ); @@ -245,7 +248,7 @@ contract FeeSharesMinterBaseTest is Base { function test_fuzz_setConfig_revertsWith_InvalidConfig_TimeInterval( uint32 minTimeInterval ) public { - minTimeInterval = bound(minTimeInterval, minter.MAX_TIME_INTERVAL() + 1, type(uint32).max) + minTimeInterval = bound(minTimeInterval, _minter.MAX_TIME_INTERVAL() + 1, type(uint32).max) .toUint32(); IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ @@ -255,7 +258,7 @@ contract FeeSharesMinterBaseTest is Base { vm.prank(ADMIN); vm.expectRevert(IFeeSharesMinterBase.InvalidConfig.selector); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); } function test_fuzz_setConfig_revertsWith_InvalidConfig_FeePercent( @@ -274,27 +277,29 @@ contract FeeSharesMinterBaseTest is Base { vm.prank(ADMIN); vm.expectRevert(IFeeSharesMinterBase.InvalidConfig.selector); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); } function test_rescueToken() public { + uint256 amount = 1000e18; + // Mint some dummy tokens to FeeSharesMinterBase MockERC20 token = new MockERC20(); - token.mint(address(minter), 1000e18); + token.mint(address(_minter), amount); - assertEq(token.balanceOf(address(minter)), 1000e18, 'Minter should have tokens'); + assertEq(token.balanceOf(address(_minter)), amount, 'Minter should have tokens'); // Attempt rescue by non-owner (should fail) vm.prank(bob); vm.expectRevert(abi.encodeWithSelector(IRescuable.OnlyRescueGuardian.selector)); - minter.rescueToken(address(token), bob, 1000e18); + _minter.rescueToken(address(token), bob, amount); // Rescue by owner (should succeed) vm.prank(ADMIN); - minter.rescueToken(address(token), ADMIN, 1000e18); + _minter.rescueToken(address(token), ADMIN, amount); - assertEq(token.balanceOf(address(minter)), 0, 'Minter should be empty'); - assertEq(token.balanceOf(ADMIN), 1000e18, 'Admin should have tokens'); + assertEq(token.balanceOf(address(_minter)), 0, 'Minter should be empty'); + assertEq(token.balanceOf(ADMIN), amount, 'Admin should have tokens'); } function test_transferOwnership_2Step() public { @@ -302,20 +307,20 @@ contract FeeSharesMinterBaseTest is Base { // Transfer ownership (starts 2-step process) vm.prank(ADMIN); - minter.transferOwnership(newOwner); + _minter.transferOwnership(newOwner); // Verify owner hasn't changed yet - assertEq(minter.owner(), ADMIN, 'Owner should still be ADMIN'); + assertEq(_minter.owner(), ADMIN, 'Owner should still be ADMIN'); // Verify pending owner - assertEq(minter.pendingOwner(), newOwner, 'Pending owner should be newOwner'); + assertEq(_minter.pendingOwner(), newOwner, 'Pending owner should be newOwner'); // Accept ownership vm.prank(newOwner); - minter.acceptOwnership(); + _minter.acceptOwnership(); // Verify owner changed - assertEq(minter.owner(), newOwner, 'Owner should now be newOwner'); - assertEq(minter.pendingOwner(), address(0), 'Pending owner should be cleared'); + assertEq(_minter.owner(), newOwner, 'Owner should now be newOwner'); + assertEq(_minter.pendingOwner(), address(0), 'Pending owner should be cleared'); } function test_performUpkeep() public { @@ -338,7 +343,7 @@ contract FeeSharesMinterBaseTest is Base { addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); drawAmount = bound(drawAmount, 1, addAmount / 2); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); - minTimeInterval = bound(minTimeInterval, 0, minter.MAX_TIME_INTERVAL()).toUint32(); + minTimeInterval = bound(minTimeInterval, 0, _minter.MAX_TIME_INTERVAL()).toUint32(); minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); @@ -347,7 +352,7 @@ contract FeeSharesMinterBaseTest is Base { minAccruedFeesPercent: minAccruedFeesPercent }); vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); _addAndDrawLiquidity({ hub: hub1, @@ -362,28 +367,28 @@ contract FeeSharesMinterBaseTest is Base { }); bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); + (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); assertEq( upkeepNeeded, - minter.checkExecute(address(hub1), daiAssetId), + _minter.checkExecute(address(hub1), daiAssetId), 'checkUpkeep and checkExecute must be consistent' ); if (upkeepNeeded) { - minter.performUpkeep(performData); + _minter.performUpkeep(performData); - assertEq(minter.lastMintTime(address(hub1), daiAssetId), block.timestamp); + assertEq(_minter.lastMintTime(address(hub1), daiAssetId), block.timestamp); - (bool upkeepNeededAfter, ) = minter.checkUpkeep(checkData); + (bool upkeepNeededAfter, ) = _minter.checkUpkeep(checkData); assertFalse(upkeepNeededAfter, 'checkUpkeep should return false after performUpkeep'); assertFalse( - minter.checkExecute(address(hub1), daiAssetId), + _minter.checkExecute(address(hub1), daiAssetId), 'checkExecute should return false after performUpkeep' ); } else { vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - minter.performUpkeep(performData); + _minter.performUpkeep(performData); } } @@ -393,7 +398,7 @@ contract FeeSharesMinterBaseTest is Base { minAccruedFeesPercent: 0 }); vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); _addAndDrawLiquidity({ hub: hub1, @@ -408,24 +413,24 @@ contract FeeSharesMinterBaseTest is Base { }); bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); + (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); assertFalse(upkeepNeeded, 'checkUpkeep should be false at 6 days'); vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - minter.performUpkeep(performData); + _minter.performUpkeep(performData); vm.warp(block.timestamp + 1 days); - (bool upkeepNeededAfter, bytes memory performDataAfter) = minter.checkUpkeep(checkData); + (bool upkeepNeededAfter, bytes memory performDataAfter) = _minter.checkUpkeep(checkData); assertTrue(upkeepNeededAfter, 'checkUpkeep should be true at 7 days'); - minter.performUpkeep(performDataAfter); + _minter.performUpkeep(performDataAfter); assertEq( - minter.lastMintTime(address(hub1), daiAssetId), + _minter.lastMintTime(address(hub1), daiAssetId), block.timestamp, 'lastMintTime should be updated' ); - (upkeepNeeded, ) = minter.checkUpkeep(checkData); + (upkeepNeeded, ) = _minter.checkUpkeep(checkData); assertFalse(upkeepNeeded, 'checkUpkeep should be false after performUpkeep'); } @@ -435,7 +440,7 @@ contract FeeSharesMinterBaseTest is Base { minAccruedFeesPercent: 0 }); vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); // Liquidity added, but no fees accrued HubActions.add({ @@ -450,11 +455,11 @@ contract FeeSharesMinterBaseTest is Base { assertEq(hub1.getAssetAccruedFees(daiAssetId), 0, 'Fees should be zero'); bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); + (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); assertFalse(upkeepNeeded, 'checkUpkeep should return false with no fees'); vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - minter.performUpkeep(performData); + _minter.performUpkeep(performData); } function test_performUpkeep_revertsWith_ConditionsNotMet_percentThresholdNotMet_withMinShares() @@ -465,7 +470,7 @@ contract FeeSharesMinterBaseTest is Base { minAccruedFeesPercent: 5000 }); vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); _addAndDrawLiquidity({ hub: hub1, @@ -484,14 +489,18 @@ contract FeeSharesMinterBaseTest is Base { assertGt(fees, 0, 'Fees must be nonzero'); assertGt(hub1.previewAddByAssets(daiAssetId, fees), 0, 'At least 1 share would be minted'); - assertLt(fees, totalAssets / 2, 'Fees must be < 50% of total'); + assertLt( + fees, + totalAssets.percentMulDown(config.minAccruedFeesPercent), + 'Fees must be < minAccruedFeesPercent of total' + ); bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); + (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); assertFalse(upkeepNeeded, 'checkUpkeep should be false: ratio below threshold'); vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - minter.performUpkeep(performData); + _minter.performUpkeep(performData); } function test_performUpkeep_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { @@ -500,7 +509,7 @@ contract FeeSharesMinterBaseTest is Base { minAccruedFeesPercent: 0 }); vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, config); // Inflate exchange rate _addAndDrawLiquidity({ @@ -516,7 +525,7 @@ contract FeeSharesMinterBaseTest is Base { }); // Clear accrued fees - minter.execute(address(hub1), daiAssetId); + _minter.execute(address(hub1), daiAssetId); // Accrue some fees skip(110 days); @@ -526,10 +535,10 @@ contract FeeSharesMinterBaseTest is Base { assertEq(hub1.previewAddByAssets(daiAssetId, fees), 0, 'Shares must round to zero'); bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); + (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); assertFalse(upkeepNeeded, 'checkUpkeep should be false when 0 shares minted'); vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - minter.performUpkeep(performData); + _minter.performUpkeep(performData); } } From 36d151ccf05d886eb52046453bcb35d5fd1a3a20 Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Wed, 8 Apr 2026 19:22:47 -0700 Subject: [PATCH 15/26] fix: Some pr comments --- src/utils/FeeSharesMinterBase.sol | 2 +- src/utils/IFeeSharesMinterBase.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/FeeSharesMinterBase.sol b/src/utils/FeeSharesMinterBase.sol index 2fd399a16..b771ebf4f 100644 --- a/src/utils/FeeSharesMinterBase.sol +++ b/src/utils/FeeSharesMinterBase.sol @@ -15,7 +15,7 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { uint256 public constant MAX_TIME_INTERVAL = 365 days; /// @inheritdoc IFeeSharesMinterBase - mapping(address hub => mapping(uint256 assetId => uint256)) public lastMintTime; + mapping(address hub => mapping(uint256 assetId => uint256 timestamp)) public lastMintTime; mapping(address hub => mapping(uint256 assetId => MintConfig)) internal _configs; diff --git a/src/utils/IFeeSharesMinterBase.sol b/src/utils/IFeeSharesMinterBase.sol index 7501f204e..01b8a573b 100644 --- a/src/utils/IFeeSharesMinterBase.sol +++ b/src/utils/IFeeSharesMinterBase.sol @@ -9,7 +9,7 @@ import {AutomationCompatibleInterface} from 'src/dependencies/chainlink/Automati interface IFeeSharesMinterBase is AutomationCompatibleInterface { /// @notice Configuration for automated fee share minting on a specific asset. /// @param minTimeInterval Minimum number of seconds that must elapse between mint executions. - /// @param minAccruedFeesPercent Minimum ratio of accrued fees to total assets, in bps. + /// @param minAccruedFeesPercent Minimum ratio of accrued fees to total assets, in BPS. struct MintConfig { uint48 minTimeInterval; uint16 minAccruedFeesPercent; @@ -60,7 +60,7 @@ interface IFeeSharesMinterBase is AutomationCompatibleInterface { /// @notice Returns the last timestamp at which fee shares were minted for a given asset. /// @param hub The address of the hub. /// @param assetId The identifier of the asset. - /// @return The block timestamp of the last successful `execute` call. + /// @return The timestamp of the last successful `execute` call. function lastMintTime(address hub, uint256 assetId) external view returns (uint256); /// @notice Checks whether the conditions to mint fee shares are currently met. From e0244e0831dc83d84ab4f5637c369b23b26d45a8 Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Wed, 8 Apr 2026 19:35:46 -0700 Subject: [PATCH 16/26] merge in main --- tests/contracts/utils/FeeSharesMinterBase.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/contracts/utils/FeeSharesMinterBase.t.sol b/tests/contracts/utils/FeeSharesMinterBase.t.sol index 96707e3c7..bf4ff2a09 100644 --- a/tests/contracts/utils/FeeSharesMinterBase.t.sol +++ b/tests/contracts/utils/FeeSharesMinterBase.t.sol @@ -15,9 +15,9 @@ contract FeeSharesMinterBaseTest is Base { super.setUp(); _minter = new FeeSharesMinterBase(ADMIN); - // Grant _minter the HUB_ADMIN_ROLE so it can call mintFeeShares + // Grant _minter the HUB_FEE_MINTER_ROLE so it can call mintFeeShares vm.prank(ADMIN); - accessManager.grantRole(Roles.HUB_ADMIN_ROLE, address(_minter), 0); + accessManager.grantRole(Roles.HUB_FEE_MINTER_ROLE, address(_minter), 0); } function test_setConfig_revertsWith_OwnableUnauthorized() public { From 6e155aac3a11bf62bd5131bd65dcab240fbbc47c Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Wed, 8 Apr 2026 19:44:30 -0700 Subject: [PATCH 17/26] fix: Remaining pr comments --- tests/contracts/utils/FeeSharesMinterBase.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/contracts/utils/FeeSharesMinterBase.t.sol b/tests/contracts/utils/FeeSharesMinterBase.t.sol index bf4ff2a09..aa3c6fe08 100644 --- a/tests/contracts/utils/FeeSharesMinterBase.t.sol +++ b/tests/contracts/utils/FeeSharesMinterBase.t.sol @@ -154,7 +154,7 @@ contract FeeSharesMinterBaseTest is Base { function test_execute_revertsWith_ConditionsNotMet_PercentThresholdNotMet() public { IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ minTimeInterval: 0, - minAccruedFeesPercent: 5000 // 50% threshold + minAccruedFeesPercent: 50_00 // 50% threshold }); vm.prank(ADMIN); _minter.setConfig(address(hub1), daiAssetId, config); From 39c7c5a503e751b8c99a90ae7de3657852b9b442 Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Thu, 9 Apr 2026 13:27:10 -0700 Subject: [PATCH 18/26] feat: Remove time component from fee minter --- src/utils/FeeSharesMinterBase.sol | 40 ++-- src/utils/IFeeSharesMinterBase.sol | 37 +--- .../contracts/utils/FeeSharesMinterBase.t.sol | 197 ++---------------- 3 files changed, 40 insertions(+), 234 deletions(-) diff --git a/src/utils/FeeSharesMinterBase.sol b/src/utils/FeeSharesMinterBase.sol index b771ebf4f..c30c1fe35 100644 --- a/src/utils/FeeSharesMinterBase.sol +++ b/src/utils/FeeSharesMinterBase.sol @@ -11,27 +11,22 @@ import {IHub} from 'src/hub/interfaces/IHub.sol'; /// @author Aave Labs /// @notice Contract to mint fee shares on the Hub when specific conditions are met. contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { - /// @inheritdoc IFeeSharesMinterBase - uint256 public constant MAX_TIME_INTERVAL = 365 days; - - /// @inheritdoc IFeeSharesMinterBase - mapping(address hub => mapping(uint256 assetId => uint256 timestamp)) public lastMintTime; - - mapping(address hub => mapping(uint256 assetId => MintConfig)) internal _configs; + mapping(address hub => mapping(uint256 assetId => uint16 minAccruedFeesPercent)) + internal _configs; /// @dev Constructor. /// @param owner The owner of the contract. constructor(address owner) Ownable(owner) {} /// @inheritdoc IFeeSharesMinterBase - function setConfig(address hub, uint256 assetId, MintConfig memory config) external onlyOwner { - require( - config.minAccruedFeesPercent <= PercentageMath.PERCENTAGE_FACTOR && - config.minTimeInterval <= MAX_TIME_INTERVAL, - InvalidConfig() - ); - _configs[hub][assetId] = config; - emit ConfigUpdated(hub, assetId, config); + function setConfig( + address hub, + uint256 assetId, + uint16 minAccruedFeesPercent + ) external onlyOwner { + require(minAccruedFeesPercent <= PercentageMath.PERCENTAGE_FACTOR, InvalidConfig()); + _configs[hub][assetId] = minAccruedFeesPercent; + emit ConfigUpdated(hub, assetId, minAccruedFeesPercent); } /// @inheritdoc IFeeSharesMinterBase @@ -56,7 +51,7 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { } /// @inheritdoc IFeeSharesMinterBase - function getConfig(address hub, uint256 assetId) external view returns (MintConfig memory) { + function getConfig(address hub, uint256 assetId) external view returns (uint16) { return _configs[hub][assetId]; } @@ -71,7 +66,6 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { function _execute(address hub, uint256 assetId) internal virtual { require(_checkExecute(hub, assetId), ConditionsNotMet()); - lastMintTime[hub][assetId] = block.timestamp; IHub(hub).mintFeeShares(assetId); } @@ -80,21 +74,13 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { /// @param assetId The identifier of the asset. /// @return True if conditions are met, false otherwise. function _checkExecute(address hub, uint256 assetId) internal view virtual returns (bool) { - MintConfig memory config = _configs[hub][assetId]; - - // Check mint interval - if (block.timestamp - lastMintTime[hub][assetId] < config.minTimeInterval) { - return false; - } + uint16 minAccruedFeesPercent = _configs[hub][assetId]; IHub hubContract = IHub(hub); uint256 accruedFees = hubContract.getAssetAccruedFees(assetId); uint256 totalAddedAssets = hubContract.getAddedAssets(assetId); - // Check if accruedFees / totalAddedAssets >= minAccruedFeesPercent (in BPS) - if ( - PercentageMath.percentDivDown(accruedFees, totalAddedAssets) < config.minAccruedFeesPercent - ) { + if (PercentageMath.percentDivDown(accruedFees, totalAddedAssets) < minAccruedFeesPercent) { return false; } diff --git a/src/utils/IFeeSharesMinterBase.sol b/src/utils/IFeeSharesMinterBase.sol index 01b8a573b..6afdef9dc 100644 --- a/src/utils/IFeeSharesMinterBase.sol +++ b/src/utils/IFeeSharesMinterBase.sol @@ -7,31 +7,23 @@ import {AutomationCompatibleInterface} from 'src/dependencies/chainlink/Automati /// @author Aave Labs /// @notice Interface for the FeeSharesMinterBase contract interface IFeeSharesMinterBase is AutomationCompatibleInterface { - /// @notice Configuration for automated fee share minting on a specific asset. - /// @param minTimeInterval Minimum number of seconds that must elapse between mint executions. - /// @param minAccruedFeesPercent Minimum ratio of accrued fees to total assets, in BPS. - struct MintConfig { - uint48 minTimeInterval; - uint16 minAccruedFeesPercent; - } - - /// @notice Emitted when the mint configuration for an asset is updated. + /// @notice Emitted when the configuration for an asset is updated. /// @param hub The address of the hub. /// @param assetId The identifier of the asset. - /// @param config The new configuration. - event ConfigUpdated(address indexed hub, uint256 indexed assetId, MintConfig config); + /// @param minAccruedFeesPercent The new minimum ratio of accrued fees to total added assets, in BPS. + event ConfigUpdated(address indexed hub, uint256 indexed assetId, uint16 minAccruedFeesPercent); /// @notice Thrown upon minting when the required conditions are not met. error ConditionsNotMet(); - /// @notice Thrown when `setConfig` is called with invalid parameter values. + /// @notice Thrown when `setConfig` is called with an invalid value. error InvalidConfig(); - /// @notice Sets the automation configuration for a specific asset. + /// @notice Sets the minimum accrued fees percent for a specific asset. /// @param hub The address of the hub. /// @param assetId The identifier of the asset. - /// @param config The new configuration to apply. - function setConfig(address hub, uint256 assetId, MintConfig memory config) external; + /// @param minAccruedFeesPercent Minimum ratio of accrued fees to total added assets, in BPS. + function setConfig(address hub, uint256 assetId, uint16 minAccruedFeesPercent) external; /// @notice Executes fee share minting if all conditions are met. /// @param hub The address of the hub. @@ -51,24 +43,15 @@ interface IFeeSharesMinterBase is AutomationCompatibleInterface { bytes calldata checkData ) external view returns (bool upkeepNeeded, bytes memory performData); - /// @notice Returns the current automation configuration for a specific asset. + /// @notice Returns the minimum accrued fees percent for a specific asset. /// @param hub The address of the hub. /// @param assetId The identifier of the asset. - /// @return The stored `MintConfig` struct. - function getConfig(address hub, uint256 assetId) external view returns (MintConfig memory); - - /// @notice Returns the last timestamp at which fee shares were minted for a given asset. - /// @param hub The address of the hub. - /// @param assetId The identifier of the asset. - /// @return The timestamp of the last successful `execute` call. - function lastMintTime(address hub, uint256 assetId) external view returns (uint256); + /// @return The minimum ratio of accrued fees to total added assets, in BPS. + function getConfig(address hub, uint256 assetId) external view returns (uint16); /// @notice Checks whether the conditions to mint fee shares are currently met. /// @param hub The address of the hub. /// @param assetId The identifier of the asset. /// @return True if `execute` would succeed, false otherwise. function checkExecute(address hub, uint256 assetId) external view returns (bool); - - /// @notice The maximum allowed value for enforcing the elapsed time between mint executions. - function MAX_TIME_INTERVAL() external view returns (uint256); } diff --git a/tests/contracts/utils/FeeSharesMinterBase.t.sol b/tests/contracts/utils/FeeSharesMinterBase.t.sol index aa3c6fe08..2695b2636 100644 --- a/tests/contracts/utils/FeeSharesMinterBase.t.sol +++ b/tests/contracts/utils/FeeSharesMinterBase.t.sol @@ -21,14 +21,9 @@ contract FeeSharesMinterBaseTest is Base { } function test_setConfig_revertsWith_OwnableUnauthorized() public { - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 1 days, - minAccruedFeesPercent: 100 // 1% - }); - vm.prank(bob); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, bob)); - _minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, 100); } function test_execute() public { @@ -36,7 +31,6 @@ contract FeeSharesMinterBaseTest is Base { addAmount: 1000e18, drawAmount: 900e18, skipTime: 365 days, - minTimeInterval: 1 days, minAccruedFeesPercent: 10 }); } @@ -45,22 +39,16 @@ contract FeeSharesMinterBaseTest is Base { uint256 addAmount, uint256 drawAmount, uint256 skipTime, - uint32 minTimeInterval, uint16 minAccruedFeesPercent ) public { addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); drawAmount = bound(drawAmount, 1, addAmount / 2); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); - minTimeInterval = bound(minTimeInterval, 0, _minter.MAX_TIME_INTERVAL()).toUint32(); minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: minTimeInterval, - minAccruedFeesPercent: minAccruedFeesPercent - }); vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); _addAndDrawLiquidity({ hub: hub1, @@ -76,60 +64,15 @@ contract FeeSharesMinterBaseTest is Base { if (_minter.checkExecute(address(hub1), daiAssetId)) { _minter.execute(address(hub1), daiAssetId); - - assertEq(_minter.lastMintTime(address(hub1), daiAssetId), block.timestamp); - assertFalse( - _minter.checkExecute(address(hub1), daiAssetId), - 'Should not be executable immediately after' - ); } else { vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); _minter.execute(address(hub1), daiAssetId); } } - function test_execute_revertsWith_ConditionsNotMet_TimeIntervalNotMet() public { - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 7 days, - minAccruedFeesPercent: 0 - }); - vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, config); - - _addAndDrawLiquidity({ - hub: hub1, - assetId: daiAssetId, - addUser: bob, - addSpoke: address(spoke1), - addAmount: 1000e18, - drawUser: bob, - drawSpoke: address(spoke1), - drawAmount: 100e18, - skipTime: 6 days - }); - - assertFalse(_minter.checkExecute(address(hub1), daiAssetId), 'Not enough time elapsed'); - vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - _minter.execute(address(hub1), daiAssetId); - - vm.warp(block.timestamp + 1 days); - assertTrue( - _minter.checkExecute(address(hub1), daiAssetId), - 'Sufficient conditions for execute' - ); - _minter.execute(address(hub1), daiAssetId); - - assertEq(_minter.lastMintTime(address(hub1), daiAssetId), block.timestamp, 'Just minted'); - assertFalse(_minter.checkExecute(address(hub1), daiAssetId), 'Cannot mint again immediately'); - } - function test_execute_revertsWith_ConditionsNotMet_zeroFees() public { - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 0, - minAccruedFeesPercent: 0 - }); vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, 0); // Add liquidity, but no borrow, so no fees HubActions.add({ @@ -152,12 +95,9 @@ contract FeeSharesMinterBaseTest is Base { } function test_execute_revertsWith_ConditionsNotMet_PercentThresholdNotMet() public { - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 0, - minAccruedFeesPercent: 50_00 // 50% threshold - }); + uint16 threshold = 50_00; // 50% vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, threshold); _addAndDrawLiquidity({ hub: hub1, @@ -185,12 +125,8 @@ contract FeeSharesMinterBaseTest is Base { } function test_execute_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 0, - minAccruedFeesPercent: 0 - }); vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, 0); // Inflate exchange rate _addAndDrawLiquidity({ @@ -221,63 +157,26 @@ contract FeeSharesMinterBaseTest is Base { _minter.execute(address(hub1), daiAssetId); } - function test_fuzz_setConfig_success( - uint32 minTimeInterval, - uint16 minAccruedFeesPercent - ) public { - minTimeInterval = bound(minTimeInterval, 0, _minter.MAX_TIME_INTERVAL()).toUint32(); + function test_fuzz_setConfig_success(uint16 minAccruedFeesPercent) public { minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: minTimeInterval, - minAccruedFeesPercent: minAccruedFeesPercent - }); - vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); - IFeeSharesMinterBase.MintConfig memory savedConfig = _minter.getConfig( - address(hub1), - daiAssetId - ); - assertEq(savedConfig.minTimeInterval, minTimeInterval); - assertEq(savedConfig.minAccruedFeesPercent, minAccruedFeesPercent); + assertEq(_minter.getConfig(address(hub1), daiAssetId), minAccruedFeesPercent); } - function test_fuzz_setConfig_revertsWith_InvalidConfig_TimeInterval( - uint32 minTimeInterval - ) public { - minTimeInterval = bound(minTimeInterval, _minter.MAX_TIME_INTERVAL() + 1, type(uint32).max) - .toUint32(); - - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: minTimeInterval, - minAccruedFeesPercent: 0 - }); - - vm.prank(ADMIN); - vm.expectRevert(IFeeSharesMinterBase.InvalidConfig.selector); - _minter.setConfig(address(hub1), daiAssetId, config); - } - - function test_fuzz_setConfig_revertsWith_InvalidConfig_FeePercent( - uint16 minAccruedFeesPercent - ) public { + function test_fuzz_setConfig_revertsWith_InvalidConfig(uint16 minAccruedFeesPercent) public { minAccruedFeesPercent = bound( minAccruedFeesPercent, PercentageMath.PERCENTAGE_FACTOR + 1, type(uint16).max ).toUint16(); - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 0, - minAccruedFeesPercent: minAccruedFeesPercent - }); - vm.prank(ADMIN); vm.expectRevert(IFeeSharesMinterBase.InvalidConfig.selector); - _minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); } function test_rescueToken() public { @@ -328,7 +227,6 @@ contract FeeSharesMinterBaseTest is Base { addAmount: 1000e18, drawAmount: 900e18, skipTime: 365 days, - minTimeInterval: 1 days, minAccruedFeesPercent: 10 }); } @@ -337,22 +235,16 @@ contract FeeSharesMinterBaseTest is Base { uint256 addAmount, uint256 drawAmount, uint256 skipTime, - uint32 minTimeInterval, uint16 minAccruedFeesPercent ) public { addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); drawAmount = bound(drawAmount, 1, addAmount / 2); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); - minTimeInterval = bound(minTimeInterval, 0, _minter.MAX_TIME_INTERVAL()).toUint32(); minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: minTimeInterval, - minAccruedFeesPercent: minAccruedFeesPercent - }); vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); _addAndDrawLiquidity({ hub: hub1, @@ -378,8 +270,6 @@ contract FeeSharesMinterBaseTest is Base { if (upkeepNeeded) { _minter.performUpkeep(performData); - assertEq(_minter.lastMintTime(address(hub1), daiAssetId), block.timestamp); - (bool upkeepNeededAfter, ) = _minter.checkUpkeep(checkData); assertFalse(upkeepNeededAfter, 'checkUpkeep should return false after performUpkeep'); assertFalse( @@ -392,55 +282,9 @@ contract FeeSharesMinterBaseTest is Base { } } - function test_performUpkeep_revertsWith_ConditionsNotMet_timeIntervalNotMet() public { - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 7 days, - minAccruedFeesPercent: 0 - }); - vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, config); - - _addAndDrawLiquidity({ - hub: hub1, - assetId: daiAssetId, - addUser: bob, - addSpoke: address(spoke1), - addAmount: 1000e18, - drawUser: bob, - drawSpoke: address(spoke1), - drawAmount: 100e18, - skipTime: 6 days - }); - - bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); - - assertFalse(upkeepNeeded, 'checkUpkeep should be false at 6 days'); - vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - _minter.performUpkeep(performData); - - vm.warp(block.timestamp + 1 days); - - (bool upkeepNeededAfter, bytes memory performDataAfter) = _minter.checkUpkeep(checkData); - assertTrue(upkeepNeededAfter, 'checkUpkeep should be true at 7 days'); - _minter.performUpkeep(performDataAfter); - - assertEq( - _minter.lastMintTime(address(hub1), daiAssetId), - block.timestamp, - 'lastMintTime should be updated' - ); - (upkeepNeeded, ) = _minter.checkUpkeep(checkData); - assertFalse(upkeepNeeded, 'checkUpkeep should be false after performUpkeep'); - } - function test_performUpkeep_revertsWith_ConditionsNotMet_noFees() public { - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 0, - minAccruedFeesPercent: 0 - }); vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, 0); // Liquidity added, but no fees accrued HubActions.add({ @@ -465,12 +309,9 @@ contract FeeSharesMinterBaseTest is Base { function test_performUpkeep_revertsWith_ConditionsNotMet_percentThresholdNotMet_withMinShares() public { - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 0, - minAccruedFeesPercent: 5000 - }); + uint16 threshold = 50_00; vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, threshold); _addAndDrawLiquidity({ hub: hub1, @@ -491,7 +332,7 @@ contract FeeSharesMinterBaseTest is Base { assertGt(hub1.previewAddByAssets(daiAssetId, fees), 0, 'At least 1 share would be minted'); assertLt( fees, - totalAssets.percentMulDown(config.minAccruedFeesPercent), + totalAssets.percentMulDown(threshold), 'Fees must be < minAccruedFeesPercent of total' ); @@ -504,12 +345,8 @@ contract FeeSharesMinterBaseTest is Base { } function test_performUpkeep_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { - IFeeSharesMinterBase.MintConfig memory config = IFeeSharesMinterBase.MintConfig({ - minTimeInterval: 0, - minAccruedFeesPercent: 0 - }); vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, config); + _minter.setConfig(address(hub1), daiAssetId, 0); // Inflate exchange rate _addAndDrawLiquidity({ From 56fbaf0bec85defa4914ba844713a4b6bef2e3f9 Mon Sep 17 00:00:00 2001 From: CheyenneAtapour Date: Thu, 9 Apr 2026 13:50:52 -0700 Subject: [PATCH 19/26] fix: Remove extraneous functions --- src/utils/FeeSharesMinterBase.sol | 20 +-- src/utils/IFeeSharesMinterBase.sol | 11 -- .../contracts/utils/FeeSharesMinterBase.t.sol | 143 +----------------- 3 files changed, 6 insertions(+), 168 deletions(-) diff --git a/src/utils/FeeSharesMinterBase.sol b/src/utils/FeeSharesMinterBase.sol index c30c1fe35..ade39a5e1 100644 --- a/src/utils/FeeSharesMinterBase.sol +++ b/src/utils/FeeSharesMinterBase.sol @@ -29,15 +29,10 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { emit ConfigUpdated(hub, assetId, minAccruedFeesPercent); } - /// @inheritdoc IFeeSharesMinterBase - function execute(address hub, uint256 assetId) external { - _execute(hub, assetId); - } - /// @inheritdoc IFeeSharesMinterBase function performUpkeep(bytes calldata performData) external override { (address hub, uint256 assetId) = abi.decode(performData, (address, uint256)); - _execute(hub, assetId); + _performUpkeep(hub, assetId); } /// @inheritdoc IFeeSharesMinterBase @@ -45,7 +40,7 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { bytes calldata checkData ) external view override returns (bool, bytes memory) { (address hub, uint256 assetId) = abi.decode(checkData, (address, uint256)); - bool upkeepNeeded = _checkExecute(hub, assetId); + bool upkeepNeeded = _checkUpkeep(hub, assetId); bytes memory performData = checkData; return (upkeepNeeded, performData); } @@ -55,16 +50,11 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { return _configs[hub][assetId]; } - /// @inheritdoc IFeeSharesMinterBase - function checkExecute(address hub, uint256 assetId) external view returns (bool) { - return _checkExecute(hub, assetId); - } - /// @dev Internal function to execute fee share minting. /// @param hub The address of the hub. /// @param assetId The identifier of the asset. - function _execute(address hub, uint256 assetId) internal virtual { - require(_checkExecute(hub, assetId), ConditionsNotMet()); + function _performUpkeep(address hub, uint256 assetId) internal virtual { + require(_checkUpkeep(hub, assetId), ConditionsNotMet()); IHub(hub).mintFeeShares(assetId); } @@ -73,7 +63,7 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { /// @param hub The address of the hub. /// @param assetId The identifier of the asset. /// @return True if conditions are met, false otherwise. - function _checkExecute(address hub, uint256 assetId) internal view virtual returns (bool) { + function _checkUpkeep(address hub, uint256 assetId) internal view virtual returns (bool) { uint16 minAccruedFeesPercent = _configs[hub][assetId]; IHub hubContract = IHub(hub); diff --git a/src/utils/IFeeSharesMinterBase.sol b/src/utils/IFeeSharesMinterBase.sol index 6afdef9dc..ab957878d 100644 --- a/src/utils/IFeeSharesMinterBase.sol +++ b/src/utils/IFeeSharesMinterBase.sol @@ -25,11 +25,6 @@ interface IFeeSharesMinterBase is AutomationCompatibleInterface { /// @param minAccruedFeesPercent Minimum ratio of accrued fees to total added assets, in BPS. function setConfig(address hub, uint256 assetId, uint16 minAccruedFeesPercent) external; - /// @notice Executes fee share minting if all conditions are met. - /// @param hub The address of the hub. - /// @param assetId The identifier of the asset. - function execute(address hub, uint256 assetId) external; - /// @notice Chainlink Automation on-chain execution entry point. /// @dev performData must be abi.encoded as (address hub, uint256 assetId). /// @inheritdoc AutomationCompatibleInterface @@ -48,10 +43,4 @@ interface IFeeSharesMinterBase is AutomationCompatibleInterface { /// @param assetId The identifier of the asset. /// @return The minimum ratio of accrued fees to total added assets, in BPS. function getConfig(address hub, uint256 assetId) external view returns (uint16); - - /// @notice Checks whether the conditions to mint fee shares are currently met. - /// @param hub The address of the hub. - /// @param assetId The identifier of the asset. - /// @return True if `execute` would succeed, false otherwise. - function checkExecute(address hub, uint256 assetId) external view returns (bool); } diff --git a/tests/contracts/utils/FeeSharesMinterBase.t.sol b/tests/contracts/utils/FeeSharesMinterBase.t.sol index 2695b2636..bb601d2d1 100644 --- a/tests/contracts/utils/FeeSharesMinterBase.t.sol +++ b/tests/contracts/utils/FeeSharesMinterBase.t.sol @@ -26,137 +26,6 @@ contract FeeSharesMinterBaseTest is Base { _minter.setConfig(address(hub1), daiAssetId, 100); } - function test_execute() public { - test_fuzz_execute({ - addAmount: 1000e18, - drawAmount: 900e18, - skipTime: 365 days, - minAccruedFeesPercent: 10 - }); - } - - function test_fuzz_execute( - uint256 addAmount, - uint256 drawAmount, - uint256 skipTime, - uint16 minAccruedFeesPercent - ) public { - addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); - drawAmount = bound(drawAmount, 1, addAmount / 2); - skipTime = bound(skipTime, 1, MAX_SKIP_TIME); - minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) - .toUint16(); - - vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); - - _addAndDrawLiquidity({ - hub: hub1, - assetId: daiAssetId, - addUser: bob, - addSpoke: address(spoke1), - addAmount: addAmount, - drawUser: bob, - drawSpoke: address(spoke1), - drawAmount: drawAmount, - skipTime: skipTime - }); - - if (_minter.checkExecute(address(hub1), daiAssetId)) { - _minter.execute(address(hub1), daiAssetId); - } else { - vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - _minter.execute(address(hub1), daiAssetId); - } - } - - function test_execute_revertsWith_ConditionsNotMet_zeroFees() public { - vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, 0); - - // Add liquidity, but no borrow, so no fees - HubActions.add({ - hub: hub1, - assetId: daiAssetId, - caller: address(spoke1), - amount: 1000e18, - user: bob - }); - - skip(365 days); - - uint256 accruedFees = hub1.getAssetAccruedFees(daiAssetId); - assertEq(accruedFees, 0, 'No fees should be accrued'); - - assertFalse(_minter.checkExecute(address(hub1), daiAssetId)); - - vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - _minter.execute(address(hub1), daiAssetId); - } - - function test_execute_revertsWith_ConditionsNotMet_PercentThresholdNotMet() public { - uint16 threshold = 50_00; // 50% - vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, threshold); - - _addAndDrawLiquidity({ - hub: hub1, - assetId: daiAssetId, - addUser: bob, - addSpoke: address(spoke1), - addAmount: 1000e18, - drawUser: bob, - drawSpoke: address(spoke1), - drawAmount: 100e18, - skipTime: 365 days - }); - - uint256 fees = hub1.getAssetAccruedFees(daiAssetId); - uint256 totalAssets = hub1.getAddedAssets(daiAssetId); - - assertGt(fees, 0, 'Fees must be nonzero'); - assertGt(hub1.previewAddByAssets(daiAssetId, fees), 0, 'At least 1 share would be minted'); - assertLt(fees, totalAssets / 2, 'Fees must be < 50% of total'); - - assertFalse(_minter.checkExecute(address(hub1), daiAssetId)); - - vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - _minter.execute(address(hub1), daiAssetId); - } - - function test_execute_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { - vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, 0); - - // Inflate exchange rate - _addAndDrawLiquidity({ - hub: hub1, - assetId: daiAssetId, - addUser: bob, - addSpoke: address(spoke1), - addAmount: 300 wei, - drawUser: bob, - drawSpoke: address(spoke1), - drawAmount: 200 wei, - skipTime: MAX_SKIP_TIME - 110 days - }); - - // Clear accrued fees - _minter.execute(address(hub1), daiAssetId); - - // Accrue some fees - skip(110 days); - - uint256 fees = hub1.getAssetAccruedFees(daiAssetId); - assertGt(fees, 0, 'Fees must be nonzero'); - assertEq(hub1.previewAddByAssets(daiAssetId, fees), 0, 'Shares must round to zero'); - - assertFalse(_minter.checkExecute(address(hub1), daiAssetId)); - - vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - _minter.execute(address(hub1), daiAssetId); - } - function test_fuzz_setConfig_success(uint16 minAccruedFeesPercent) public { minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); @@ -261,21 +130,11 @@ contract FeeSharesMinterBaseTest is Base { bytes memory checkData = abi.encode(address(hub1), daiAssetId); (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); - assertEq( - upkeepNeeded, - _minter.checkExecute(address(hub1), daiAssetId), - 'checkUpkeep and checkExecute must be consistent' - ); - if (upkeepNeeded) { _minter.performUpkeep(performData); (bool upkeepNeededAfter, ) = _minter.checkUpkeep(checkData); assertFalse(upkeepNeededAfter, 'checkUpkeep should return false after performUpkeep'); - assertFalse( - _minter.checkExecute(address(hub1), daiAssetId), - 'checkExecute should return false after performUpkeep' - ); } else { vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); _minter.performUpkeep(performData); @@ -362,7 +221,7 @@ contract FeeSharesMinterBaseTest is Base { }); // Clear accrued fees - _minter.execute(address(hub1), daiAssetId); + _minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); // Accrue some fees skip(110 days); From 7bb20ece4c38bb5a75e4be26058990caabd393cb Mon Sep 17 00:00:00 2001 From: Kogaroshi <25688223+Kogaroshi@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:11:57 +0100 Subject: [PATCH 20/26] fix : address pr comments & cleanup --- src/utils/FeeSharesMinterBase.sol | 12 +++-- .../contracts/utils/FeeSharesMinterBase.t.sol | 46 +++++++++++++------ 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/utils/FeeSharesMinterBase.sol b/src/utils/FeeSharesMinterBase.sol index ade39a5e1..1805cc921 100644 --- a/src/utils/FeeSharesMinterBase.sol +++ b/src/utils/FeeSharesMinterBase.sol @@ -11,8 +11,7 @@ import {IHub} from 'src/hub/interfaces/IHub.sol'; /// @author Aave Labs /// @notice Contract to mint fee shares on the Hub when specific conditions are met. contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { - mapping(address hub => mapping(uint256 assetId => uint16 minAccruedFeesPercent)) - internal _configs; + mapping(address hub => mapping(uint256 assetId => uint16)) internal _minAccruedFeesPercent; /// @dev Constructor. /// @param owner The owner of the contract. @@ -25,7 +24,7 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { uint16 minAccruedFeesPercent ) external onlyOwner { require(minAccruedFeesPercent <= PercentageMath.PERCENTAGE_FACTOR, InvalidConfig()); - _configs[hub][assetId] = minAccruedFeesPercent; + _minAccruedFeesPercent[hub][assetId] = minAccruedFeesPercent; emit ConfigUpdated(hub, assetId, minAccruedFeesPercent); } @@ -47,7 +46,7 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { /// @inheritdoc IFeeSharesMinterBase function getConfig(address hub, uint256 assetId) external view returns (uint16) { - return _configs[hub][assetId]; + return _minAccruedFeesPercent[hub][assetId]; } /// @dev Internal function to execute fee share minting. @@ -64,12 +63,15 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { /// @param assetId The identifier of the asset. /// @return True if conditions are met, false otherwise. function _checkUpkeep(address hub, uint256 assetId) internal view virtual returns (bool) { - uint16 minAccruedFeesPercent = _configs[hub][assetId]; + uint16 minAccruedFeesPercent = _minAccruedFeesPercent[hub][assetId]; IHub hubContract = IHub(hub); uint256 accruedFees = hubContract.getAssetAccruedFees(assetId); uint256 totalAddedAssets = hubContract.getAddedAssets(assetId); + if (totalAddedAssets == 0) { + return false; + } if (PercentageMath.percentDivDown(accruedFees, totalAddedAssets) < minAccruedFeesPercent) { return false; } diff --git a/tests/contracts/utils/FeeSharesMinterBase.t.sol b/tests/contracts/utils/FeeSharesMinterBase.t.sol index bb601d2d1..22f90e636 100644 --- a/tests/contracts/utils/FeeSharesMinterBase.t.sol +++ b/tests/contracts/utils/FeeSharesMinterBase.t.sol @@ -15,7 +15,6 @@ contract FeeSharesMinterBaseTest is Base { super.setUp(); _minter = new FeeSharesMinterBase(ADMIN); - // Grant _minter the HUB_FEE_MINTER_ROLE so it can call mintFeeShares vm.prank(ADMIN); accessManager.grantRole(Roles.HUB_FEE_MINTER_ROLE, address(_minter), 0); } @@ -26,16 +25,33 @@ contract FeeSharesMinterBaseTest is Base { _minter.setConfig(address(hub1), daiAssetId, 100); } - function test_fuzz_setConfig_success(uint16 minAccruedFeesPercent) public { + function test_fuzz_setConfig(uint16 minAccruedFeesPercent) public { minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); + vm.expectEmit(address(_minter)); + emit IFeeSharesMinterBase.ConfigUpdated(address(hub1), daiAssetId, minAccruedFeesPercent); + vm.prank(ADMIN); _minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); assertEq(_minter.getConfig(address(hub1), daiAssetId), minAccruedFeesPercent); } + function test_setConfig_independentPerPair() public { + uint16 config1 = 100; + uint16 config2 = 200; + + vm.startPrank(ADMIN); + _minter.setConfig(address(hub1), daiAssetId, config1); + _minter.setConfig(address(hub1), wethAssetId, config2); + vm.stopPrank(); + + assertEq(_minter.getConfig(address(hub1), daiAssetId), config1, 'daiAssetId config'); + assertEq(_minter.getConfig(address(hub1), wethAssetId), config2, 'wethAssetId config'); + assertEq(_minter.getConfig(address(hub1), usdxAssetId), 0, 'usdxAssetId should be unset'); + } + function test_fuzz_setConfig_revertsWith_InvalidConfig(uint16 minAccruedFeesPercent) public { minAccruedFeesPercent = bound( minAccruedFeesPercent, @@ -51,18 +67,15 @@ contract FeeSharesMinterBaseTest is Base { function test_rescueToken() public { uint256 amount = 1000e18; - // Mint some dummy tokens to FeeSharesMinterBase MockERC20 token = new MockERC20(); token.mint(address(_minter), amount); assertEq(token.balanceOf(address(_minter)), amount, 'Minter should have tokens'); - // Attempt rescue by non-owner (should fail) vm.prank(bob); vm.expectRevert(abi.encodeWithSelector(IRescuable.OnlyRescueGuardian.selector)); _minter.rescueToken(address(token), bob, amount); - // Rescue by owner (should succeed) vm.prank(ADMIN); _minter.rescueToken(address(token), ADMIN, amount); @@ -73,20 +86,15 @@ contract FeeSharesMinterBaseTest is Base { function test_transferOwnership_2Step() public { address newOwner = makeAddr('newOwner'); - // Transfer ownership (starts 2-step process) vm.prank(ADMIN); _minter.transferOwnership(newOwner); - // Verify owner hasn't changed yet assertEq(_minter.owner(), ADMIN, 'Owner should still be ADMIN'); - // Verify pending owner assertEq(_minter.pendingOwner(), newOwner, 'Pending owner should be newOwner'); - // Accept ownership vm.prank(newOwner); _minter.acceptOwnership(); - // Verify owner changed assertEq(_minter.owner(), newOwner, 'Owner should now be newOwner'); assertEq(_minter.pendingOwner(), address(0), 'Pending owner should be cleared'); } @@ -129,6 +137,7 @@ contract FeeSharesMinterBaseTest is Base { bytes memory checkData = abi.encode(address(hub1), daiAssetId); (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); + assertEq(performData, checkData, 'performData should equal checkData'); if (upkeepNeeded) { _minter.performUpkeep(performData); @@ -145,7 +154,6 @@ contract FeeSharesMinterBaseTest is Base { vm.prank(ADMIN); _minter.setConfig(address(hub1), daiAssetId, 0); - // Liquidity added, but no fees accrued HubActions.add({ hub: hub1, assetId: daiAssetId, @@ -165,6 +173,20 @@ contract FeeSharesMinterBaseTest is Base { _minter.performUpkeep(performData); } + function test_performUpkeep_revertsWith_ConditionsNotMet_noAddedAssets() public { + vm.prank(ADMIN); + _minter.setConfig(address(hub1), daiAssetId, 0); + + assertEq(hub1.getAddedAssets(daiAssetId), 0, 'Total added assets should be zero'); + + bytes memory checkData = abi.encode(address(hub1), daiAssetId); + (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); + assertFalse(upkeepNeeded, 'checkUpkeep should return false when totalAddedAssets is zero'); + + vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); + _minter.performUpkeep(performData); + } + function test_performUpkeep_revertsWith_ConditionsNotMet_percentThresholdNotMet_withMinShares() public { @@ -220,10 +242,8 @@ contract FeeSharesMinterBaseTest is Base { skipTime: MAX_SKIP_TIME - 110 days }); - // Clear accrued fees _minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); - // Accrue some fees skip(110 days); uint256 fees = hub1.getAssetAccruedFees(daiAssetId); From 55f1f82f200ccab47a6c7040f372a834333bdec2 Mon Sep 17 00:00:00 2001 From: Kogaroshi <25688223+Kogaroshi@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:37:29 +0100 Subject: [PATCH 21/26] fix : renaming --- ...aresMinterBase.sol => FeeSharesMinter.sol} | 14 +-- ...resMinterBase.sol => IFeeSharesMinter.sol} | 6 +- ...MinterBase.t.sol => FeeSharesMinter.t.sol} | 104 +++++++++--------- 3 files changed, 62 insertions(+), 62 deletions(-) rename src/utils/{FeeSharesMinterBase.sol => FeeSharesMinter.sol} (89%) rename src/utils/{IFeeSharesMinterBase.sol => IFeeSharesMinter.sol} (93%) rename tests/contracts/utils/{FeeSharesMinterBase.t.sol => FeeSharesMinter.t.sol} (61%) diff --git a/src/utils/FeeSharesMinterBase.sol b/src/utils/FeeSharesMinter.sol similarity index 89% rename from src/utils/FeeSharesMinterBase.sol rename to src/utils/FeeSharesMinter.sol index 1805cc921..369a83881 100644 --- a/src/utils/FeeSharesMinterBase.sol +++ b/src/utils/FeeSharesMinter.sol @@ -4,20 +4,20 @@ pragma solidity 0.8.28; import {Ownable2Step, Ownable} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; import {Rescuable} from 'src/utils/Rescuable.sol'; -import {IFeeSharesMinterBase} from 'src/utils/IFeeSharesMinterBase.sol'; +import {IFeeSharesMinter} from 'src/utils/IFeeSharesMinter.sol'; import {IHub} from 'src/hub/interfaces/IHub.sol'; -/// @title FeeSharesMinterBase +/// @title FeeSharesMinter /// @author Aave Labs /// @notice Contract to mint fee shares on the Hub when specific conditions are met. -contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { +contract FeeSharesMinter is IFeeSharesMinter, Ownable2Step, Rescuable { mapping(address hub => mapping(uint256 assetId => uint16)) internal _minAccruedFeesPercent; /// @dev Constructor. /// @param owner The owner of the contract. constructor(address owner) Ownable(owner) {} - /// @inheritdoc IFeeSharesMinterBase + /// @inheritdoc IFeeSharesMinter function setConfig( address hub, uint256 assetId, @@ -28,13 +28,13 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { emit ConfigUpdated(hub, assetId, minAccruedFeesPercent); } - /// @inheritdoc IFeeSharesMinterBase + /// @inheritdoc IFeeSharesMinter function performUpkeep(bytes calldata performData) external override { (address hub, uint256 assetId) = abi.decode(performData, (address, uint256)); _performUpkeep(hub, assetId); } - /// @inheritdoc IFeeSharesMinterBase + /// @inheritdoc IFeeSharesMinter function checkUpkeep( bytes calldata checkData ) external view override returns (bool, bytes memory) { @@ -44,7 +44,7 @@ contract FeeSharesMinterBase is IFeeSharesMinterBase, Ownable2Step, Rescuable { return (upkeepNeeded, performData); } - /// @inheritdoc IFeeSharesMinterBase + /// @inheritdoc IFeeSharesMinter function getConfig(address hub, uint256 assetId) external view returns (uint16) { return _minAccruedFeesPercent[hub][assetId]; } diff --git a/src/utils/IFeeSharesMinterBase.sol b/src/utils/IFeeSharesMinter.sol similarity index 93% rename from src/utils/IFeeSharesMinterBase.sol rename to src/utils/IFeeSharesMinter.sol index ab957878d..c8eea2fca 100644 --- a/src/utils/IFeeSharesMinterBase.sol +++ b/src/utils/IFeeSharesMinter.sol @@ -3,10 +3,10 @@ pragma solidity 0.8.28; import {AutomationCompatibleInterface} from 'src/dependencies/chainlink/AutomationCompatibleInterface.sol'; -/// @title IFeeSharesMinterBase +/// @title IFeeSharesMinter /// @author Aave Labs -/// @notice Interface for the FeeSharesMinterBase contract -interface IFeeSharesMinterBase is AutomationCompatibleInterface { +/// @notice Interface for the FeeSharesMinter contract +interface IFeeSharesMinter is AutomationCompatibleInterface { /// @notice Emitted when the configuration for an asset is updated. /// @param hub The address of the hub. /// @param assetId The identifier of the asset. diff --git a/tests/contracts/utils/FeeSharesMinterBase.t.sol b/tests/contracts/utils/FeeSharesMinter.t.sol similarity index 61% rename from tests/contracts/utils/FeeSharesMinterBase.t.sol rename to tests/contracts/utils/FeeSharesMinter.t.sol index 22f90e636..a4f168a29 100644 --- a/tests/contracts/utils/FeeSharesMinterBase.t.sol +++ b/tests/contracts/utils/FeeSharesMinter.t.sol @@ -2,40 +2,40 @@ pragma solidity ^0.8.0; import 'tests/setup/Base.t.sol'; -import {FeeSharesMinterBase} from 'src/utils/FeeSharesMinterBase.sol'; -import {IFeeSharesMinterBase} from 'src/utils/IFeeSharesMinterBase.sol'; +import {FeeSharesMinter} from 'src/utils/FeeSharesMinter.sol'; +import {IFeeSharesMinter} from 'src/utils/IFeeSharesMinter.sol'; -contract FeeSharesMinterBaseTest is Base { +contract FeeSharesMinterTest is Base { using SafeCast for uint256; using PercentageMath for uint256; - FeeSharesMinterBase internal _minter; + FeeSharesMinter internal minter; function setUp() public override { super.setUp(); - _minter = new FeeSharesMinterBase(ADMIN); + minter = new FeeSharesMinter(ADMIN); vm.prank(ADMIN); - accessManager.grantRole(Roles.HUB_FEE_MINTER_ROLE, address(_minter), 0); + accessManager.grantRole(Roles.HUB_FEE_MINTER_ROLE, address(minter), 0); } function test_setConfig_revertsWith_OwnableUnauthorized() public { vm.prank(bob); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, bob)); - _minter.setConfig(address(hub1), daiAssetId, 100); + minter.setConfig(address(hub1), daiAssetId, 100); } function test_fuzz_setConfig(uint16 minAccruedFeesPercent) public { minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); - vm.expectEmit(address(_minter)); - emit IFeeSharesMinterBase.ConfigUpdated(address(hub1), daiAssetId, minAccruedFeesPercent); + vm.expectEmit(address(minter)); + emit IFeeSharesMinter.ConfigUpdated(address(hub1), daiAssetId, minAccruedFeesPercent); vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); + minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); - assertEq(_minter.getConfig(address(hub1), daiAssetId), minAccruedFeesPercent); + assertEq(minter.getConfig(address(hub1), daiAssetId), minAccruedFeesPercent); } function test_setConfig_independentPerPair() public { @@ -43,13 +43,13 @@ contract FeeSharesMinterBaseTest is Base { uint16 config2 = 200; vm.startPrank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, config1); - _minter.setConfig(address(hub1), wethAssetId, config2); + minter.setConfig(address(hub1), daiAssetId, config1); + minter.setConfig(address(hub1), wethAssetId, config2); vm.stopPrank(); - assertEq(_minter.getConfig(address(hub1), daiAssetId), config1, 'daiAssetId config'); - assertEq(_minter.getConfig(address(hub1), wethAssetId), config2, 'wethAssetId config'); - assertEq(_minter.getConfig(address(hub1), usdxAssetId), 0, 'usdxAssetId should be unset'); + assertEq(minter.getConfig(address(hub1), daiAssetId), config1, 'daiAssetId config'); + assertEq(minter.getConfig(address(hub1), wethAssetId), config2, 'wethAssetId config'); + assertEq(minter.getConfig(address(hub1), usdxAssetId), 0, 'usdxAssetId should be unset'); } function test_fuzz_setConfig_revertsWith_InvalidConfig(uint16 minAccruedFeesPercent) public { @@ -60,26 +60,26 @@ contract FeeSharesMinterBaseTest is Base { ).toUint16(); vm.prank(ADMIN); - vm.expectRevert(IFeeSharesMinterBase.InvalidConfig.selector); - _minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); + vm.expectRevert(IFeeSharesMinter.InvalidConfig.selector); + minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); } function test_rescueToken() public { uint256 amount = 1000e18; MockERC20 token = new MockERC20(); - token.mint(address(_minter), amount); + token.mint(address(minter), amount); - assertEq(token.balanceOf(address(_minter)), amount, 'Minter should have tokens'); + assertEq(token.balanceOf(address(minter)), amount, 'Minter should have tokens'); vm.prank(bob); vm.expectRevert(abi.encodeWithSelector(IRescuable.OnlyRescueGuardian.selector)); - _minter.rescueToken(address(token), bob, amount); + minter.rescueToken(address(token), bob, amount); vm.prank(ADMIN); - _minter.rescueToken(address(token), ADMIN, amount); + minter.rescueToken(address(token), ADMIN, amount); - assertEq(token.balanceOf(address(_minter)), 0, 'Minter should be empty'); + assertEq(token.balanceOf(address(minter)), 0, 'Minter should be empty'); assertEq(token.balanceOf(ADMIN), amount, 'Admin should have tokens'); } @@ -87,16 +87,16 @@ contract FeeSharesMinterBaseTest is Base { address newOwner = makeAddr('newOwner'); vm.prank(ADMIN); - _minter.transferOwnership(newOwner); + minter.transferOwnership(newOwner); - assertEq(_minter.owner(), ADMIN, 'Owner should still be ADMIN'); - assertEq(_minter.pendingOwner(), newOwner, 'Pending owner should be newOwner'); + assertEq(minter.owner(), ADMIN, 'Owner should still be ADMIN'); + assertEq(minter.pendingOwner(), newOwner, 'Pending owner should be newOwner'); vm.prank(newOwner); - _minter.acceptOwnership(); + minter.acceptOwnership(); - assertEq(_minter.owner(), newOwner, 'Owner should now be newOwner'); - assertEq(_minter.pendingOwner(), address(0), 'Pending owner should be cleared'); + assertEq(minter.owner(), newOwner, 'Owner should now be newOwner'); + assertEq(minter.pendingOwner(), address(0), 'Pending owner should be cleared'); } function test_performUpkeep() public { @@ -121,7 +121,7 @@ contract FeeSharesMinterBaseTest is Base { .toUint16(); vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); + minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); _addAndDrawLiquidity({ hub: hub1, @@ -136,23 +136,23 @@ contract FeeSharesMinterBaseTest is Base { }); bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); + (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); assertEq(performData, checkData, 'performData should equal checkData'); if (upkeepNeeded) { - _minter.performUpkeep(performData); + minter.performUpkeep(performData); - (bool upkeepNeededAfter, ) = _minter.checkUpkeep(checkData); + (bool upkeepNeededAfter, ) = minter.checkUpkeep(checkData); assertFalse(upkeepNeededAfter, 'checkUpkeep should return false after performUpkeep'); } else { - vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - _minter.performUpkeep(performData); + vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); + minter.performUpkeep(performData); } } function test_performUpkeep_revertsWith_ConditionsNotMet_noFees() public { vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, 0); + minter.setConfig(address(hub1), daiAssetId, 0); HubActions.add({ hub: hub1, @@ -166,25 +166,25 @@ contract FeeSharesMinterBaseTest is Base { assertEq(hub1.getAssetAccruedFees(daiAssetId), 0, 'Fees should be zero'); bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); + (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); assertFalse(upkeepNeeded, 'checkUpkeep should return false with no fees'); - vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - _minter.performUpkeep(performData); + vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); + minter.performUpkeep(performData); } function test_performUpkeep_revertsWith_ConditionsNotMet_noAddedAssets() public { vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, 0); + minter.setConfig(address(hub1), daiAssetId, 0); assertEq(hub1.getAddedAssets(daiAssetId), 0, 'Total added assets should be zero'); bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); + (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); assertFalse(upkeepNeeded, 'checkUpkeep should return false when totalAddedAssets is zero'); - vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - _minter.performUpkeep(performData); + vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); + minter.performUpkeep(performData); } function test_performUpkeep_revertsWith_ConditionsNotMet_percentThresholdNotMet_withMinShares() @@ -192,7 +192,7 @@ contract FeeSharesMinterBaseTest is Base { { uint16 threshold = 50_00; vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, threshold); + minter.setConfig(address(hub1), daiAssetId, threshold); _addAndDrawLiquidity({ hub: hub1, @@ -218,16 +218,16 @@ contract FeeSharesMinterBaseTest is Base { ); bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); + (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); assertFalse(upkeepNeeded, 'checkUpkeep should be false: ratio below threshold'); - vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - _minter.performUpkeep(performData); + vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); + minter.performUpkeep(performData); } function test_performUpkeep_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { vm.prank(ADMIN); - _minter.setConfig(address(hub1), daiAssetId, 0); + minter.setConfig(address(hub1), daiAssetId, 0); // Inflate exchange rate _addAndDrawLiquidity({ @@ -242,7 +242,7 @@ contract FeeSharesMinterBaseTest is Base { skipTime: MAX_SKIP_TIME - 110 days }); - _minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); + minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); skip(110 days); @@ -251,10 +251,10 @@ contract FeeSharesMinterBaseTest is Base { assertEq(hub1.previewAddByAssets(daiAssetId, fees), 0, 'Shares must round to zero'); bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = _minter.checkUpkeep(checkData); + (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); assertFalse(upkeepNeeded, 'checkUpkeep should be false when 0 shares minted'); - vm.expectRevert(IFeeSharesMinterBase.ConditionsNotMet.selector); - _minter.performUpkeep(performData); + vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); + minter.performUpkeep(performData); } } From c0c322d0db856a0529cbaeb49e7843cd105cfa74 Mon Sep 17 00:00:00 2001 From: Kogaroshi <25688223+Kogaroshi@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:38:39 +0100 Subject: [PATCH 22/26] feat : add FeeSharesMinter to deploy engine --- scripts/deploy/AaveV4DeployBatchBase.s.sol | 7 ++++ .../deploy/examples/AaveV4DeployAnvil.s.sol | 1 + .../batches/AaveV4FeeSharesMinterBatch.sol | 25 ++++++++++++ src/deployments/libraries/BatchReports.sol | 5 +++ .../libraries/OrchestrationReports.sol | 2 + .../orchestration/AaveV4DeployBase.sol | 16 ++++++++ .../AaveV4DeployOrchestration.sol | 19 ++++++++++ .../AaveV4FeeSharesMinterDeployProcedure.sol | 24 ++++++++++++ src/deployments/utils/MetadataLogger.sol | 1 + .../utils/libraries/InputUtils.sol | 3 ++ tests/deployments/AaveV4BatchDeployment.t.sol | 38 +++++++++++++++---- .../batches/AaveV4FeeSharesMinterBatch.t.sol | 37 ++++++++++++++++++ .../fork/PostDeploymentVerificationBase.t.sol | 4 ++ .../fork/PostDeploymentVerificationTest.t.sol | 3 ++ .../procedures/ProceduresBase.t.sol | 1 + ...AaveV4FeeSharesMinterDeployProcedure.t.sol | 29 ++++++++++++++ ...4FeeSharesMinterDeployProcedureWrapper.sol | 12 ++++++ .../scripts/AaveV4DeployBatchBaseScript.t.sol | 12 ++++++ tests/utils/BatchTestProcedures.sol | 28 ++++++++++++++ 19 files changed, 259 insertions(+), 8 deletions(-) create mode 100644 src/deployments/batches/AaveV4FeeSharesMinterBatch.sol create mode 100644 src/deployments/procedures/deploy/utils/AaveV4FeeSharesMinterDeployProcedure.sol create mode 100644 tests/deployments/batches/AaveV4FeeSharesMinterBatch.t.sol create mode 100644 tests/deployments/procedures/deploy/utils/AaveV4FeeSharesMinterDeployProcedure.t.sol create mode 100644 tests/helpers/mocks/deployments/procedures/AaveV4FeeSharesMinterDeployProcedureWrapper.sol diff --git a/scripts/deploy/AaveV4DeployBatchBase.s.sol b/scripts/deploy/AaveV4DeployBatchBase.s.sol index 2bf98e934..e17642351 100644 --- a/scripts/deploy/AaveV4DeployBatchBase.s.sol +++ b/scripts/deploy/AaveV4DeployBatchBase.s.sol @@ -117,6 +117,10 @@ abstract contract AaveV4DeployBatchBaseScript is Script { _logWarning(string.concat('treasury spoke owner', message, outcome)); sanitizedInputs.treasurySpokeOwner = deployer; } + if (inputs.feeSharesMinterOwner == address(0)) { + _logWarning(string.concat('fee shares minter owner', message, outcome)); + sanitizedInputs.feeSharesMinterOwner = deployer; + } if (inputs.spokeAdmin == address(0)) { _logWarning(string.concat('spoke admin', message, outcome)); sanitizedInputs.spokeAdmin = deployer; @@ -133,6 +137,9 @@ abstract contract AaveV4DeployBatchBaseScript is Script { _logWarning(string.concat('treasury spoke owner', message, outcome)); sanitizedInputs.treasurySpokeOwner = deployer; + _logWarning(string.concat('fee shares minter owner', message, outcome)); + sanitizedInputs.feeSharesMinterOwner = deployer; + _logWarning(string.concat('proxy admin owner', message, outcome)); sanitizedInputs.proxyAdminOwner = deployer; } diff --git a/scripts/deploy/examples/AaveV4DeployAnvil.s.sol b/scripts/deploy/examples/AaveV4DeployAnvil.s.sol index 279740f7f..9113d0163 100644 --- a/scripts/deploy/examples/AaveV4DeployAnvil.s.sol +++ b/scripts/deploy/examples/AaveV4DeployAnvil.s.sol @@ -45,6 +45,7 @@ contract AaveV4DeployAnvil is AaveV4DeployBatchBaseScript { hubAdmin: address(0), hubConfiguratorAdmin: address(0), treasurySpokeOwner: address(0), + feeSharesMinterOwner: address(0), spokeAdmin: address(0), spokeConfiguratorAdmin: address(1), gatewayOwner: address(2), diff --git a/src/deployments/batches/AaveV4FeeSharesMinterBatch.sol b/src/deployments/batches/AaveV4FeeSharesMinterBatch.sol new file mode 100644 index 000000000..f26763f94 --- /dev/null +++ b/src/deployments/batches/AaveV4FeeSharesMinterBatch.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {BatchReports} from 'src/deployments/libraries/BatchReports.sol'; +import {AaveV4FeeSharesMinterDeployProcedure} from 'src/deployments/procedures/deploy/utils/AaveV4FeeSharesMinterDeployProcedure.sol'; + +/// @title AaveV4FeeSharesMinterBatch +/// @author Aave Labs +/// @notice Deploys the FeeSharesMinter contract, producing a batch report. +contract AaveV4FeeSharesMinterBatch is AaveV4FeeSharesMinterDeployProcedure { + BatchReports.FeeSharesMinterBatchReport internal _report; + + /// @dev Constructor. + /// @param owner_ The owner of the FeeSharesMinter. + /// @param salt_ The CREATE2 salt for deterministic deployment. + constructor(address owner_, bytes32 salt_) { + address feeSharesMinter = _deployFeeSharesMinter({owner: owner_, salt: salt_}); + _report = BatchReports.FeeSharesMinterBatchReport({feeSharesMinter: feeSharesMinter}); + } + + /// @notice Returns the batch deployment report. + function getReport() external view returns (BatchReports.FeeSharesMinterBatchReport memory) { + return _report; + } +} diff --git a/src/deployments/libraries/BatchReports.sol b/src/deployments/libraries/BatchReports.sol index b05070568..38aca11bf 100644 --- a/src/deployments/libraries/BatchReports.sol +++ b/src/deployments/libraries/BatchReports.sol @@ -40,6 +40,11 @@ library BatchReports { address treasurySpoke; } + /// @dev feeSharesMinter The deployed FeeSharesMinter contract address. + struct FeeSharesMinterBatchReport { + address feeSharesMinter; + } + /// @dev signatureGateway The deployed SignatureGateway contract address. /// @dev nativeGateway The deployed NativeTokenGateway contract address. struct GatewaysBatchReport { diff --git a/src/deployments/libraries/OrchestrationReports.sol b/src/deployments/libraries/OrchestrationReports.sol index bd4d2e76c..f34ef20c7 100644 --- a/src/deployments/libraries/OrchestrationReports.sol +++ b/src/deployments/libraries/OrchestrationReports.sol @@ -24,6 +24,7 @@ library OrchestrationReports { /// @dev authorityBatchReport AccessManager deployment report. /// @dev configuratorBatchReport Configurator deployment report. /// @dev treasurySpokeBatchReport TreasurySpoke deployment report. + /// @dev feeSharesMinterBatchReport FeeSharesMinter deployment report. /// @dev spokeInstanceBatchReports Per-spoke deployment reports. /// @dev hubInstanceBatchReports Per-hub deployment reports. /// @dev gatewaysBatchReport Gateway deployment report. @@ -33,6 +34,7 @@ library OrchestrationReports { BatchReports.AuthorityBatchReport authorityBatchReport; BatchReports.ConfiguratorBatchReport configuratorBatchReport; BatchReports.TreasurySpokeBatchReport treasurySpokeBatchReport; + BatchReports.FeeSharesMinterBatchReport feeSharesMinterBatchReport; SpokeDeploymentReport[] spokeInstanceBatchReports; HubDeploymentReport[] hubInstanceBatchReports; BatchReports.GatewaysBatchReport gatewaysBatchReport; diff --git a/src/deployments/orchestration/AaveV4DeployBase.sol b/src/deployments/orchestration/AaveV4DeployBase.sol index 5fef5ee8e..0d42fa6df 100644 --- a/src/deployments/orchestration/AaveV4DeployBase.sol +++ b/src/deployments/orchestration/AaveV4DeployBase.sol @@ -11,6 +11,7 @@ import {AaveV4PositionManagerBatch} from 'src/deployments/batches/AaveV4Position import {AaveV4SpokeInstanceBatch} from 'src/deployments/batches/AaveV4SpokeInstanceBatch.sol'; import {AaveV4TokenizationSpokeBatch} from 'src/deployments/batches/AaveV4TokenizationSpokeBatch.sol'; import {AaveV4TreasurySpokeBatch} from 'src/deployments/batches/AaveV4TreasurySpokeBatch.sol'; +import {AaveV4FeeSharesMinterBatch} from 'src/deployments/batches/AaveV4FeeSharesMinterBatch.sol'; /// @title AaveV4DeployBase Library /// @author Aave Labs @@ -61,6 +62,21 @@ library AaveV4DeployBase { return treasurySpokeBatch.getReport(); } + /// @notice Deploys the FeeSharesMinter batch containing the FeeSharesMinter contract. + /// @param owner The owner of the FeeSharesMinter. + /// @param salt The CREATE2 salt for deterministic deployment. + /// @return The FeeSharesMinter batch report. + function deployFeeSharesMinterBatch( + address owner, + bytes32 salt + ) internal returns (BatchReports.FeeSharesMinterBatchReport memory) { + AaveV4FeeSharesMinterBatch feeSharesMinterBatch = new AaveV4FeeSharesMinterBatch({ + owner_: owner, + salt_: salt + }); + return feeSharesMinterBatch.getReport(); + } + /// @notice Deploys the Hub instance batch containing the Hub proxy, implementation, and IR strategy. /// @param proxyAdminOwner The owner of the proxy admin. /// @param authority The access-control authority for the Hub. diff --git a/src/deployments/orchestration/AaveV4DeployOrchestration.sol b/src/deployments/orchestration/AaveV4DeployOrchestration.sol index 55d963730..b096af23b 100644 --- a/src/deployments/orchestration/AaveV4DeployOrchestration.sol +++ b/src/deployments/orchestration/AaveV4DeployOrchestration.sol @@ -70,6 +70,13 @@ library AaveV4DeployOrchestration { salt: salt }); + // Deploy FeeSharesMinter Batch (single instance for all hubs) + report.feeSharesMinterBatchReport = _deployFeeSharesMinterBatch({ + logger: logger, + feeSharesMinterOwner: deployInputs.feeSharesMinterOwner, + salt: salt + }); + // Validate label uniqueness (duplicate labels produce identical CREATE2 salts) InputUtils.validateUniqueLabels(deployInputs.hubLabels, 'hub'); InputUtils.validateUniqueLabels(deployInputs.spokeLabels, 'spoke'); @@ -340,6 +347,18 @@ library AaveV4DeployOrchestration { return report; } + function _deployFeeSharesMinterBatch( + Logger logger, + address feeSharesMinterOwner, + bytes32 salt + ) internal returns (BatchReports.FeeSharesMinterBatchReport memory report) { + logger.logHeader1('deploying FeeSharesMinterBatch'); + report = AaveV4DeployBase.deployFeeSharesMinterBatch({owner: feeSharesMinterOwner, salt: salt}); + logger.log('FeeSharesMinter', report.feeSharesMinter); + logger.logNewLine(); + return report; + } + function _deployGatewayBatch( Logger logger, address gatewayOwner, diff --git a/src/deployments/procedures/deploy/utils/AaveV4FeeSharesMinterDeployProcedure.sol b/src/deployments/procedures/deploy/utils/AaveV4FeeSharesMinterDeployProcedure.sol new file mode 100644 index 000000000..2701ef293 --- /dev/null +++ b/src/deployments/procedures/deploy/utils/AaveV4FeeSharesMinterDeployProcedure.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {AaveV4DeployProcedureBase} from 'src/deployments/procedures/AaveV4DeployProcedureBase.sol'; +import {Create2Utils} from 'src/deployments/utils/libraries/Create2Utils.sol'; +import {FeeSharesMinter} from 'src/utils/FeeSharesMinter.sol'; + +/// @title AaveV4FeeSharesMinterDeployProcedure +/// @author Aave Labs +/// @notice Deploys the FeeSharesMinter contract. +contract AaveV4FeeSharesMinterDeployProcedure is AaveV4DeployProcedureBase { + /// @notice Deploys a new FeeSharesMinter instance via CREATE2. + /// @param owner The owner of the FeeSharesMinter. + /// @param salt The CREATE2 salt for deterministic deployment. + /// @return The address of the deployed FeeSharesMinter contract. + function _deployFeeSharesMinter(address owner, bytes32 salt) internal returns (address) { + require(owner != address(0), 'invalid owner'); + return + Create2Utils.create2Deploy({ + salt: salt, + bytecode: abi.encodePacked(type(FeeSharesMinter).creationCode, abi.encode(owner)) + }); + } +} diff --git a/src/deployments/utils/MetadataLogger.sol b/src/deployments/utils/MetadataLogger.sol index 017fefa80..f3e4033d9 100644 --- a/src/deployments/utils/MetadataLogger.sol +++ b/src/deployments/utils/MetadataLogger.sol @@ -20,6 +20,7 @@ contract MetadataLogger is Logger { _write('hubConfigurator', report.configuratorBatchReport.hubConfigurator); _write('spokeConfigurator', report.configuratorBatchReport.spokeConfigurator); _write('treasurySpoke', report.treasurySpokeBatchReport.treasurySpoke); + _write('feeSharesMinter', report.feeSharesMinterBatchReport.feeSharesMinter); // Group hubs by property type uint256 hubLen = report.hubInstanceBatchReports.length; diff --git a/src/deployments/utils/libraries/InputUtils.sol b/src/deployments/utils/libraries/InputUtils.sol index da21a62e0..85d95a7ad 100644 --- a/src/deployments/utils/libraries/InputUtils.sol +++ b/src/deployments/utils/libraries/InputUtils.sol @@ -12,6 +12,8 @@ library InputUtils { /// @dev hubConfiguratorAdmin The admin granted all hub configurator roles. Only used when grantRoles is true. /// @dev treasurySpokeOwner The owner of the TreasurySpoke (Ownable). Required at deploy time (constructor arg). /// When grantRoles is `false`, defaults to the deployer; ownership can be transferred post-deployment. + /// @dev feeSharesMinterOwner The owner of the FeeSharesMinter (Ownable). Required at deploy time (constructor arg). + /// When grantRoles is `false`, defaults to the deployer; ownership can be transferred post-deployment. /// @dev spokeAdmin The spoke admin. Only used when grantRoles is true. /// @dev spokeConfiguratorAdmin The admin granted all spoke configurator roles. Only used when grantRoles is true. /// @dev gatewayOwner The owner of the native token and signature gateways. @@ -34,6 +36,7 @@ library InputUtils { address hubAdmin; address hubConfiguratorAdmin; address treasurySpokeOwner; + address feeSharesMinterOwner; address spokeAdmin; address spokeConfiguratorAdmin; address gatewayOwner; diff --git a/tests/deployments/AaveV4BatchDeployment.t.sol b/tests/deployments/AaveV4BatchDeployment.t.sol index a1870f64f..d18be9549 100644 --- a/tests/deployments/AaveV4BatchDeployment.t.sol +++ b/tests/deployments/AaveV4BatchDeployment.t.sol @@ -13,6 +13,7 @@ contract AaveV4BatchDeploymentTest is BatchTestProcedures { hubAdmin: makeAddr('hubAdmin'), hubConfiguratorAdmin: makeAddr('hubConfiguratorAdmin'), treasurySpokeOwner: makeAddr('treasurySpokeOwner'), + feeSharesMinterOwner: makeAddr('feeSharesMinterOwner'), spokeAdmin: makeAddr('spokeAdmin'), spokeConfiguratorAdmin: makeAddr('spokeConfiguratorAdmin'), gatewayOwner: makeAddr('gatewayOwner'), @@ -112,6 +113,20 @@ contract AaveV4BatchDeploymentTest is BatchTestProcedures { } } + /// @dev Reverts as feeSharesMinter is always deployed and owner is required + function testAaveV4BatchDeployment_fuzz_withZeroFeeSharesMinterOwner(bool grantRoles) public { + _inputs.feeSharesMinterOwner = address(0); + _inputs.grantRoles = grantRoles; + + (bool isExpectedError, bytes memory errorMessage) = _getExpectedError(); + if (isExpectedError) { + vm.expectRevert(errorMessage); + this.checkedV4Deployment(); + } else { + checkedV4Deployment(); + } + } + function testAaveV4BatchDeployment_fuzz_withZeroProxyAdminOwner( bool withoutHubs, bool withoutSpokes, @@ -434,6 +449,7 @@ contract AaveV4BatchDeploymentTest is BatchTestProcedures { assertNotEq(deployInputs.accessManagerAdmin, address(0)); assertNotEq(deployInputs.hubConfiguratorAdmin, address(0)); assertNotEq(deployInputs.treasurySpokeOwner, address(0)); + assertNotEq(deployInputs.feeSharesMinterOwner, address(0)); assertNotEq(deployInputs.proxyAdminOwner, address(0)); assertNotEq(deployInputs.spokeConfiguratorAdmin, address(0)); assertNotEq(deployInputs.gatewayOwner, address(0)); @@ -449,11 +465,12 @@ contract AaveV4BatchDeploymentTest is BatchTestProcedures { /// 1. AuthorityBatch (deployer as initial admin) /// 2. ConfiguratorBatch /// 3. TreasurySpokeBatch (treasurySpokeOwner) - /// 4. Hubs (proxyAdminOwner) - /// 5. Spokes (proxyAdminOwner) - /// 6. Gateways (gatewayOwner, nativeWrapper) - /// 7. PositionManagers (positionManagerOwner) - /// 8. Roles (hubAdmin, hubConfiguratorAdmin, spokeAdmin, spokeConfiguratorAdmin, accessManagerAdmin) + /// 4. FeeSharesMinterBatch (feeSharesMinterOwner) + /// 5. Hubs (proxyAdminOwner) + /// 6. Spokes (proxyAdminOwner) + /// 7. Gateways (gatewayOwner, nativeWrapper) + /// 8. PositionManagers (positionManagerOwner) + /// 9. Roles (hubAdmin, hubConfiguratorAdmin, spokeAdmin, spokeConfiguratorAdmin, accessManagerAdmin) function _getExpectedError() internal view @@ -467,7 +484,12 @@ contract AaveV4BatchDeploymentTest is BatchTestProcedures { return (true, bytes('invalid owner')); } - // 3. hubs and spokes require proxy admin owner when deployed + // 3. fee shares minter requires owner + if (_inputs.feeSharesMinterOwner == address(0)) { + return (true, bytes('invalid owner')); + } + + // 5. hubs and spokes require proxy admin owner when deployed if ( (_inputs.hubLabels.length > 0 || _inputs.spokeLabels.length > 0) && _inputs.proxyAdminOwner == address(0) @@ -475,7 +497,7 @@ contract AaveV4BatchDeploymentTest is BatchTestProcedures { return (true, bytes('invalid proxy admin owner')); } - // 4. gateways: native gateway checks nativeWrapper, then owner; + // 6. gateways: native gateway checks nativeWrapper, then owner; // signature gateway checks owner if (_inputs.deployNativeTokenGateway && _inputs.nativeWrapper == address(0)) { return (true, bytes('invalid native wrapper')); @@ -487,7 +509,7 @@ contract AaveV4BatchDeploymentTest is BatchTestProcedures { return (true, bytes('invalid owner')); } - // 5. position managers require owner when deployed + // 7. position managers require owner when deployed if (_inputs.deployPositionManagers && _inputs.positionManagerOwner == address(0)) { return (true, bytes('invalid owner')); } diff --git a/tests/deployments/batches/AaveV4FeeSharesMinterBatch.t.sol b/tests/deployments/batches/AaveV4FeeSharesMinterBatch.t.sol new file mode 100644 index 000000000..0500ff4fe --- /dev/null +++ b/tests/deployments/batches/AaveV4FeeSharesMinterBatch.t.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/deployments/batches/BatchBase.t.sol'; +import {AaveV4FeeSharesMinterBatch} from 'src/deployments/batches/AaveV4FeeSharesMinterBatch.sol'; + +contract AaveV4FeeSharesMinterBatchTest is BatchBaseTest { + AaveV4FeeSharesMinterBatch public feeSharesMinterBatch; + BatchReports.FeeSharesMinterBatchReport public report; + + function setUp() public override { + super.setUp(); + feeSharesMinterBatch = new AaveV4FeeSharesMinterBatch({owner_: admin, salt_: salt}); + report = feeSharesMinterBatch.getReport(); + } + + function test_getReport() public view { + assertNotEq(report.feeSharesMinter, address(0)); + } + + function test_feeSharesMinterOwner() public view { + assertEq(Ownable(report.feeSharesMinter).owner(), admin); + } + + function test_revert_zeroOwner() public { + vm.expectRevert('invalid owner'); + new AaveV4FeeSharesMinterBatch({owner_: address(0), salt_: keccak256('zeroOwnerSalt')}); + } + + function test_differentSaltProducesDifferentAddress() public { + AaveV4FeeSharesMinterBatch newBatch = new AaveV4FeeSharesMinterBatch({ + owner_: admin, + salt_: keccak256('differentSalt') + }); + assertNotEq(report.feeSharesMinter, newBatch.getReport().feeSharesMinter); + } +} diff --git a/tests/deployments/fork/PostDeploymentVerificationBase.t.sol b/tests/deployments/fork/PostDeploymentVerificationBase.t.sol index fc1353bd6..16425d77c 100644 --- a/tests/deployments/fork/PostDeploymentVerificationBase.t.sol +++ b/tests/deployments/fork/PostDeploymentVerificationBase.t.sol @@ -35,6 +35,10 @@ abstract contract PostDeploymentVerificationBase is BatchTestProcedures { '$.spokeConfigurator' ); report.treasurySpokeBatchReport.treasurySpoke = vm.parseJsonAddress(json, '$.treasurySpoke'); + report.feeSharesMinterBatchReport.feeSharesMinter = vm.parseJsonAddress( + json, + '$.feeSharesMinter' + ); report.salt = vm.parseJsonBytes32(json, '$.salt'); // Optional fields (conditionally written by MetadataLogger) diff --git a/tests/deployments/fork/PostDeploymentVerificationTest.t.sol b/tests/deployments/fork/PostDeploymentVerificationTest.t.sol index 69daaa5bf..daba560ef 100644 --- a/tests/deployments/fork/PostDeploymentVerificationTest.t.sol +++ b/tests/deployments/fork/PostDeploymentVerificationTest.t.sol @@ -19,6 +19,7 @@ contract PostDeploymentVerificationTest is PostDeploymentVerificationBase, AaveV address hubAdmin; address hubConfiguratorAdmin; address treasurySpokeOwner; + address feeSharesMinterOwner; address spokeAdmin; address spokeConfiguratorAdmin; address gatewayOwner; @@ -203,6 +204,7 @@ contract PostDeploymentVerificationTest is PostDeploymentVerificationBase, AaveV hubAdmin: params.hubAdmin, hubConfiguratorAdmin: params.hubConfiguratorAdmin, treasurySpokeOwner: params.treasurySpokeOwner, + feeSharesMinterOwner: params.feeSharesMinterOwner, spokeAdmin: params.spokeAdmin, spokeConfiguratorAdmin: params.spokeConfiguratorAdmin, gatewayOwner: params.gatewayOwner, @@ -257,6 +259,7 @@ contract PostDeploymentVerificationTest is PostDeploymentVerificationBase, AaveV hubAdmin: makeAddr('hubAdmin'), hubConfiguratorAdmin: makeAddr('hubConfiguratorAdmin'), treasurySpokeOwner: makeAddr('treasurySpokeOwner'), + feeSharesMinterOwner: makeAddr('feeSharesMinterOwner'), spokeAdmin: makeAddr('spokeAdmin'), spokeConfiguratorAdmin: makeAddr('spokeConfiguratorAdmin'), gatewayOwner: makeAddr('gatewayOwner'), diff --git a/tests/deployments/procedures/ProceduresBase.t.sol b/tests/deployments/procedures/ProceduresBase.t.sol index 1e21a15e5..485ebf46e 100644 --- a/tests/deployments/procedures/ProceduresBase.t.sol +++ b/tests/deployments/procedures/ProceduresBase.t.sol @@ -16,6 +16,7 @@ import {AaveV4AccessManagerEnumerableDeployProcedureWrapper} from 'tests/helpers import {AaveV4AaveOracleDeployProcedureWrapper} from 'tests/helpers/mocks/deployments/procedures/AaveV4AaveOracleDeployProcedureWrapper.sol'; import {AaveV4SpokeDeployProcedureWrapper} from 'tests/helpers/mocks/deployments/procedures/AaveV4SpokeDeployProcedureWrapper.sol'; import {AaveV4TreasurySpokeDeployProcedureWrapper} from 'tests/helpers/mocks/deployments/procedures/AaveV4TreasurySpokeDeployProcedureWrapper.sol'; +import {AaveV4FeeSharesMinterDeployProcedureWrapper} from 'tests/helpers/mocks/deployments/procedures/AaveV4FeeSharesMinterDeployProcedureWrapper.sol'; import {AaveV4SpokeConfiguratorDeployProcedureWrapper} from 'tests/helpers/mocks/deployments/procedures/AaveV4SpokeConfiguratorDeployProcedureWrapper.sol'; import {AaveV4AccessManagerRolesProcedureWrapper} from 'tests/helpers/mocks/deployments/procedures/AaveV4AccessManagerRolesProcedureWrapper.sol'; import {AaveV4SpokeRolesProcedureWrapper} from 'tests/helpers/mocks/deployments/procedures/AaveV4SpokeRolesProcedureWrapper.sol'; diff --git a/tests/deployments/procedures/deploy/utils/AaveV4FeeSharesMinterDeployProcedure.t.sol b/tests/deployments/procedures/deploy/utils/AaveV4FeeSharesMinterDeployProcedure.t.sol new file mode 100644 index 000000000..d01c059d4 --- /dev/null +++ b/tests/deployments/procedures/deploy/utils/AaveV4FeeSharesMinterDeployProcedure.t.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/deployments/procedures/ProceduresBase.t.sol'; + +contract AaveV4FeeSharesMinterDeployProcedureTest is ProceduresBase { + AaveV4FeeSharesMinterDeployProcedureWrapper public aaveV4FeeSharesMinterDeployProcedureWrapper; + + function setUp() public override { + super.setUp(); + aaveV4FeeSharesMinterDeployProcedureWrapper = new AaveV4FeeSharesMinterDeployProcedureWrapper(); + } + + function test_deployFeeSharesMinter() public { + address feeSharesMinter = aaveV4FeeSharesMinterDeployProcedureWrapper.deployFeeSharesMinter( + owner, + salt + ); + assertEq(Ownable(feeSharesMinter).owner(), owner); + } + + function test_deployFeeSharesMinter_reverts() public { + vm.expectRevert('invalid owner'); + aaveV4FeeSharesMinterDeployProcedureWrapper.deployFeeSharesMinter({ + owner: address(0), + salt: salt + }); + } +} diff --git a/tests/helpers/mocks/deployments/procedures/AaveV4FeeSharesMinterDeployProcedureWrapper.sol b/tests/helpers/mocks/deployments/procedures/AaveV4FeeSharesMinterDeployProcedureWrapper.sol new file mode 100644 index 000000000..b823c9781 --- /dev/null +++ b/tests/helpers/mocks/deployments/procedures/AaveV4FeeSharesMinterDeployProcedureWrapper.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {AaveV4FeeSharesMinterDeployProcedure} from 'src/deployments/procedures/deploy/utils/AaveV4FeeSharesMinterDeployProcedure.sol'; + +contract AaveV4FeeSharesMinterDeployProcedureWrapper is AaveV4FeeSharesMinterDeployProcedure { + bool public IS_TEST = true; + + function deployFeeSharesMinter(address owner, bytes32 salt) external returns (address) { + return _deployFeeSharesMinter(owner, salt); + } +} diff --git a/tests/scripts/AaveV4DeployBatchBaseScript.t.sol b/tests/scripts/AaveV4DeployBatchBaseScript.t.sol index 60b769d01..c74338f46 100644 --- a/tests/scripts/AaveV4DeployBatchBaseScript.t.sol +++ b/tests/scripts/AaveV4DeployBatchBaseScript.t.sol @@ -56,6 +56,7 @@ contract AaveV4DeployBatchBaseScriptTest is Test { hubAdmin: makeAddr('hubAdmin'), hubConfiguratorAdmin: makeAddr('hubConfiguratorAdmin'), treasurySpokeOwner: makeAddr('treasurySpokeOwner'), + feeSharesMinterOwner: makeAddr('feeSharesMinterOwner'), spokeAdmin: makeAddr('spokeAdmin'), spokeConfiguratorAdmin: makeAddr('spokeConfiguratorAdmin'), gatewayOwner: makeAddr('gatewayOwner'), @@ -105,6 +106,7 @@ contract AaveV4DeployBatchBaseScriptTest is Test { expected.accessManagerAdmin = _deployer; } else { expected.treasurySpokeOwner = _deployer; + expected.feeSharesMinterOwner = _deployer; expected.proxyAdminOwner = _deployer; } assertEq(sanitized, expected); @@ -123,6 +125,7 @@ contract AaveV4DeployBatchBaseScriptTest is Test { } else { // when grantRoles=false, treasurySpokeOwner and proxyAdminOwner always default to deployer expected.treasurySpokeOwner = _deployer; + expected.feeSharesMinterOwner = _deployer; expected.proxyAdminOwner = _deployer; } assertEq(sanitized, expected); @@ -140,6 +143,7 @@ contract AaveV4DeployBatchBaseScriptTest is Test { expected.spokeAdmin = _deployer; } else { expected.treasurySpokeOwner = _deployer; + expected.feeSharesMinterOwner = _deployer; expected.proxyAdminOwner = _deployer; } assertEq(sanitized, expected); @@ -159,6 +163,7 @@ contract AaveV4DeployBatchBaseScriptTest is Test { expected.hubConfiguratorAdmin = _deployer; } else { expected.treasurySpokeOwner = _deployer; + expected.feeSharesMinterOwner = _deployer; expected.proxyAdminOwner = _deployer; } assertEq(sanitized, expected); @@ -178,6 +183,7 @@ contract AaveV4DeployBatchBaseScriptTest is Test { expected.spokeConfiguratorAdmin = _deployer; } else { expected.treasurySpokeOwner = _deployer; + expected.feeSharesMinterOwner = _deployer; expected.proxyAdminOwner = _deployer; } assertEq(sanitized, expected); @@ -196,6 +202,7 @@ contract AaveV4DeployBatchBaseScriptTest is Test { expected.proxyAdminOwner = _deployer; if (!grantRoles) { expected.treasurySpokeOwner = _deployer; + expected.feeSharesMinterOwner = _deployer; } assertEq(sanitized, expected); } @@ -213,6 +220,7 @@ contract AaveV4DeployBatchBaseScriptTest is Test { // treasurySpokeOwner always defaults to deployer (in both grantRoles branches) expected.treasurySpokeOwner = _deployer; if (!grantRoles) { + expected.feeSharesMinterOwner = _deployer; expected.proxyAdminOwner = _deployer; } assertEq(sanitized, expected); @@ -229,6 +237,7 @@ contract AaveV4DeployBatchBaseScriptTest is Test { expected.gatewayOwner = _deployer; if (!grantRoles) { expected.treasurySpokeOwner = _deployer; + expected.feeSharesMinterOwner = _deployer; expected.proxyAdminOwner = _deployer; } assertEq(sanitized, expected); @@ -247,6 +256,7 @@ contract AaveV4DeployBatchBaseScriptTest is Test { expected.positionManagerOwner = _deployer; if (!grantRoles) { expected.treasurySpokeOwner = _deployer; + expected.feeSharesMinterOwner = _deployer; expected.proxyAdminOwner = _deployer; } assertEq(sanitized, expected); @@ -280,6 +290,7 @@ contract AaveV4DeployBatchBaseScriptTest is Test { expected.nativeWrapper = address(0); if (!grantRoles) { expected.treasurySpokeOwner = _deployer; + expected.feeSharesMinterOwner = _deployer; expected.proxyAdminOwner = _deployer; } assertEq(sanitized, expected); @@ -317,6 +328,7 @@ contract AaveV4DeployBatchBaseScriptTest is Test { assertEq(a.hubAdmin, b.hubAdmin, 'hub admin'); assertEq(a.hubConfiguratorAdmin, b.hubConfiguratorAdmin, 'hub configurator admin'); assertEq(a.treasurySpokeOwner, b.treasurySpokeOwner, 'treasury spoke owner'); + assertEq(a.feeSharesMinterOwner, b.feeSharesMinterOwner, 'fee shares minter owner'); assertEq(a.proxyAdminOwner, b.proxyAdminOwner, 'proxy admin owner'); assertEq(a.spokeConfiguratorAdmin, b.spokeConfiguratorAdmin, 'spoke configurator admin'); assertEq(a.spokeAdmin, b.spokeAdmin, 'spoke admin'); diff --git a/tests/utils/BatchTestProcedures.sol b/tests/utils/BatchTestProcedures.sol index ed33f852e..cac44c600 100644 --- a/tests/utils/BatchTestProcedures.sol +++ b/tests/utils/BatchTestProcedures.sol @@ -151,6 +151,9 @@ contract BatchTestProcedures is Test, Create2TestHelper, WETHDeployProcedure { inputs.treasurySpokeOwner = inputs.treasurySpokeOwner != address(0) ? inputs.treasurySpokeOwner : _deployer; + inputs.feeSharesMinterOwner = inputs.feeSharesMinterOwner != address(0) + ? inputs.feeSharesMinterOwner + : _deployer; inputs.spokeAdmin = inputs.spokeAdmin != address(0) ? inputs.spokeAdmin : _deployer; inputs.proxyAdminOwner = inputs.proxyAdminOwner != address(0) ? inputs.proxyAdminOwner @@ -236,6 +239,7 @@ contract BatchTestProcedures is Test, Create2TestHelper, WETHDeployProcedure { assertNotEq(report.configuratorBatchReport.spokeConfigurator, address(0), 'SpokeConfigurator'); assertNotEq(report.configuratorBatchReport.hubConfigurator, address(0), 'HubConfigurator'); assertNotEq(report.treasurySpokeBatchReport.treasurySpoke, address(0), 'TreasurySpoke'); + assertNotEq(report.feeSharesMinterBatchReport.feeSharesMinter, address(0), 'FeeSharesMinter'); for (uint256 i = 0; i < report.hubInstanceBatchReports.length; i++) { assertNotEq(report.hubInstanceBatchReports[i].report.hubProxy, address(0), 'Hub'); assertNotEq( @@ -362,6 +366,7 @@ contract BatchTestProcedures is Test, Create2TestHelper, WETHDeployProcedure { _checkInterestRateStrategyDeployment({report: hubReport, label: label}); } _checkTreasurySpokeDeployment(report); + _checkFeeSharesMinterDeployment(report); } function _checkHubDeployment( @@ -415,6 +420,16 @@ contract BatchTestProcedures is Test, Create2TestHelper, WETHDeployProcedure { ); } + function _checkFeeSharesMinterDeployment( + OrchestrationReports.FullDeploymentReport memory report + ) internal pure { + assertNotEq( + report.feeSharesMinterBatchReport.feeSharesMinter, + address(0), + 'fee shares minter deployed' + ); + } + function _checkAccessManagerRoles( IAccessManagerEnumerable accessManager, InputUtils.FullDeployInputs memory inputs @@ -645,6 +660,7 @@ contract BatchTestProcedures is Test, Create2TestHelper, WETHDeployProcedure { ); } _checkTreasurySpokeRoles(report.treasurySpokeBatchReport.treasurySpoke, inputs); + _checkFeeSharesMinterRoles(report.feeSharesMinterBatchReport.feeSharesMinter, inputs); for (uint256 i = 0; i < inputs.hubLabels.length; i++) { for (uint256 j = 0; j < _hubFeeMinterRoleSelectors.length; j++) { assertEq( @@ -674,6 +690,17 @@ contract BatchTestProcedures is Test, Create2TestHelper, WETHDeployProcedure { assertEq(Ownable(treasurySpoke).owner(), inputs.treasurySpokeOwner, 'treasury spoke owner'); } + function _checkFeeSharesMinterRoles( + address feeSharesMinter, + InputUtils.FullDeployInputs memory inputs + ) internal view { + assertEq( + Ownable(feeSharesMinter).owner(), + inputs.feeSharesMinterOwner, + 'fee shares minter owner' + ); + } + function _checkHubSelectorRoles( IAccessManagerEnumerable accessManager, OrchestrationReports.FullDeploymentReport memory report, @@ -865,6 +892,7 @@ contract BatchTestProcedures is Test, Create2TestHelper, WETHDeployProcedure { _assertHasCode(report.configuratorBatchReport.hubConfigurator, 'hubConfigurator'); _assertHasCode(report.configuratorBatchReport.spokeConfigurator, 'spokeConfigurator'); _assertHasCode(report.treasurySpokeBatchReport.treasurySpoke, 'treasurySpoke'); + _assertHasCode(report.feeSharesMinterBatchReport.feeSharesMinter, 'feeSharesMinter'); for (uint256 i; i < report.hubInstanceBatchReports.length; i++) { string memory label = report.hubInstanceBatchReports[i].label; From 8bb19cc7f9b0808afbedf9699baed5038a0e0bdb Mon Sep 17 00:00:00 2001 From: YBM <31329384+yan-man@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:35:43 -0500 Subject: [PATCH 23/26] fix: address fee minter comments (#1299) --- src/deployments/README.md | 11 ++++- .../AaveV4DeployOrchestration.sol | 12 +++++- .../utils/libraries/InputUtils.sol | 2 +- src/utils/FeeSharesMinter.sol | 12 ++++-- src/utils/IFeeSharesMinter.sol | 8 ++-- tests/contracts/utils/FeeSharesMinter.t.sol | 41 +++++++++++++++---- tests/setup/Base.t.sol | 3 ++ tests/utils/BatchTestProcedures.sol | 19 ++++++++- 8 files changed, 89 insertions(+), 19 deletions(-) diff --git a/src/deployments/README.md b/src/deployments/README.md index 17cbc2592..fad9579c2 100644 --- a/src/deployments/README.md +++ b/src/deployments/README.md @@ -32,7 +32,7 @@ This deploys `LiquidationLogic` via CREATE2 and writes `FOUNDRY_LIBRARIES` to `. make deploy-contracts ``` -This runs `AaveV4DeployOrchestration.deployAaveV4()`, which deploys batches in order: AccessManager → role labeling → Configurators → Configurator role setup → TreasurySpoke → Hubs → Spokes → Gateways → PositionManagers → role grants → DEFAULT_ADMIN transfer. +This runs `AaveV4DeployOrchestration.deployAaveV4()`, which deploys batches in order: AccessManager → role labeling → Configurators → Configurator role setup → TreasurySpoke → FeeSharesMinter → Hubs → Spokes → Gateways → PositionManagers → role grants → DEFAULT_ADMIN transfer. ### TokenizationSpoke @@ -66,6 +66,7 @@ src/deployments/ AaveV4AuthorityBatch AccessManagerEnumerable AaveV4ConfiguratorBatch HubConfigurator, SpokeConfigurator AaveV4TreasurySpokeBatch TreasurySpoke (single instance, proxy + impl) + AaveV4FeeSharesMinterBatch FeeSharesMinter (single instance for all hubs) AaveV4HubInstanceBatch HubInstance (proxy + impl), InterestRateStrategy AaveV4SpokeInstanceBatch SpokeInstance (proxy + impl), AaveOracle AaveV4GatewayBatch NativeTokenGateway, SignatureGateway @@ -130,7 +131,7 @@ See `Roles.sol` NatSpec for the full role strategy and evolution guidelines. All | --- | --------------------------- | ---------------------------------- | ----------------------------------------------------------------------------- | | 100 | HUB_DOMAIN_ADMIN_ROLE | hubAdmin | (reserved for future use) | | 101 | HUB_CONFIGURATOR_ROLE | hubAdmin, HubConfigurator contract | addAsset, updateAssetConfig, addSpoke, updateSpokeConfig, setInterestRateData | -| 102 | HUB_FEE_MINTER_ROLE | hubAdmin | mintFeeShares | +| 102 | HUB_FEE_MINTER_ROLE | hubAdmin, FeeSharesMinter contract | mintFeeShares | | 103 | HUB_DEFICIT_ELIMINATOR_ROLE | hubAdmin | eliminateDeficit | #### `HubConfigurator` Roles @@ -201,6 +202,11 @@ AaveV4DeployBatchBase.s.sol (Foundry script entry point) | | new AaveV4TreasurySpokeBatch(owner, salt) | | Create2Utils.create2Deploy() --> TreasurySpoke | | + | +-- _deployFeeSharesMinterBatch() + | | AaveV4DeployBase.deployFeeSharesMinterBatch() + | | new AaveV4FeeSharesMinterBatch(owner, salt) + | | Create2Utils.create2Deploy() --> FeeSharesMinter + | | | +-- InputUtils.validateUniqueLabels() revert on duplicate hub or spoke labels | | | +-- _deployHubs(hubLabels) for each hub label: @@ -241,6 +247,7 @@ AaveV4DeployBatchBase.s.sol (Foundry script entry point) | | _grantHubRoles() (if hubLabels.length > 0) | | AaveV4HubRolesProcedure.grantHubAllRoles() hubAdmin gets roles 101-103 | | AaveV4HubRolesProcedure.grantHubRole() HubConfigurator gets role 101 + | | AaveV4HubRolesProcedure.grantHubRole() FeeSharesMinter gets role 102 | | AaveV4HubConfiguratorRolesProcedure.grantHubConfiguratorAllRoles() | | hubConfiguratorAdmin gets role 200 | | _grantSpokeRoles() (if spokeLabels.length > 0) diff --git a/src/deployments/orchestration/AaveV4DeployOrchestration.sol b/src/deployments/orchestration/AaveV4DeployOrchestration.sol index b096af23b..a33dd5780 100644 --- a/src/deployments/orchestration/AaveV4DeployOrchestration.sol +++ b/src/deployments/orchestration/AaveV4DeployOrchestration.sol @@ -397,7 +397,7 @@ library AaveV4DeployOrchestration { return report; } - /// @dev Setup roles for the hub and spoke configurators. + /// @dev Setup roles for the Hub and spoke configurators. function _setupConfiguratorRoles( Logger logger, OrchestrationReports.FullDeploymentReport memory report @@ -457,6 +457,16 @@ library AaveV4DeployOrchestration { admin: report.configuratorBatchReport.hubConfigurator }); + logger.logHeader1( + 'granting HUB_FEE_MINTER_ROLE to', + report.feeSharesMinterBatchReport.feeSharesMinter + ); + AaveV4HubRolesProcedure.grantHubRole({ + accessManager: accessManager, + role: Roles.HUB_FEE_MINTER_ROLE, + admin: report.feeSharesMinterBatchReport.feeSharesMinter + }); + logger.logHeader1('granting HubConfigurator Admin roles to', hubConfiguratorAdmin); AaveV4HubConfiguratorRolesProcedure.grantHubConfiguratorAllRoles({ accessManager: accessManager, diff --git a/src/deployments/utils/libraries/InputUtils.sol b/src/deployments/utils/libraries/InputUtils.sol index 85d95a7ad..f597d46d2 100644 --- a/src/deployments/utils/libraries/InputUtils.sol +++ b/src/deployments/utils/libraries/InputUtils.sol @@ -8,7 +8,7 @@ library InputUtils { /// @dev accessManagerAdmin The default admin of the access manager. Only used when grantRoles is true. /// @dev proxyAdminOwner The owner of the Hub and Spoke ProxyAdmin contracts. Required at deploy time (constructor arg). /// When grantRoles is `false`, defaults to the deployer; ownership can be transferred post-deployment. - /// @dev hubAdmin The admin of the hub. Only used when grantRoles is true. + /// @dev hubAdmin The admin of the Hub. Only used when grantRoles is true. /// @dev hubConfiguratorAdmin The admin granted all hub configurator roles. Only used when grantRoles is true. /// @dev treasurySpokeOwner The owner of the TreasurySpoke (Ownable). Required at deploy time (constructor arg). /// When grantRoles is `false`, defaults to the deployer; ownership can be transferred post-deployment. diff --git a/src/utils/FeeSharesMinter.sol b/src/utils/FeeSharesMinter.sol index 369a83881..6fef952b6 100644 --- a/src/utils/FeeSharesMinter.sol +++ b/src/utils/FeeSharesMinter.sol @@ -23,7 +23,10 @@ contract FeeSharesMinter is IFeeSharesMinter, Ownable2Step, Rescuable { uint256 assetId, uint16 minAccruedFeesPercent ) external onlyOwner { - require(minAccruedFeesPercent <= PercentageMath.PERCENTAGE_FACTOR, InvalidConfig()); + require( + minAccruedFeesPercent > 0 && minAccruedFeesPercent <= PercentageMath.PERCENTAGE_FACTOR, + InvalidConfig() + ); _minAccruedFeesPercent[hub][assetId] = minAccruedFeesPercent; emit ConfigUpdated(hub, assetId, minAccruedFeesPercent); } @@ -50,7 +53,7 @@ contract FeeSharesMinter is IFeeSharesMinter, Ownable2Step, Rescuable { } /// @dev Internal function to execute fee share minting. - /// @param hub The address of the hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. function _performUpkeep(address hub, uint256 assetId) internal virtual { require(_checkUpkeep(hub, assetId), ConditionsNotMet()); @@ -59,11 +62,14 @@ contract FeeSharesMinter is IFeeSharesMinter, Ownable2Step, Rescuable { } /// @dev Internal function to check execution conditions. - /// @param hub The address of the hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. /// @return True if conditions are met, false otherwise. function _checkUpkeep(address hub, uint256 assetId) internal view virtual returns (bool) { uint16 minAccruedFeesPercent = _minAccruedFeesPercent[hub][assetId]; + if (minAccruedFeesPercent == 0) { + return false; + } IHub hubContract = IHub(hub); uint256 accruedFees = hubContract.getAssetAccruedFees(assetId); diff --git a/src/utils/IFeeSharesMinter.sol b/src/utils/IFeeSharesMinter.sol index c8eea2fca..95ff3a518 100644 --- a/src/utils/IFeeSharesMinter.sol +++ b/src/utils/IFeeSharesMinter.sol @@ -8,7 +8,7 @@ import {AutomationCompatibleInterface} from 'src/dependencies/chainlink/Automati /// @notice Interface for the FeeSharesMinter contract interface IFeeSharesMinter is AutomationCompatibleInterface { /// @notice Emitted when the configuration for an asset is updated. - /// @param hub The address of the hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. /// @param minAccruedFeesPercent The new minimum ratio of accrued fees to total added assets, in BPS. event ConfigUpdated(address indexed hub, uint256 indexed assetId, uint16 minAccruedFeesPercent); @@ -20,9 +20,9 @@ interface IFeeSharesMinter is AutomationCompatibleInterface { error InvalidConfig(); /// @notice Sets the minimum accrued fees percent for a specific asset. - /// @param hub The address of the hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. - /// @param minAccruedFeesPercent Minimum ratio of accrued fees to total added assets, in BPS. + /// @param minAccruedFeesPercent Minimum ratio of accrued fees to total added assets, in BPS. Must be greater than zero. function setConfig(address hub, uint256 assetId, uint16 minAccruedFeesPercent) external; /// @notice Chainlink Automation on-chain execution entry point. @@ -39,7 +39,7 @@ interface IFeeSharesMinter is AutomationCompatibleInterface { ) external view returns (bool upkeepNeeded, bytes memory performData); /// @notice Returns the minimum accrued fees percent for a specific asset. - /// @param hub The address of the hub. + /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. /// @return The minimum ratio of accrued fees to total added assets, in BPS. function getConfig(address hub, uint256 assetId) external view returns (uint16); diff --git a/tests/contracts/utils/FeeSharesMinter.t.sol b/tests/contracts/utils/FeeSharesMinter.t.sol index a4f168a29..13abf6743 100644 --- a/tests/contracts/utils/FeeSharesMinter.t.sol +++ b/tests/contracts/utils/FeeSharesMinter.t.sol @@ -2,8 +2,6 @@ pragma solidity ^0.8.0; import 'tests/setup/Base.t.sol'; -import {FeeSharesMinter} from 'src/utils/FeeSharesMinter.sol'; -import {IFeeSharesMinter} from 'src/utils/IFeeSharesMinter.sol'; contract FeeSharesMinterTest is Base { using SafeCast for uint256; @@ -26,7 +24,7 @@ contract FeeSharesMinterTest is Base { } function test_fuzz_setConfig(uint16 minAccruedFeesPercent) public { - minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) + minAccruedFeesPercent = bound(minAccruedFeesPercent, 1, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); vm.expectEmit(address(minter)); @@ -117,7 +115,7 @@ contract FeeSharesMinterTest is Base { addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); drawAmount = bound(drawAmount, 1, addAmount / 2); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); - minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) + minAccruedFeesPercent = bound(minAccruedFeesPercent, 1, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); vm.prank(ADMIN); @@ -150,9 +148,38 @@ contract FeeSharesMinterTest is Base { } } - function test_performUpkeep_revertsWith_ConditionsNotMet_noFees() public { + function test_checkUpkeep_returnsFalse_unconfiguredPair() public { + _addAndDrawLiquidity({ + hub: hub1, + assetId: daiAssetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: 1000e18, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: 900e18, + skipTime: 365 days + }); + + uint256 fees = hub1.getAssetAccruedFees(daiAssetId); + assertGt(fees, 0); + assertGt(hub1.previewAddByAssets(daiAssetId, fees), 0); + assertEq(minter.getConfig(address(hub1), daiAssetId), 0); + + bytes memory checkData = abi.encode(address(hub1), daiAssetId); + (bool upkeepNeeded, ) = minter.checkUpkeep(checkData); + assertFalse(upkeepNeeded, 'checkUpkeep should return false for unconfigured pair'); + } + + function test_setConfig_revertsWith_InvalidConfig_zero() public { vm.prank(ADMIN); + vm.expectRevert(IFeeSharesMinter.InvalidConfig.selector); minter.setConfig(address(hub1), daiAssetId, 0); + } + + function test_performUpkeep_revertsWith_ConditionsNotMet_noFees() public { + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, 1); HubActions.add({ hub: hub1, @@ -175,7 +202,7 @@ contract FeeSharesMinterTest is Base { function test_performUpkeep_revertsWith_ConditionsNotMet_noAddedAssets() public { vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, 0); + minter.setConfig(address(hub1), daiAssetId, 1); assertEq(hub1.getAddedAssets(daiAssetId), 0, 'Total added assets should be zero'); @@ -227,7 +254,7 @@ contract FeeSharesMinterTest is Base { function test_performUpkeep_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, 0); + minter.setConfig(address(hub1), daiAssetId, 1); // Inflate exchange rate _addAndDrawLiquidity({ diff --git a/tests/setup/Base.t.sol b/tests/setup/Base.t.sol index f6f511f8e..94d23ec38 100644 --- a/tests/setup/Base.t.sol +++ b/tests/setup/Base.t.sol @@ -55,6 +55,9 @@ import { IBasicInterestRateStrategy } from 'src/hub/AssetInterestRateStrategy.sol'; +// fee minter +import {FeeSharesMinter, IFeeSharesMinter} from 'src/utils/FeeSharesMinter.sol'; + // spoke import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; import {TreasurySpoke, ITreasurySpoke} from 'src/spoke/TreasurySpoke.sol'; diff --git a/tests/utils/BatchTestProcedures.sol b/tests/utils/BatchTestProcedures.sol index cac44c600..c38439cea 100644 --- a/tests/utils/BatchTestProcedures.sol +++ b/tests/utils/BatchTestProcedures.sol @@ -644,7 +644,7 @@ contract BatchTestProcedures is Test, Create2TestHelper, WETHDeployProcedure { if (inputs.hubLabels.length > 0 && inputs.grantRoles) { assertEq( accessManager.getRoleMemberCount(Roles.HUB_FEE_MINTER_ROLE), - 1, + 2, 'HubFeeMinterRoleCount' ); assertEq( @@ -652,6 +652,11 @@ contract BatchTestProcedures is Test, Create2TestHelper, WETHDeployProcedure { inputs.hubAdmin, 'HubFeeMinterRole member - hub admin' ); + assertEq( + accessManager.getRoleMember(Roles.HUB_FEE_MINTER_ROLE, 1), + report.feeSharesMinterBatchReport.feeSharesMinter, + 'HubFeeMinterRole member - fee shares minter' + ); } else { assertEq( accessManager.getRoleMemberCount(Roles.HUB_FEE_MINTER_ROLE), @@ -679,6 +684,18 @@ contract BatchTestProcedures is Test, Create2TestHelper, WETHDeployProcedure { ); assertEq(allowed, inputs.grantRoles ? true : false, 'HubFeeMinterRole allowed'); assertEq(delay, 0, 'HubFeeMinterRole delay'); + + (bool minterAllowed, uint32 minterDelay) = accessManager.canCall( + report.feeSharesMinterBatchReport.feeSharesMinter, + report.hubInstanceBatchReports[i].report.hubProxy, + _hubFeeMinterRoleSelectors[j] + ); + assertEq( + minterAllowed, + inputs.grantRoles ? true : false, + 'HubFeeMinterRole allowed - fee shares minter' + ); + assertEq(minterDelay, 0, 'HubFeeMinterRole delay - fee shares minter'); } } } From 98c4cc4ed47fd7b7fe5275cfaa5c0cc2f409af6a Mon Sep 17 00:00:00 2001 From: Alexandru Niculae <43644109+avniculae@users.noreply.github.com> Date: Thu, 21 May 2026 16:38:23 +0300 Subject: [PATCH 24/26] fix: address comments --- .../AaveV4DeployOrchestration.sol | 2 +- .../utils/libraries/InputUtils.sol | 2 +- src/utils/FeeSharesMinter.sol | 35 ++- src/utils/IFeeSharesMinter.sol | 17 +- tests/contracts/utils/FeeSharesMinter.t.sol | 269 ++++++++++-------- 5 files changed, 176 insertions(+), 149 deletions(-) diff --git a/src/deployments/orchestration/AaveV4DeployOrchestration.sol b/src/deployments/orchestration/AaveV4DeployOrchestration.sol index a33dd5780..a54c80d9f 100644 --- a/src/deployments/orchestration/AaveV4DeployOrchestration.sol +++ b/src/deployments/orchestration/AaveV4DeployOrchestration.sol @@ -397,7 +397,7 @@ library AaveV4DeployOrchestration { return report; } - /// @dev Setup roles for the Hub and spoke configurators. + /// @dev Setup roles for the hub and spoke configurators. function _setupConfiguratorRoles( Logger logger, OrchestrationReports.FullDeploymentReport memory report diff --git a/src/deployments/utils/libraries/InputUtils.sol b/src/deployments/utils/libraries/InputUtils.sol index f597d46d2..85d95a7ad 100644 --- a/src/deployments/utils/libraries/InputUtils.sol +++ b/src/deployments/utils/libraries/InputUtils.sol @@ -8,7 +8,7 @@ library InputUtils { /// @dev accessManagerAdmin The default admin of the access manager. Only used when grantRoles is true. /// @dev proxyAdminOwner The owner of the Hub and Spoke ProxyAdmin contracts. Required at deploy time (constructor arg). /// When grantRoles is `false`, defaults to the deployer; ownership can be transferred post-deployment. - /// @dev hubAdmin The admin of the Hub. Only used when grantRoles is true. + /// @dev hubAdmin The admin of the hub. Only used when grantRoles is true. /// @dev hubConfiguratorAdmin The admin granted all hub configurator roles. Only used when grantRoles is true. /// @dev treasurySpokeOwner The owner of the TreasurySpoke (Ownable). Required at deploy time (constructor arg). /// When grantRoles is `false`, defaults to the deployer; ownership can be transferred post-deployment. diff --git a/src/utils/FeeSharesMinter.sol b/src/utils/FeeSharesMinter.sol index 6fef952b6..3e4141f6b 100644 --- a/src/utils/FeeSharesMinter.sol +++ b/src/utils/FeeSharesMinter.sol @@ -4,13 +4,15 @@ pragma solidity 0.8.28; import {Ownable2Step, Ownable} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; import {Rescuable} from 'src/utils/Rescuable.sol'; -import {IFeeSharesMinter} from 'src/utils/IFeeSharesMinter.sol'; +import {IFeeSharesMinter, AutomationCompatibleInterface} from 'src/utils/IFeeSharesMinter.sol'; import {IHub} from 'src/hub/interfaces/IHub.sol'; /// @title FeeSharesMinter /// @author Aave Labs /// @notice Contract to mint fee shares on the Hub when specific conditions are met. contract FeeSharesMinter is IFeeSharesMinter, Ownable2Step, Rescuable { + using PercentageMath for uint256; + mapping(address hub => mapping(uint256 assetId => uint16)) internal _minAccruedFeesPercent; /// @dev Constructor. @@ -24,27 +26,26 @@ contract FeeSharesMinter is IFeeSharesMinter, Ownable2Step, Rescuable { uint16 minAccruedFeesPercent ) external onlyOwner { require( - minAccruedFeesPercent > 0 && minAccruedFeesPercent <= PercentageMath.PERCENTAGE_FACTOR, - InvalidConfig() + minAccruedFeesPercent <= PercentageMath.PERCENTAGE_FACTOR, + InvalidConfig(minAccruedFeesPercent) ); + require(assetId < IHub(hub).getAssetCount(), IHub.AssetNotListed()); _minAccruedFeesPercent[hub][assetId] = minAccruedFeesPercent; emit ConfigUpdated(hub, assetId, minAccruedFeesPercent); } - /// @inheritdoc IFeeSharesMinter + /// @dev `performData` must be abi.encoded as (address hub, uint256 assetId). + /// @inheritdoc AutomationCompatibleInterface function performUpkeep(bytes calldata performData) external override { (address hub, uint256 assetId) = abi.decode(performData, (address, uint256)); _performUpkeep(hub, assetId); } - /// @inheritdoc IFeeSharesMinter - function checkUpkeep( - bytes calldata checkData - ) external view override returns (bool, bytes memory) { + /// @dev `checkData` must be abi.encoded as (address hub, uint256 assetId). + /// @inheritdoc AutomationCompatibleInterface + function checkUpkeep(bytes memory checkData) external view override returns (bool, bytes memory) { (address hub, uint256 assetId) = abi.decode(checkData, (address, uint256)); - bool upkeepNeeded = _checkUpkeep(hub, assetId); - bytes memory performData = checkData; - return (upkeepNeeded, performData); + return (_checkUpkeep(hub, assetId), checkData); } /// @inheritdoc IFeeSharesMinter @@ -71,21 +72,19 @@ contract FeeSharesMinter is IFeeSharesMinter, Ownable2Step, Rescuable { return false; } - IHub hubContract = IHub(hub); - uint256 accruedFees = hubContract.getAssetAccruedFees(assetId); - uint256 totalAddedAssets = hubContract.getAddedAssets(assetId); + IHub targetHub = IHub(hub); + uint256 accruedFees = targetHub.getAssetAccruedFees(assetId); + uint256 totalAddedAssets = targetHub.getAddedAssets(assetId); if (totalAddedAssets == 0) { return false; } - if (PercentageMath.percentDivDown(accruedFees, totalAddedAssets) < minAccruedFeesPercent) { + if (accruedFees.percentDivDown(totalAddedAssets) < minAccruedFeesPercent) { return false; } // Ensure at least 1 fee share would be minted - uint256 expectedShares = hubContract.previewAddByAssets(assetId, accruedFees); - - return expectedShares > 0; + return targetHub.previewAddByAssets(assetId, accruedFees) > 0; } /// @inheritdoc Rescuable diff --git a/src/utils/IFeeSharesMinter.sol b/src/utils/IFeeSharesMinter.sol index 95ff3a518..a5e150a5c 100644 --- a/src/utils/IFeeSharesMinter.sol +++ b/src/utils/IFeeSharesMinter.sol @@ -17,27 +17,14 @@ interface IFeeSharesMinter is AutomationCompatibleInterface { error ConditionsNotMet(); /// @notice Thrown when `setConfig` is called with an invalid value. - error InvalidConfig(); + error InvalidConfig(uint16 minAccruedFeesPercent); /// @notice Sets the minimum accrued fees percent for a specific asset. /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. - /// @param minAccruedFeesPercent Minimum ratio of accrued fees to total added assets, in BPS. Must be greater than zero. + /// @param minAccruedFeesPercent Minimum ratio of accrued fees to total added assets, in BPS. Must be at most PercentageMath.PERCENTAGE_FACTOR; set to 0 to disable minting. function setConfig(address hub, uint256 assetId, uint16 minAccruedFeesPercent) external; - /// @notice Chainlink Automation on-chain execution entry point. - /// @dev performData must be abi.encoded as (address hub, uint256 assetId). - /// @inheritdoc AutomationCompatibleInterface - function performUpkeep(bytes calldata performData) external; - - /// @notice Chainlink Automation off-chain simulation check. - /// @dev checkData must be abi.encoded as (address hub, uint256 assetId). - /// @dev Returns whether upkeep is needed and the performData in bytes when conditions are met. - /// @inheritdoc AutomationCompatibleInterface - function checkUpkeep( - bytes calldata checkData - ) external view returns (bool upkeepNeeded, bytes memory performData); - /// @notice Returns the minimum accrued fees percent for a specific asset. /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. diff --git a/tests/contracts/utils/FeeSharesMinter.t.sol b/tests/contracts/utils/FeeSharesMinter.t.sol index 13abf6743..72e4f13db 100644 --- a/tests/contracts/utils/FeeSharesMinter.t.sol +++ b/tests/contracts/utils/FeeSharesMinter.t.sol @@ -24,7 +24,7 @@ contract FeeSharesMinterTest is Base { } function test_fuzz_setConfig(uint16 minAccruedFeesPercent) public { - minAccruedFeesPercent = bound(minAccruedFeesPercent, 1, PercentageMath.PERCENTAGE_FACTOR) + minAccruedFeesPercent = bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) .toUint16(); vm.expectEmit(address(minter)); @@ -50,6 +50,16 @@ contract FeeSharesMinterTest is Base { assertEq(minter.getConfig(address(hub1), usdxAssetId), 0, 'usdxAssetId should be unset'); } + function test_setConfig_zeroDisables() public { + _setupHappyPath(daiAssetId, 1); + + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, 0); + assertEq(minter.getConfig(address(hub1), daiAssetId), 0); + + _assertCheckUpkeepNotNeeded(address(hub1), daiAssetId); + } + function test_fuzz_setConfig_revertsWith_InvalidConfig(uint16 minAccruedFeesPercent) public { minAccruedFeesPercent = bound( minAccruedFeesPercent, @@ -58,10 +68,19 @@ contract FeeSharesMinterTest is Base { ).toUint16(); vm.prank(ADMIN); - vm.expectRevert(IFeeSharesMinter.InvalidConfig.selector); + vm.expectRevert( + abi.encodeWithSelector(IFeeSharesMinter.InvalidConfig.selector, minAccruedFeesPercent) + ); minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); } + function test_setConfig_revertsWith_AssetNotListed() public { + uint256 invalidAssetId = hub1.getAssetCount(); + vm.prank(ADMIN); + vm.expectRevert(IHub.AssetNotListed.selector); + minter.setConfig(address(hub1), invalidAssetId, 100); + } + function test_rescueToken() public { uint256 amount = 1000e18; @@ -97,8 +116,14 @@ contract FeeSharesMinterTest is Base { assertEq(minter.pendingOwner(), address(0), 'Pending owner should be cleared'); } + function test_checkUpkeep_returnsCheckDataAsPerformData() public view { + bytes memory checkData = abi.encode(address(hub1), daiAssetId); + (, bytes memory performData) = minter.checkUpkeep(checkData); + assertEq(performData, checkData); + } + function test_performUpkeep() public { - test_fuzz_performUpkeep({ + _performAndAssertSuccess({ addAmount: 1000e18, drawAmount: 900e18, skipTime: 365 days, @@ -106,157 +131,113 @@ contract FeeSharesMinterTest is Base { }); } - function test_fuzz_performUpkeep( + function test_fuzz_performUpkeep_success( uint256 addAmount, uint256 drawAmount, uint256 skipTime, uint16 minAccruedFeesPercent ) public { - addAmount = bound(addAmount, 2, MAX_SUPPLY_AMOUNT); - drawAmount = bound(drawAmount, 1, addAmount / 2); - skipTime = bound(skipTime, 1, MAX_SKIP_TIME); - minAccruedFeesPercent = bound(minAccruedFeesPercent, 1, PercentageMath.PERCENTAGE_FACTOR) - .toUint16(); - - vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, minAccruedFeesPercent); - - _addAndDrawLiquidity({ - hub: hub1, - assetId: daiAssetId, - addUser: bob, - addSpoke: address(spoke1), - addAmount: addAmount, - drawUser: bob, - drawSpoke: address(spoke1), - drawAmount: drawAmount, - skipTime: skipTime - }); + addAmount = bound(addAmount, 100e18, 1e26); + drawAmount = bound(drawAmount, addAmount / 2, (addAmount * 9) / 10); + skipTime = bound(skipTime, 365 days, MAX_SKIP_TIME); + minAccruedFeesPercent = bound(minAccruedFeesPercent, 1, 10).toUint16(); - bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); - assertEq(performData, checkData, 'performData should equal checkData'); - - if (upkeepNeeded) { - minter.performUpkeep(performData); - - (bool upkeepNeededAfter, ) = minter.checkUpkeep(checkData); - assertFalse(upkeepNeededAfter, 'checkUpkeep should return false after performUpkeep'); - } else { - vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); - minter.performUpkeep(performData); - } + _performAndAssertSuccess(addAmount, drawAmount, skipTime, minAccruedFeesPercent); } function test_checkUpkeep_returnsFalse_unconfiguredPair() public { - _addAndDrawLiquidity({ - hub: hub1, - assetId: daiAssetId, - addUser: bob, - addSpoke: address(spoke1), - addAmount: 1000e18, - drawUser: bob, - drawSpoke: address(spoke1), - drawAmount: 900e18, - skipTime: 365 days - }); - - uint256 fees = hub1.getAssetAccruedFees(daiAssetId); - assertGt(fees, 0); - assertGt(hub1.previewAddByAssets(daiAssetId, fees), 0); - assertEq(minter.getConfig(address(hub1), daiAssetId), 0); + _setupHappyPath(daiAssetId, 1); - bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, ) = minter.checkUpkeep(checkData); - assertFalse(upkeepNeeded, 'checkUpkeep should return false for unconfigured pair'); - } - - function test_setConfig_revertsWith_InvalidConfig_zero() public { - vm.prank(ADMIN); - vm.expectRevert(IFeeSharesMinter.InvalidConfig.selector); - minter.setConfig(address(hub1), daiAssetId, 0); + // wethAssetId was never configured + assertEq(minter.getConfig(address(hub1), wethAssetId), 0); + _assertCheckUpkeepNotNeeded(address(hub1), wethAssetId); } function test_performUpkeep_revertsWith_ConditionsNotMet_noFees() public { - vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, 1); - - HubActions.add({ - hub: hub1, - assetId: daiAssetId, - caller: address(spoke1), - amount: 1000e18, - user: bob - }); - skip(365 days); + _setupHappyPath(daiAssetId, 1); + // Single change: drain accrued fees via performUpkeep + minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); assertEq(hub1.getAssetAccruedFees(daiAssetId), 0, 'Fees should be zero'); - bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); - assertFalse(upkeepNeeded, 'checkUpkeep should return false with no fees'); + _assertCheckUpkeepNotNeeded(address(hub1), daiAssetId); vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); - minter.performUpkeep(performData); + minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); } function test_performUpkeep_revertsWith_ConditionsNotMet_noAddedAssets() public { - vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, 1); + _setupHappyPath(daiAssetId, 1); - assertEq(hub1.getAddedAssets(daiAssetId), 0, 'Total added assets should be zero'); + // Single change: configure a different asset that has no added liquidity + vm.prank(ADMIN); + minter.setConfig(address(hub1), wethAssetId, 1); + assertEq(hub1.getAddedAssets(wethAssetId), 0, 'Total added assets should be zero'); - bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); - assertFalse(upkeepNeeded, 'checkUpkeep should return false when totalAddedAssets is zero'); + _assertCheckUpkeepNotNeeded(address(hub1), wethAssetId); vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); - minter.performUpkeep(performData); + minter.performUpkeep(abi.encode(address(hub1), wethAssetId)); } function test_performUpkeep_revertsWith_ConditionsNotMet_percentThresholdNotMet_withMinShares() public { - uint16 threshold = 50_00; - vm.prank(ADMIN); - minter.setConfig(address(hub1), daiAssetId, threshold); + _setupHappyPath(daiAssetId, 1); - _addAndDrawLiquidity({ - hub: hub1, - assetId: daiAssetId, - addUser: bob, - addSpoke: address(spoke1), - addAmount: 1000e18, - drawUser: bob, - drawSpoke: address(spoke1), - drawAmount: 100e18, - skipTime: 365 days - }); + // Single change: raise threshold above the actual fees/totalAssets ratio + uint16 highThreshold = 50_00; + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, highThreshold); uint256 fees = hub1.getAssetAccruedFees(daiAssetId); - uint256 totalAssets = hub1.getAddedAssets(daiAssetId); - - assertGt(fees, 0, 'Fees must be nonzero'); assertGt(hub1.previewAddByAssets(daiAssetId, fees), 0, 'At least 1 share would be minted'); assertLt( fees, - totalAssets.percentMulDown(threshold), - 'Fees must be < minAccruedFeesPercent of total' + hub1.getAddedAssets(daiAssetId).percentMulDown(highThreshold), + 'Fees must be < threshold of total' ); - bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); - assertFalse(upkeepNeeded, 'checkUpkeep should be false: ratio below threshold'); + _assertCheckUpkeepNotNeeded(address(hub1), daiAssetId); + + vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); + minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); + } + + function test_fuzz_performUpkeep_revertsWith_ConditionsNotMet_thresholdAboveRatio( + uint256 addAmount, + uint256 drawAmount, + uint256 skipTime, + uint16 setupPercent, + uint16 newThreshold + ) public { + addAmount = bound(addAmount, 100e18, 1e26); + drawAmount = bound(drawAmount, addAmount / 2, (addAmount * 9) / 10); + skipTime = bound(skipTime, 365 days, MAX_SKIP_TIME); + setupPercent = bound(setupPercent, 1, 10).toUint16(); + + _setupHappyPath(daiAssetId, setupPercent, addAmount, drawAmount, skipTime); + + uint256 currentRatio = hub1.getAssetAccruedFees(daiAssetId).percentDivDown( + hub1.getAddedAssets(daiAssetId) + ); + vm.assume(currentRatio < PercentageMath.PERCENTAGE_FACTOR); + newThreshold = bound(newThreshold, currentRatio + 1, PercentageMath.PERCENTAGE_FACTOR) + .toUint16(); + + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, newThreshold); + + _assertCheckUpkeepNotNeeded(address(hub1), daiAssetId); vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); - minter.performUpkeep(performData); + minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); } function test_performUpkeep_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { + // Setup tiny amounts so a subsequent mint will inflate exchange rate vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, 1); - - // Inflate exchange rate _addAndDrawLiquidity({ hub: hub1, assetId: daiAssetId, @@ -268,20 +249,80 @@ contract FeeSharesMinterTest is Base { drawAmount: 200 wei, skipTime: MAX_SKIP_TIME - 110 days }); + _assertCheckUpkeepNeeded(address(hub1), daiAssetId); + // Single change: mint to inflate exchange rate, then skip a short period so the + // newly-accrued fees round to zero shares minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); - skip(110 days); uint256 fees = hub1.getAssetAccruedFees(daiAssetId); assertGt(fees, 0, 'Fees must be nonzero'); assertEq(hub1.previewAddByAssets(daiAssetId, fees), 0, 'Shares must round to zero'); - bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (bool upkeepNeeded, bytes memory performData) = minter.checkUpkeep(checkData); - assertFalse(upkeepNeeded, 'checkUpkeep should be false when 0 shares minted'); + _assertCheckUpkeepNotNeeded(address(hub1), daiAssetId); vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); - minter.performUpkeep(performData); + minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); + } + + function _setupHappyPath(uint256 assetId, uint16 minAccruedFeesPercent) internal { + _setupHappyPath(assetId, minAccruedFeesPercent, 1000e18, 900e18, 365 days); + } + + function _setupHappyPath( + uint256 assetId, + uint16 minAccruedFeesPercent, + uint256 addAmount, + uint256 drawAmount, + uint256 skipTime + ) internal { + vm.prank(ADMIN); + minter.setConfig(address(hub1), assetId, minAccruedFeesPercent); + _addAndDrawLiquidity({ + hub: hub1, + assetId: assetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: addAmount, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: drawAmount, + skipTime: skipTime + }); + _assertCheckUpkeepNeeded(address(hub1), assetId); + } + + function _performAndAssertSuccess( + uint256 addAmount, + uint256 drawAmount, + uint256 skipTime, + uint16 minAccruedFeesPercent + ) internal { + _setupHappyPath(daiAssetId, minAccruedFeesPercent, addAmount, drawAmount, skipTime); + + address feeReceiver = _getFeeReceiver(hub1, daiAssetId); + uint256 sharesBefore = hub1.getSpokeAddedShares(daiAssetId, feeReceiver); + uint256 expectedMintedShares = hub1.previewAddByAssets( + daiAssetId, + hub1.getAssetAccruedFees(daiAssetId) + ); + + vm.expectCall(address(hub1), abi.encodeCall(IHub.mintFeeShares, (daiAssetId))); + minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); + + uint256 sharesAfter = hub1.getSpokeAddedShares(daiAssetId, feeReceiver); + assertEq(sharesAfter - sharesBefore, expectedMintedShares, 'fee shares minted to receiver'); + _assertCheckUpkeepNotNeeded(address(hub1), daiAssetId); + } + + function _assertCheckUpkeepNeeded(address hub, uint256 assetId) internal view { + (bool upkeepNeeded, ) = minter.checkUpkeep(abi.encode(hub, assetId)); + assertTrue(upkeepNeeded, 'checkUpkeep should be true'); + } + + function _assertCheckUpkeepNotNeeded(address hub, uint256 assetId) internal view { + (bool upkeepNeeded, ) = minter.checkUpkeep(abi.encode(hub, assetId)); + assertFalse(upkeepNeeded, 'checkUpkeep should be false'); } } From c85cb6f72df719b72e8cd436e1f293b56b5aa350 Mon Sep 17 00:00:00 2001 From: Alexandru Niculae <43644109+avniculae@users.noreply.github.com> Date: Fri, 22 May 2026 14:03:43 +0300 Subject: [PATCH 25/26] feat: migrate FeeSharesMinter to CRE --- snapshots/FeeSharesMinter.Operations.json | 9 + .../AutomationCompatibleInterface.sol | 47 -- src/dependencies/chainlink/IReceiver.sol | 16 + src/utils/FeeSharesMinter.sol | 85 +++- src/utils/IFeeSharesMinter.sol | 77 ++- tests/contracts/utils/FeeSharesMinter.t.sol | 460 +++++++++++++++--- .../gas/FeeSharesMinter.Operations.gas.t.sol | 108 ++++ tests/setup/Base.t.sol | 2 + 8 files changed, 663 insertions(+), 141 deletions(-) create mode 100644 snapshots/FeeSharesMinter.Operations.json delete mode 100644 src/dependencies/chainlink/AutomationCompatibleInterface.sol create mode 100644 src/dependencies/chainlink/IReceiver.sol create mode 100644 tests/gas/FeeSharesMinter.Operations.gas.t.sol diff --git a/snapshots/FeeSharesMinter.Operations.json b/snapshots/FeeSharesMinter.Operations.json new file mode 100644 index 000000000..c6fa837fd --- /dev/null +++ b/snapshots/FeeSharesMinter.Operations.json @@ -0,0 +1,9 @@ +{ + "onReport": "117135", + "setConfig: cold": "59220", + "setConfig: disable": "37308", + "setConfig: warm": "42120", + "setWorkflowConfig: cold": "72976", + "setWorkflowConfig: deactivate": "35952", + "setWorkflowConfig: warm": "35964" +} \ No newline at end of file diff --git a/src/dependencies/chainlink/AutomationCompatibleInterface.sol b/src/dependencies/chainlink/AutomationCompatibleInterface.sol deleted file mode 100644 index 3df531980..000000000 --- a/src/dependencies/chainlink/AutomationCompatibleInterface.sol +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: MIT -// Imported from https://github.com/smartcontractkit/chainlink/blob/v2.22.0/contracts/src/v0.8/automation/interfaces/AutomationCompatibleInterface.sol -pragma solidity ^0.8.0; - -// solhint-disable-next-line interface-starts-with-i -interface AutomationCompatibleInterface { - /** - * @notice method that is simulated by the keepers to see if any work actually - * needs to be performed. This method does does not actually need to be - * executable, and since it is only ever simulated it can consume lots of gas. - * @dev To ensure that it is never called, you may want to add the - * cannotExecute modifier from KeeperBase to your implementation of this - * method. - * @param checkData specified in the upkeep registration so it is always the - * same for a registered upkeep. This can easily be broken down into specific - * arguments using `abi.decode`, so multiple upkeeps can be registered on the - * same contract and easily differentiated by the contract. - * @return upkeepNeeded boolean to indicate whether the keeper should call - * performUpkeep or not. - * @return performData bytes that the keeper should call performUpkeep with, if - * upkeep is needed. If you would like to encode data to decode later, try - * `abi.encode`. - */ - function checkUpkeep( - bytes calldata checkData - ) external returns (bool upkeepNeeded, bytes memory performData); - - /** - * @notice method that is actually executed by the keepers, via the registry. - * The data returned by the checkUpkeep simulation will be passed into - * this method to actually be executed. - * @dev The input to this method should not be trusted, and the caller of the - * method should not even be restricted to any single registry. Anyone should - * be able call it, and the input should be validated, there is no guarantee - * that the data passed in is the performData returned from checkUpkeep. This - * could happen due to malicious keepers, racing keepers, or simply a state - * change while the performUpkeep transaction is waiting for confirmation. - * Always validate the data passed in. - * @param performData is the data which was passed back from the checkData - * simulation. If it is encoded, it can easily be decoded into other types by - * calling `abi.decode`. This data should not be trusted, and should be - * validated against the contract's current state. - */ - function performUpkeep( - bytes calldata performData - ) external; -} diff --git a/src/dependencies/chainlink/IReceiver.sol b/src/dependencies/chainlink/IReceiver.sol new file mode 100644 index 000000000..9a592aa6c --- /dev/null +++ b/src/dependencies/chainlink/IReceiver.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +// Imported from https://github.com/smartcontractkit/chainlink/blob/v2.22.0/contracts/src/v0.8/keystone/interfaces/IReceiver.sol +pragma solidity ^0.8.0; + +import {IERC165} from 'src/dependencies/openzeppelin/IERC165.sol'; + +/// @title IReceiver - receives keystone reports +/// @notice Implementations must support the IReceiver interface through ERC165. +interface IReceiver is IERC165 { + /// @notice Handles incoming keystone reports. + /// @dev If this function call reverts, it can be retried with a higher gas + /// limit. The receiver is responsible for discarding stale reports. + /// @param metadata Report's metadata. + /// @param report Workflow report. + function onReport(bytes calldata metadata, bytes calldata report) external; +} \ No newline at end of file diff --git a/src/utils/FeeSharesMinter.sol b/src/utils/FeeSharesMinter.sol index 3e4141f6b..d1cc8730a 100644 --- a/src/utils/FeeSharesMinter.sol +++ b/src/utils/FeeSharesMinter.sol @@ -2,21 +2,22 @@ pragma solidity 0.8.28; import {Ownable2Step, Ownable} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; +import {IERC165} from 'src/dependencies/openzeppelin/IERC165.sol'; +import {IReceiver} from 'src/dependencies/chainlink/IReceiver.sol'; import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; import {Rescuable} from 'src/utils/Rescuable.sol'; -import {IFeeSharesMinter, AutomationCompatibleInterface} from 'src/utils/IFeeSharesMinter.sol'; +import {IFeeSharesMinter} from 'src/utils/IFeeSharesMinter.sol'; import {IHub} from 'src/hub/interfaces/IHub.sol'; /// @title FeeSharesMinter /// @author Aave Labs -/// @notice Contract to mint fee shares on the Hub when specific conditions are met. +/// @notice Contract that receives signed CRE reports and mints Hub fee shares when conditions are met. contract FeeSharesMinter is IFeeSharesMinter, Ownable2Step, Rescuable { using PercentageMath for uint256; mapping(address hub => mapping(uint256 assetId => uint16)) internal _minAccruedFeesPercent; + mapping(bytes32 workflowId => WorkflowConfig) internal _workflowConfigs; - /// @dev Constructor. - /// @param owner The owner of the contract. constructor(address owner) Ownable(owner) {} /// @inheritdoc IFeeSharesMinter @@ -34,18 +35,28 @@ contract FeeSharesMinter is IFeeSharesMinter, Ownable2Step, Rescuable { emit ConfigUpdated(hub, assetId, minAccruedFeesPercent); } - /// @dev `performData` must be abi.encoded as (address hub, uint256 assetId). - /// @inheritdoc AutomationCompatibleInterface - function performUpkeep(bytes calldata performData) external override { - (address hub, uint256 assetId) = abi.decode(performData, (address, uint256)); - _performUpkeep(hub, assetId); + /// @inheritdoc IFeeSharesMinter + function setWorkflowConfig( + bytes32 workflowId, + WorkflowConfig calldata config + ) external onlyOwner { + _workflowConfigs[workflowId] = config; + emit WorkflowConfigUpdated( + workflowId, + config.forwarder, + config.owner, + config.name, + config.isActive + ); } - /// @dev `checkData` must be abi.encoded as (address hub, uint256 assetId). - /// @inheritdoc AutomationCompatibleInterface - function checkUpkeep(bytes memory checkData) external view override returns (bool, bytes memory) { - (address hub, uint256 assetId) = abi.decode(checkData, (address, uint256)); - return (_checkUpkeep(hub, assetId), checkData); + /// @dev `report` must be abi-encoded as `(address hub, uint256 assetId)`. + /// @inheritdoc IReceiver + function onReport(bytes calldata metadata, bytes calldata report) external override { + _validateWorkflow(metadata); + (address hub, uint256 assetId) = abi.decode(report, (address, uint256)); + require(_canMint(hub, assetId), ConditionsNotMet()); + IHub(hub).mintFeeShares(assetId); } /// @inheritdoc IFeeSharesMinter @@ -53,20 +64,32 @@ contract FeeSharesMinter is IFeeSharesMinter, Ownable2Step, Rescuable { return _minAccruedFeesPercent[hub][assetId]; } - /// @dev Internal function to execute fee share minting. - /// @param hub The address of the Hub. - /// @param assetId The identifier of the asset. - function _performUpkeep(address hub, uint256 assetId) internal virtual { - require(_checkUpkeep(hub, assetId), ConditionsNotMet()); + /// @inheritdoc IFeeSharesMinter + function getWorkflowConfig(bytes32 workflowId) external view returns (WorkflowConfig memory) { + return _workflowConfigs[workflowId]; + } - IHub(hub).mintFeeShares(assetId); + /// @inheritdoc IFeeSharesMinter + function canMint(address hub, uint256 assetId) external view returns (bool) { + return _canMint(hub, assetId); } - /// @dev Internal function to check execution conditions. - /// @param hub The address of the Hub. - /// @param assetId The identifier of the asset. - /// @return True if conditions are met, false otherwise. - function _checkUpkeep(address hub, uint256 assetId) internal view virtual returns (bool) { + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(IReceiver).interfaceId || interfaceId == type(IERC165).interfaceId; + } + + function _validateWorkflow(bytes calldata metadata) internal view virtual { + (bytes32 workflowId, bytes10 workflowName, address workflowOwner) = _decodeMetadata(metadata); + WorkflowConfig storage config = _workflowConfigs[workflowId]; + + require(config.isActive, WorkflowNotActive(workflowId)); + require(msg.sender == config.forwarder, InvalidWorkflowForwarder(msg.sender, config.forwarder)); + require(workflowOwner == config.owner, InvalidWorkflowOwner(workflowOwner, config.owner)); + require(workflowName == config.name, InvalidWorkflowName(workflowName, config.name)); + } + + function _canMint(address hub, uint256 assetId) internal view virtual returns (bool) { uint16 minAccruedFeesPercent = _minAccruedFeesPercent[hub][assetId]; if (minAccruedFeesPercent == 0) { return false; @@ -83,7 +106,6 @@ contract FeeSharesMinter is IFeeSharesMinter, Ownable2Step, Rescuable { return false; } - // Ensure at least 1 fee share would be minted return targetHub.previewAddByAssets(assetId, accruedFees) > 0; } @@ -91,4 +113,15 @@ contract FeeSharesMinter is IFeeSharesMinter, Ownable2Step, Rescuable { function _rescueGuardian() internal view override returns (address) { return owner(); } + + function _decodeMetadata( + bytes memory metadata + ) internal pure returns (bytes32 workflowId, bytes10 workflowName, address workflowOwner) { + assembly { + workflowId := mload(add(metadata, 32)) + workflowName := mload(add(metadata, 64)) + workflowOwner := shr(96, mload(add(metadata, 74))) + } + return (workflowId, workflowName, workflowOwner); + } } diff --git a/src/utils/IFeeSharesMinter.sol b/src/utils/IFeeSharesMinter.sol index a5e150a5c..785e1d596 100644 --- a/src/utils/IFeeSharesMinter.sol +++ b/src/utils/IFeeSharesMinter.sol @@ -1,33 +1,96 @@ // SPDX-License-Identifier: LicenseRef-BUSL pragma solidity 0.8.28; -import {AutomationCompatibleInterface} from 'src/dependencies/chainlink/AutomationCompatibleInterface.sol'; +import {IReceiver} from 'src/dependencies/chainlink/IReceiver.sol'; /// @title IFeeSharesMinter /// @author Aave Labs -/// @notice Interface for the FeeSharesMinter contract -interface IFeeSharesMinter is AutomationCompatibleInterface { - /// @notice Emitted when the configuration for an asset is updated. +/// @notice Interface for the FeeSharesMinter contract. +/// @dev `report` for the inherited `onReport` function must be abi-encoded as `(address hub, uint256 assetId)`. +interface IFeeSharesMinter is IReceiver { + /// @notice Authorization parameters for a CRE workflow allowed to trigger fee share minting. + /// @dev forwarder The Keystone forwarder address authorized to deliver this workflow's reports. + /// @dev owner The expected workflow owner, validated against the report metadata. + /// @dev name The expected workflow name, validated against the report metadata. + /// @dev isActive Whether this workflow is currently allowed to trigger minting. + struct WorkflowConfig { + address forwarder; + address owner; + bytes10 name; + bool isActive; + } + + /// @notice Emitted when the minting threshold for a hub/asset pair is updated. /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. /// @param minAccruedFeesPercent The new minimum ratio of accrued fees to total added assets, in BPS. event ConfigUpdated(address indexed hub, uint256 indexed assetId, uint16 minAccruedFeesPercent); - /// @notice Thrown upon minting when the required conditions are not met. + /// @notice Emitted when a workflow authorization is updated. + /// @param workflowId The CRE workflow identifier. + /// @param forwarder The forwarder address authorized for this workflow. + /// @param owner The workflow owner. + /// @param name The workflow name. + /// @param isActive Whether the workflow is active. + event WorkflowConfigUpdated( + bytes32 indexed workflowId, + address forwarder, + address owner, + bytes10 name, + bool isActive + ); + + /// @notice Thrown when `onReport` is called but the minting threshold conditions are not met. error ConditionsNotMet(); - /// @notice Thrown when `setConfig` is called with an invalid value. + /// @notice Thrown when `setConfig` is called with a value above `PercentageMath.PERCENTAGE_FACTOR`. + /// @param minAccruedFeesPercent The rejected value. error InvalidConfig(uint16 minAccruedFeesPercent); + /// @notice Thrown when `onReport` receives a report for a workflow that is not active or not registered. + /// @param workflowId The workflow identifier carried in the report metadata. + error WorkflowNotActive(bytes32 workflowId); + + /// @notice Thrown when `onReport` is called by an address other than the workflow's configured forwarder. + /// @param received The actual `msg.sender`. + /// @param expected The forwarder address configured for the workflow. + error InvalidWorkflowForwarder(address received, address expected); + + /// @notice Thrown when the workflow owner in the report metadata does not match the configured owner. + /// @param received The owner address carried in the report metadata. + /// @param expected The owner address configured for the workflow. + error InvalidWorkflowOwner(address received, address expected); + + /// @notice Thrown when the workflow name in the report metadata does not match the configured name. + /// @param received The workflow name carried in the report metadata. + /// @param expected The workflow name configured for the workflow. + error InvalidWorkflowName(bytes10 received, bytes10 expected); + /// @notice Sets the minimum accrued fees percent for a specific asset. /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. - /// @param minAccruedFeesPercent Minimum ratio of accrued fees to total added assets, in BPS. Must be at most PercentageMath.PERCENTAGE_FACTOR; set to 0 to disable minting. + /// @param minAccruedFeesPercent Minimum ratio of accrued fees to total added assets, in BPS. Must be at most `PercentageMath.PERCENTAGE_FACTOR`; set to 0 to disable minting. function setConfig(address hub, uint256 assetId, uint16 minAccruedFeesPercent) external; + /// @notice Registers or updates a CRE workflow authorized to trigger fee share minting. + /// @param workflowId The workflow identifier. + /// @param config The workflow configuration. Set `isActive` to false to disable. + function setWorkflowConfig(bytes32 workflowId, WorkflowConfig calldata config) external; + /// @notice Returns the minimum accrued fees percent for a specific asset. /// @param hub The address of the Hub. /// @param assetId The identifier of the asset. /// @return The minimum ratio of accrued fees to total added assets, in BPS. function getConfig(address hub, uint256 assetId) external view returns (uint16); + + /// @notice Returns the configuration for a registered CRE workflow. + /// @param workflowId The workflow identifier. + /// @return The workflow configuration. + function getWorkflowConfig(bytes32 workflowId) external view returns (WorkflowConfig memory); + + /// @notice Returns whether mint conditions are currently met for a specific asset. + /// @param hub The address of the Hub. + /// @param assetId The identifier of the asset. + /// @return True if `onReport` would succeed for this hub/asset pair given current state. + function canMint(address hub, uint256 assetId) external view returns (bool); } diff --git a/tests/contracts/utils/FeeSharesMinter.t.sol b/tests/contracts/utils/FeeSharesMinter.t.sol index 72e4f13db..b8e00f5dd 100644 --- a/tests/contracts/utils/FeeSharesMinter.t.sol +++ b/tests/contracts/utils/FeeSharesMinter.t.sol @@ -9,12 +9,37 @@ contract FeeSharesMinterTest is Base { FeeSharesMinter internal minter; + address internal FORWARDER; + address internal WORKFLOW_OWNER; + bytes10 internal constant WORKFLOW_NAME = bytes10('fee-minter'); + bytes32 internal constant WORKFLOW_ID = keccak256('FeeSharesMinter:test'); + function setUp() public override { super.setUp(); minter = new FeeSharesMinter(ADMIN); vm.prank(ADMIN); accessManager.grantRole(Roles.HUB_FEE_MINTER_ROLE, address(minter), 0); + + FORWARDER = makeAddr('forwarder'); + WORKFLOW_OWNER = makeAddr('workflow-owner'); + + vm.prank(ADMIN); + minter.setWorkflowConfig( + WORKFLOW_ID, + IFeeSharesMinter.WorkflowConfig({ + forwarder: FORWARDER, + owner: WORKFLOW_OWNER, + name: WORKFLOW_NAME, + isActive: true + }) + ); + } + + function test_supportsInterface() public view { + assertTrue(minter.supportsInterface(type(IReceiver).interfaceId)); + assertTrue(minter.supportsInterface(type(IERC165).interfaceId)); + assertFalse(minter.supportsInterface(0xffffffff)); } function test_setConfig_revertsWith_OwnableUnauthorized() public { @@ -57,7 +82,7 @@ contract FeeSharesMinterTest is Base { minter.setConfig(address(hub1), daiAssetId, 0); assertEq(minter.getConfig(address(hub1), daiAssetId), 0); - _assertCheckUpkeepNotNeeded(address(hub1), daiAssetId); + _assertCannotMint(address(hub1), daiAssetId); } function test_fuzz_setConfig_revertsWith_InvalidConfig(uint16 minAccruedFeesPercent) public { @@ -81,48 +106,234 @@ contract FeeSharesMinterTest is Base { minter.setConfig(address(hub1), invalidAssetId, 100); } - function test_rescueToken() public { - uint256 amount = 1000e18; + function test_getConfig_returnsZero_whenUnset() public view { + assertEq(minter.getConfig(address(hub1), daiAssetId), 0); + } - MockERC20 token = new MockERC20(); - token.mint(address(minter), amount); + function test_getConfig_returnsLatestSetValue() public { + vm.startPrank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, 100); + assertEq(minter.getConfig(address(hub1), daiAssetId), 100); - assertEq(token.balanceOf(address(minter)), amount, 'Minter should have tokens'); + minter.setConfig(address(hub1), daiAssetId, 250); + vm.stopPrank(); + assertEq(minter.getConfig(address(hub1), daiAssetId), 250); + } + + function test_getConfig_isIndependentPerHub() public { + address otherHub = makeAddr('other-hub'); + vm.mockCall( + otherHub, + abi.encodeWithSelector(IHub.getAssetCount.selector), + abi.encode(uint256(10)) + ); + + vm.startPrank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, 100); + minter.setConfig(otherHub, daiAssetId, 200); + vm.stopPrank(); + + assertEq(minter.getConfig(address(hub1), daiAssetId), 100); + assertEq(minter.getConfig(otherHub, daiAssetId), 200); + } + + function test_getWorkflowConfig_returnsZeroStruct_whenUnset() public view { + bytes32 unknownId = keccak256('never-registered'); + IFeeSharesMinter.WorkflowConfig memory stored = minter.getWorkflowConfig(unknownId); + assertEq(stored.forwarder, address(0)); + assertEq(stored.owner, address(0)); + assertEq(stored.name, bytes10(0)); + assertFalse(stored.isActive); + } + + function test_getWorkflowConfig_returnsDefault() public view { + IFeeSharesMinter.WorkflowConfig memory stored = minter.getWorkflowConfig(WORKFLOW_ID); + assertEq(stored.forwarder, FORWARDER); + assertEq(stored.owner, WORKFLOW_OWNER); + assertEq(stored.name, WORKFLOW_NAME); + assertTrue(stored.isActive); + } + + function test_getWorkflowConfig_returnsLatestSetValue() public { + address forwarder2 = makeAddr('forwarder-2'); + address owner2 = makeAddr('owner-2'); + bytes10 name2 = bytes10('updated-wf'); + + vm.prank(ADMIN); + minter.setWorkflowConfig( + WORKFLOW_ID, + IFeeSharesMinter.WorkflowConfig({ + forwarder: forwarder2, + owner: owner2, + name: name2, + isActive: false + }) + ); + IFeeSharesMinter.WorkflowConfig memory stored = minter.getWorkflowConfig(WORKFLOW_ID); + assertEq(stored.forwarder, forwarder2); + assertEq(stored.owner, owner2); + assertEq(stored.name, name2); + assertFalse(stored.isActive); + } + + function test_getWorkflowConfig_isIndependentPerWorkflowId() public { + bytes32 otherId = keccak256('workflow-other'); + address otherForwarder = makeAddr('forwarder-other'); + address otherOwner = makeAddr('owner-other'); + bytes10 otherName = bytes10('other-name'); + + vm.prank(ADMIN); + minter.setWorkflowConfig( + otherId, + IFeeSharesMinter.WorkflowConfig({ + forwarder: otherForwarder, + owner: otherOwner, + name: otherName, + isActive: true + }) + ); + + IFeeSharesMinter.WorkflowConfig memory defaultCfg = minter.getWorkflowConfig(WORKFLOW_ID); + IFeeSharesMinter.WorkflowConfig memory otherCfg = minter.getWorkflowConfig(otherId); + + assertEq(defaultCfg.forwarder, FORWARDER); + assertEq(otherCfg.forwarder, otherForwarder); + assertTrue(defaultCfg.forwarder != otherCfg.forwarder); + } + + function test_setWorkflowConfig_revertsWith_OwnableUnauthorized() public { vm.prank(bob); - vm.expectRevert(abi.encodeWithSelector(IRescuable.OnlyRescueGuardian.selector)); - minter.rescueToken(address(token), bob, amount); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, bob)); + minter.setWorkflowConfig(WORKFLOW_ID, _defaultWorkflowConfig()); + } + + function test_setWorkflowConfig_emitsEvent() public { + bytes32 newId = keccak256('another-workflow'); + address newForwarder = makeAddr('forwarder-2'); + address newOwner = makeAddr('owner-2'); + bytes10 newName = bytes10('other-name'); + + vm.expectEmit(address(minter)); + emit IFeeSharesMinter.WorkflowConfigUpdated(newId, newForwarder, newOwner, newName, true); vm.prank(ADMIN); - minter.rescueToken(address(token), ADMIN, amount); + minter.setWorkflowConfig( + newId, + IFeeSharesMinter.WorkflowConfig({ + forwarder: newForwarder, + owner: newOwner, + name: newName, + isActive: true + }) + ); - assertEq(token.balanceOf(address(minter)), 0, 'Minter should be empty'); - assertEq(token.balanceOf(ADMIN), amount, 'Admin should have tokens'); + IFeeSharesMinter.WorkflowConfig memory stored = minter.getWorkflowConfig(newId); + assertEq(stored.forwarder, newForwarder); + assertEq(stored.owner, newOwner); + assertEq(stored.name, newName); + assertTrue(stored.isActive); } - function test_transferOwnership_2Step() public { - address newOwner = makeAddr('newOwner'); + function test_setWorkflowConfig_multipleWorkflows() public { + _setupHappyPath(daiAssetId, 1); + + bytes32 secondId = keccak256('workflow-2'); + address secondForwarder = makeAddr('forwarder-2'); + address secondOwner = makeAddr('owner-2'); + bytes10 secondName = bytes10('second-wf'); vm.prank(ADMIN); - minter.transferOwnership(newOwner); + minter.setWorkflowConfig( + secondId, + IFeeSharesMinter.WorkflowConfig({ + forwarder: secondForwarder, + owner: secondOwner, + name: secondName, + isActive: true + }) + ); - assertEq(minter.owner(), ADMIN, 'Owner should still be ADMIN'); - assertEq(minter.pendingOwner(), newOwner, 'Pending owner should be newOwner'); + // Both workflows can independently submit valid reports + _callOnReport(FORWARDER, WORKFLOW_ID, WORKFLOW_NAME, WORKFLOW_OWNER, address(hub1), daiAssetId); - vm.prank(newOwner); - minter.acceptOwnership(); + _setupHappyPath(wethAssetId, 1); + _callOnReport(secondForwarder, secondId, secondName, secondOwner, address(hub1), wethAssetId); + } - assertEq(minter.owner(), newOwner, 'Owner should now be newOwner'); - assertEq(minter.pendingOwner(), address(0), 'Pending owner should be cleared'); + function test_setWorkflowConfig_canDeactivate() public { + _setupHappyPath(daiAssetId, 1); + + IFeeSharesMinter.WorkflowConfig memory disabled = _defaultWorkflowConfig(); + disabled.isActive = false; + + vm.prank(ADMIN); + minter.setWorkflowConfig(WORKFLOW_ID, disabled); + + vm.expectRevert( + abi.encodeWithSelector(IFeeSharesMinter.WorkflowNotActive.selector, WORKFLOW_ID) + ); + _callOnReportDefault(address(hub1), daiAssetId); + } + + function test_onReport_revertsWith_WorkflowNotActive_unknownWorkflow() public { + _setupHappyPath(daiAssetId, 1); + + bytes32 unknownId = keccak256('unknown'); + vm.expectRevert(abi.encodeWithSelector(IFeeSharesMinter.WorkflowNotActive.selector, unknownId)); + _callOnReport(FORWARDER, unknownId, WORKFLOW_NAME, WORKFLOW_OWNER, address(hub1), daiAssetId); + } + + function test_onReport_revertsWith_InvalidWorkflowForwarder() public { + _setupHappyPath(daiAssetId, 1); + + address wrongForwarder = makeAddr('wrong-forwarder'); + vm.expectRevert( + abi.encodeWithSelector( + IFeeSharesMinter.InvalidWorkflowForwarder.selector, + wrongForwarder, + FORWARDER + ) + ); + _callOnReport( + wrongForwarder, + WORKFLOW_ID, + WORKFLOW_NAME, + WORKFLOW_OWNER, + address(hub1), + daiAssetId + ); + } + + function test_onReport_revertsWith_InvalidWorkflowOwner() public { + _setupHappyPath(daiAssetId, 1); + + address wrongOwner = makeAddr('wrong-owner'); + vm.expectRevert( + abi.encodeWithSelector( + IFeeSharesMinter.InvalidWorkflowOwner.selector, + wrongOwner, + WORKFLOW_OWNER + ) + ); + _callOnReport(FORWARDER, WORKFLOW_ID, WORKFLOW_NAME, wrongOwner, address(hub1), daiAssetId); } - function test_checkUpkeep_returnsCheckDataAsPerformData() public view { - bytes memory checkData = abi.encode(address(hub1), daiAssetId); - (, bytes memory performData) = minter.checkUpkeep(checkData); - assertEq(performData, checkData); + function test_onReport_revertsWith_InvalidWorkflowName() public { + _setupHappyPath(daiAssetId, 1); + + bytes10 wrongName = bytes10('wrong-name'); + vm.expectRevert( + abi.encodeWithSelector( + IFeeSharesMinter.InvalidWorkflowName.selector, + wrongName, + WORKFLOW_NAME + ) + ); + _callOnReport(FORWARDER, WORKFLOW_ID, wrongName, WORKFLOW_OWNER, address(hub1), daiAssetId); } - function test_performUpkeep() public { + function test_onReport_success() public { _performAndAssertSuccess({ addAmount: 1000e18, drawAmount: 900e18, @@ -131,7 +342,7 @@ contract FeeSharesMinterTest is Base { }); } - function test_fuzz_performUpkeep_success( + function test_fuzz_onReport_success( uint256 addAmount, uint256 drawAmount, uint256 skipTime, @@ -145,47 +356,106 @@ contract FeeSharesMinterTest is Base { _performAndAssertSuccess(addAmount, drawAmount, skipTime, minAccruedFeesPercent); } - function test_checkUpkeep_returnsFalse_unconfiguredPair() public { + function test_canMint_returnsTrue_whenAllConditionsMet() public { _setupHappyPath(daiAssetId, 1); + _assertCanMint(address(hub1), daiAssetId); + } - // wethAssetId was never configured - assertEq(minter.getConfig(address(hub1), wethAssetId), 0); - _assertCheckUpkeepNotNeeded(address(hub1), wethAssetId); + function test_canMint_returnsFalse_unconfigured() public view { + // No prior setConfig — the threshold defaults to 0. + assertEq(minter.getConfig(address(hub1), daiAssetId), 0); + _assertCannotMint(address(hub1), daiAssetId); } - function test_performUpkeep_revertsWith_ConditionsNotMet_noFees() public { + function test_canMint_returnsFalse_disabledByZeroConfig() public { _setupHappyPath(daiAssetId, 1); - // Single change: drain accrued fees via performUpkeep - minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, 0); + + _assertCannotMint(address(hub1), daiAssetId); + } + + function test_canMint_returnsFalse_noAddedAssets() public { + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, 1); + + assertEq(hub1.getAddedAssets(daiAssetId), 0); + _assertCannotMint(address(hub1), daiAssetId); + } + + function test_canMint_returnsFalse_ratioBelowThreshold() public { + _setupHappyPath(daiAssetId, 1); + + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, 50_00); + + uint256 fees = hub1.getAssetAccruedFees(daiAssetId); + assertLt( + fees, + hub1.getAddedAssets(daiAssetId).percentMulDown(50_00), + 'fees must be below threshold' + ); + _assertCannotMint(address(hub1), daiAssetId); + } + + function test_canMint_returnsFalse_sharesRoundToZero() public { + // Tiny add/draw so the first mint inflates the exchange rate enough that + // subsequent fees round to zero shares. + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, 1); + _addAndDrawLiquidity({ + hub: hub1, + assetId: daiAssetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: 300 wei, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: 200 wei, + skipTime: MAX_SKIP_TIME - 110 days + }); + _assertCanMint(address(hub1), daiAssetId); + + _callOnReportDefault(address(hub1), daiAssetId); + skip(110 days); + + uint256 fees = hub1.getAssetAccruedFees(daiAssetId); + assertGt(fees, 0, 'fees must accrue'); + assertEq(hub1.previewAddByAssets(daiAssetId, fees), 0, 'shares must round to zero'); + _assertCannotMint(address(hub1), daiAssetId); + } + + function test_onReport_revertsWith_ConditionsNotMet_noFees() public { + _setupHappyPath(daiAssetId, 1); + + _callOnReportDefault(address(hub1), daiAssetId); assertEq(hub1.getAssetAccruedFees(daiAssetId), 0, 'Fees should be zero'); - _assertCheckUpkeepNotNeeded(address(hub1), daiAssetId); + _assertCannotMint(address(hub1), daiAssetId); vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); - minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); + _callOnReportDefault(address(hub1), daiAssetId); } - function test_performUpkeep_revertsWith_ConditionsNotMet_noAddedAssets() public { + function test_onReport_revertsWith_ConditionsNotMet_noAddedAssets() public { _setupHappyPath(daiAssetId, 1); - // Single change: configure a different asset that has no added liquidity vm.prank(ADMIN); minter.setConfig(address(hub1), wethAssetId, 1); assertEq(hub1.getAddedAssets(wethAssetId), 0, 'Total added assets should be zero'); - _assertCheckUpkeepNotNeeded(address(hub1), wethAssetId); + _assertCannotMint(address(hub1), wethAssetId); vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); - minter.performUpkeep(abi.encode(address(hub1), wethAssetId)); + _callOnReportDefault(address(hub1), wethAssetId); } - function test_performUpkeep_revertsWith_ConditionsNotMet_percentThresholdNotMet_withMinShares() + function test_onReport_revertsWith_ConditionsNotMet_percentThresholdNotMet_withMinShares() public { _setupHappyPath(daiAssetId, 1); - // Single change: raise threshold above the actual fees/totalAssets ratio uint16 highThreshold = 50_00; vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, highThreshold); @@ -198,13 +468,13 @@ contract FeeSharesMinterTest is Base { 'Fees must be < threshold of total' ); - _assertCheckUpkeepNotNeeded(address(hub1), daiAssetId); + _assertCannotMint(address(hub1), daiAssetId); vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); - minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); + _callOnReportDefault(address(hub1), daiAssetId); } - function test_fuzz_performUpkeep_revertsWith_ConditionsNotMet_thresholdAboveRatio( + function test_fuzz_onReport_revertsWith_ConditionsNotMet_thresholdAboveRatio( uint256 addAmount, uint256 drawAmount, uint256 skipTime, @@ -228,14 +498,13 @@ contract FeeSharesMinterTest is Base { vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, newThreshold); - _assertCheckUpkeepNotNeeded(address(hub1), daiAssetId); + _assertCannotMint(address(hub1), daiAssetId); vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); - minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); + _callOnReportDefault(address(hub1), daiAssetId); } - function test_performUpkeep_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { - // Setup tiny amounts so a subsequent mint will inflate exchange rate + function test_onReport_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { vm.prank(ADMIN); minter.setConfig(address(hub1), daiAssetId, 1); _addAndDrawLiquidity({ @@ -249,21 +518,56 @@ contract FeeSharesMinterTest is Base { drawAmount: 200 wei, skipTime: MAX_SKIP_TIME - 110 days }); - _assertCheckUpkeepNeeded(address(hub1), daiAssetId); + _assertCanMint(address(hub1), daiAssetId); // Single change: mint to inflate exchange rate, then skip a short period so the // newly-accrued fees round to zero shares - minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); + _callOnReportDefault(address(hub1), daiAssetId); skip(110 days); uint256 fees = hub1.getAssetAccruedFees(daiAssetId); assertGt(fees, 0, 'Fees must be nonzero'); assertEq(hub1.previewAddByAssets(daiAssetId, fees), 0, 'Shares must round to zero'); - _assertCheckUpkeepNotNeeded(address(hub1), daiAssetId); + _assertCannotMint(address(hub1), daiAssetId); vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); - minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); + _callOnReportDefault(address(hub1), daiAssetId); + } + + function test_rescueToken() public { + uint256 amount = 1000e18; + + MockERC20 token = new MockERC20(); + token.mint(address(minter), amount); + + assertEq(token.balanceOf(address(minter)), amount, 'Minter should have tokens'); + + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(IRescuable.OnlyRescueGuardian.selector)); + minter.rescueToken(address(token), bob, amount); + + vm.prank(ADMIN); + minter.rescueToken(address(token), ADMIN, amount); + + assertEq(token.balanceOf(address(minter)), 0, 'Minter should be empty'); + assertEq(token.balanceOf(ADMIN), amount, 'Admin should have tokens'); + } + + function test_transferOwnership_2Step() public { + address newOwner = makeAddr('newOwner'); + + vm.prank(ADMIN); + minter.transferOwnership(newOwner); + + assertEq(minter.owner(), ADMIN, 'Owner should still be ADMIN'); + assertEq(minter.pendingOwner(), newOwner, 'Pending owner should be newOwner'); + + vm.prank(newOwner); + minter.acceptOwnership(); + + assertEq(minter.owner(), newOwner, 'Owner should now be newOwner'); + assertEq(minter.pendingOwner(), address(0), 'Pending owner should be cleared'); } function _setupHappyPath(uint256 assetId, uint16 minAccruedFeesPercent) internal { @@ -290,7 +594,7 @@ contract FeeSharesMinterTest is Base { drawAmount: drawAmount, skipTime: skipTime }); - _assertCheckUpkeepNeeded(address(hub1), assetId); + _assertCanMint(address(hub1), assetId); } function _performAndAssertSuccess( @@ -309,20 +613,54 @@ contract FeeSharesMinterTest is Base { ); vm.expectCall(address(hub1), abi.encodeCall(IHub.mintFeeShares, (daiAssetId))); - minter.performUpkeep(abi.encode(address(hub1), daiAssetId)); + _callOnReportDefault(address(hub1), daiAssetId); uint256 sharesAfter = hub1.getSpokeAddedShares(daiAssetId, feeReceiver); assertEq(sharesAfter - sharesBefore, expectedMintedShares, 'fee shares minted to receiver'); - _assertCheckUpkeepNotNeeded(address(hub1), daiAssetId); + _assertCannotMint(address(hub1), daiAssetId); + } + + function _defaultWorkflowConfig() internal view returns (IFeeSharesMinter.WorkflowConfig memory) { + return + IFeeSharesMinter.WorkflowConfig({ + forwarder: FORWARDER, + owner: WORKFLOW_OWNER, + name: WORKFLOW_NAME, + isActive: true + }); + } + + function _buildMetadata( + bytes32 workflowId, + bytes10 workflowName, + address workflowOwner + ) internal pure returns (bytes memory) { + return abi.encodePacked(workflowId, workflowName, workflowOwner); + } + + function _callOnReport( + address caller, + bytes32 workflowId, + bytes10 workflowName, + address workflowOwner, + address hub, + uint256 assetId + ) internal { + bytes memory metadata = _buildMetadata(workflowId, workflowName, workflowOwner); + bytes memory report = abi.encode(hub, assetId); + vm.prank(caller); + minter.onReport(metadata, report); + } + + function _callOnReportDefault(address hub, uint256 assetId) internal { + _callOnReport(FORWARDER, WORKFLOW_ID, WORKFLOW_NAME, WORKFLOW_OWNER, hub, assetId); } - function _assertCheckUpkeepNeeded(address hub, uint256 assetId) internal view { - (bool upkeepNeeded, ) = minter.checkUpkeep(abi.encode(hub, assetId)); - assertTrue(upkeepNeeded, 'checkUpkeep should be true'); + function _assertCanMint(address hub, uint256 assetId) internal view { + assertTrue(minter.canMint(hub, assetId), 'canMint should be true'); } - function _assertCheckUpkeepNotNeeded(address hub, uint256 assetId) internal view { - (bool upkeepNeeded, ) = minter.checkUpkeep(abi.encode(hub, assetId)); - assertFalse(upkeepNeeded, 'checkUpkeep should be false'); + function _assertCannotMint(address hub, uint256 assetId) internal view { + assertFalse(minter.canMint(hub, assetId), 'canMint should be false'); } } diff --git a/tests/gas/FeeSharesMinter.Operations.gas.t.sol b/tests/gas/FeeSharesMinter.Operations.gas.t.sol new file mode 100644 index 000000000..15bb5d0fe --- /dev/null +++ b/tests/gas/FeeSharesMinter.Operations.gas.t.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/setup/Base.t.sol'; + +/// forge-config: default.isolate = true +contract FeeSharesMinterOperations_Gas_Tests is Base { + FeeSharesMinter internal minter; + + address internal FORWARDER; + address internal WORKFLOW_OWNER; + bytes10 internal constant WORKFLOW_NAME = bytes10('fee-minter'); + bytes32 internal constant WORKFLOW_ID = keccak256('FeeSharesMinter:gas'); + + function setUp() public override { + super.setUp(); + minter = new FeeSharesMinter(ADMIN); + + vm.prank(ADMIN); + accessManager.grantRole(Roles.HUB_FEE_MINTER_ROLE, address(minter), 0); + + FORWARDER = makeAddr('forwarder'); + WORKFLOW_OWNER = makeAddr('workflow-owner'); + } + + function test_setConfig() public { + vm.startPrank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, 100); + vm.snapshotGasLastCall('FeeSharesMinter.Operations', 'setConfig: cold'); + + minter.setConfig(address(hub1), daiAssetId, 250); + vm.snapshotGasLastCall('FeeSharesMinter.Operations', 'setConfig: warm'); + + minter.setConfig(address(hub1), daiAssetId, 0); + vm.snapshotGasLastCall('FeeSharesMinter.Operations', 'setConfig: disable'); + vm.stopPrank(); + } + + function test_setWorkflowConfig() public { + IFeeSharesMinter.WorkflowConfig memory cfg = IFeeSharesMinter.WorkflowConfig({ + forwarder: FORWARDER, + owner: WORKFLOW_OWNER, + name: WORKFLOW_NAME, + isActive: true + }); + + vm.startPrank(ADMIN); + minter.setWorkflowConfig(WORKFLOW_ID, cfg); + vm.snapshotGasLastCall('FeeSharesMinter.Operations', 'setWorkflowConfig: cold'); + + cfg.forwarder = makeAddr('forwarder-2'); + minter.setWorkflowConfig(WORKFLOW_ID, cfg); + vm.snapshotGasLastCall('FeeSharesMinter.Operations', 'setWorkflowConfig: warm'); + + cfg.isActive = false; + minter.setWorkflowConfig(WORKFLOW_ID, cfg); + vm.snapshotGasLastCall('FeeSharesMinter.Operations', 'setWorkflowConfig: deactivate'); + vm.stopPrank(); + } + + function test_onReport() public { + vm.startPrank(ADMIN); + minter.setWorkflowConfig( + WORKFLOW_ID, + IFeeSharesMinter.WorkflowConfig({ + forwarder: FORWARDER, + owner: WORKFLOW_OWNER, + name: WORKFLOW_NAME, + isActive: true + }) + ); + minter.setConfig(address(hub1), daiAssetId, 10); + vm.stopPrank(); + + _addAndDrawLiquidity({ + hub: hub1, + assetId: daiAssetId, + addUser: bob, + addSpoke: address(spoke1), + addAmount: 1000e18, + drawUser: bob, + drawSpoke: address(spoke1), + drawAmount: 900e18, + skipTime: 365 days + }); + + bytes memory metadata = abi.encodePacked(WORKFLOW_ID, WORKFLOW_NAME, WORKFLOW_OWNER); + bytes memory report = abi.encode(address(hub1), daiAssetId); + + address feeReceiver = _getFeeReceiver(hub1, daiAssetId); + uint256 sharesBefore = hub1.getSpokeAddedShares(daiAssetId, feeReceiver); + uint256 expectedMintedShares = hub1.previewAddByAssets( + daiAssetId, + hub1.getAssetAccruedFees(daiAssetId) + ); + assertGt(expectedMintedShares, 0, 'expected minted shares must be greater than 0'); + + vm.prank(FORWARDER); + minter.onReport(metadata, report); + vm.snapshotGasLastCall('FeeSharesMinter.Operations', 'onReport'); + + assertEq( + hub1.getSpokeAddedShares(daiAssetId, feeReceiver) - sharesBefore, + expectedMintedShares, + 'fee shares minted to receiver' + ); + } +} diff --git a/tests/setup/Base.t.sol b/tests/setup/Base.t.sol index 94d23ec38..598658935 100644 --- a/tests/setup/Base.t.sol +++ b/tests/setup/Base.t.sol @@ -57,6 +57,8 @@ import { // fee minter import {FeeSharesMinter, IFeeSharesMinter} from 'src/utils/FeeSharesMinter.sol'; +import {IReceiver} from 'src/dependencies/chainlink/IReceiver.sol'; +import {IERC165} from 'src/dependencies/openzeppelin/IERC165.sol'; // spoke import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; From 9010c1f86e54feb920f21321ee6a613eb0caf0b5 Mon Sep 17 00:00:00 2001 From: Alexandru Niculae <43644109+avniculae@users.noreply.github.com> Date: Fri, 22 May 2026 18:27:25 +0300 Subject: [PATCH 26/26] feat: add FeeSharesMinter support in config engine --- src/config-engine/AaveV4ConfigEngine.sol | 18 + src/config-engine/AaveV4Payload.sol | 59 +++ .../interfaces/IAaveV4ConfigEngine.sol | 49 ++ .../libraries/FeeSharesMinterEngine.sol | 59 +++ tests/config-engine/AaveV4Payload.t.sol | 124 +++++ tests/config-engine/BaseConfigEngine.t.sol | 40 ++ .../config-engine/FeeSharesMinterEngine.t.sol | 465 ++++++++++++++++++ .../config-engine/AaveV4PayloadWrapper.sol | 60 +++ 8 files changed, 874 insertions(+) create mode 100644 src/config-engine/libraries/FeeSharesMinterEngine.sol create mode 100644 tests/config-engine/FeeSharesMinterEngine.t.sol diff --git a/src/config-engine/AaveV4ConfigEngine.sol b/src/config-engine/AaveV4ConfigEngine.sol index 1c8597920..80edd2dd0 100644 --- a/src/config-engine/AaveV4ConfigEngine.sol +++ b/src/config-engine/AaveV4ConfigEngine.sol @@ -5,6 +5,7 @@ import {HubEngine} from 'src/config-engine/libraries/HubEngine.sol'; import {SpokeEngine} from 'src/config-engine/libraries/SpokeEngine.sol'; import {AccessManagerEngine} from 'src/config-engine/libraries/AccessManagerEngine.sol'; import {PositionManagerEngine} from 'src/config-engine/libraries/PositionManagerEngine.sol'; +import {FeeSharesMinterEngine} from 'src/config-engine/libraries/FeeSharesMinterEngine.sol'; import {IAaveV4ConfigEngine} from 'src/config-engine/interfaces/IAaveV4ConfigEngine.sol'; /// @title AaveV4ConfigEngine @@ -126,4 +127,21 @@ contract AaveV4ConfigEngine is IAaveV4ConfigEngine { function executeTargetAdminDelayUpdates(TargetAdminDelayUpdate[] calldata updates) external { AccessManagerEngine.executeTargetAdminDelayUpdates(updates); } + + /// @inheritdoc IAaveV4ConfigEngine + function executeFeeSharesMinterConfigs(FeeSharesMinterConfig[] calldata configs) external { + FeeSharesMinterEngine.executeFeeSharesMinterConfigs(configs); + } + + /// @inheritdoc IAaveV4ConfigEngine + function executeFeeSharesMinterHubConfigs(FeeSharesMinterHubConfig[] calldata configs) external { + FeeSharesMinterEngine.executeFeeSharesMinterHubConfigs(configs); + } + + /// @inheritdoc IAaveV4ConfigEngine + function executeFeeSharesMinterWorkflowConfigs( + FeeSharesMinterWorkflowConfig[] calldata configs + ) external { + FeeSharesMinterEngine.executeFeeSharesMinterWorkflowConfigs(configs); + } } diff --git a/src/config-engine/AaveV4Payload.sol b/src/config-engine/AaveV4Payload.sol index 1f3e33e73..181b5bfbc 100644 --- a/src/config-engine/AaveV4Payload.sol +++ b/src/config-engine/AaveV4Payload.sol @@ -31,6 +31,7 @@ abstract contract AaveV4Payload { _executeHubActions(); _executeSpokeActions(); _executePositionManagerActions(); + _executeFeeSharesMinterActions(); _postExecute(); } @@ -260,6 +261,39 @@ abstract contract AaveV4Payload { return new IAaveV4ConfigEngine.PositionManagerRoleRenouncement[](0); } + /// @notice Returns the per-asset FeeSharesMinter configs to execute. Override to provide configs. + /// @return An array of FeeSharesMinterConfig structs (empty by default). + function feeSharesMinterConfigs() + public + view + virtual + returns (IAaveV4ConfigEngine.FeeSharesMinterConfig[] memory) + { + return new IAaveV4ConfigEngine.FeeSharesMinterConfig[](0); + } + + /// @notice Returns the hub-wide FeeSharesMinter configs to execute. Override to provide configs. + /// @return An array of FeeSharesMinterHubConfig structs (empty by default). + function feeSharesMinterHubConfigs() + public + view + virtual + returns (IAaveV4ConfigEngine.FeeSharesMinterHubConfig[] memory) + { + return new IAaveV4ConfigEngine.FeeSharesMinterHubConfig[](0); + } + + /// @notice Returns the FeeSharesMinter workflow configs to execute. Override to provide configs. + /// @return An array of FeeSharesMinterWorkflowConfig structs (empty by default). + function feeSharesMinterWorkflowConfigs() + public + view + virtual + returns (IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[] memory) + { + return new IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[](0); + } + /// @notice Executes all hub-related configuration actions via delegatecall to the engine. function _executeHubActions() internal { IAaveV4ConfigEngine.AssetListing[] memory listings = hubAssetListings(); @@ -421,6 +455,31 @@ abstract contract AaveV4Payload { } } + /// @notice Executes all FeeSharesMinter configuration actions via delegatecall to the engine. + function _executeFeeSharesMinterActions() internal { + IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[] + memory workflowConfigs = feeSharesMinterWorkflowConfigs(); + if (workflowConfigs.length > 0) { + _delegateCallEngine( + abi.encodeCall(IAaveV4ConfigEngine.executeFeeSharesMinterWorkflowConfigs, (workflowConfigs)) + ); + } + + IAaveV4ConfigEngine.FeeSharesMinterConfig[] memory configs = feeSharesMinterConfigs(); + if (configs.length > 0) { + _delegateCallEngine( + abi.encodeCall(IAaveV4ConfigEngine.executeFeeSharesMinterConfigs, (configs)) + ); + } + + IAaveV4ConfigEngine.FeeSharesMinterHubConfig[] memory hubConfigs = feeSharesMinterHubConfigs(); + if (hubConfigs.length > 0) { + _delegateCallEngine( + abi.encodeCall(IAaveV4ConfigEngine.executeFeeSharesMinterHubConfigs, (hubConfigs)) + ); + } + } + /// @notice Delegatecalls the config engine with the given calldata. /// @param data The ABI-encoded function call to forward to CONFIG_ENGINE. /// @dev Bubbles up any revert reason from the engine call. Assumes the engine functions return no data. diff --git a/src/config-engine/interfaces/IAaveV4ConfigEngine.sol b/src/config-engine/interfaces/IAaveV4ConfigEngine.sol index 2dacaac8e..18236c0c2 100644 --- a/src/config-engine/interfaces/IAaveV4ConfigEngine.sol +++ b/src/config-engine/interfaces/IAaveV4ConfigEngine.sol @@ -6,6 +6,7 @@ import {ISpokeConfigurator} from 'src/spoke/interfaces/ISpokeConfigurator.sol'; import {IHub} from 'src/hub/interfaces/IHub.sol'; import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; import {IAssetInterestRateStrategy} from 'src/hub/interfaces/IAssetInterestRateStrategy.sol'; +import {IFeeSharesMinter} from 'src/utils/IFeeSharesMinter.sol'; /// @title IAaveV4ConfigEngine /// @author Aave Labs @@ -336,6 +337,39 @@ interface IAaveV4ConfigEngine { uint32 newDelay; } + /// @notice Parameters for setting the FeeSharesMinter per-asset minimum accrued fees percent. + /// @dev feeSharesMinter The FeeSharesMinter address. + /// @dev hub The address of the Hub. + /// @dev assetId The identifier of the asset. + /// @dev minAccruedFeesPercent The minimum ratio of accrued fees to total added assets, in BPS. + struct FeeSharesMinterConfig { + address feeSharesMinter; + address hub; + uint256 assetId; + uint16 minAccruedFeesPercent; + } + + /// @notice Parameters for setting the FeeSharesMinter minimum accrued fees percent for every + /// asset currently listed on a Hub. + /// @dev feeSharesMinter The FeeSharesMinter address. + /// @dev hub The address of the Hub. + /// @dev minAccruedFeesPercent The minimum ratio of accrued fees to total added assets, in BPS. + struct FeeSharesMinterHubConfig { + address feeSharesMinter; + address hub; + uint16 minAccruedFeesPercent; + } + + /// @notice Parameters for setting a FeeSharesMinter workflow authorization. + /// @dev feeSharesMinter The FeeSharesMinter address. + /// @dev workflowId The CRE workflow identifier. + /// @dev config The workflow configuration. + struct FeeSharesMinterWorkflowConfig { + address feeSharesMinter; + bytes32 workflowId; + IFeeSharesMinter.WorkflowConfig config; + } + /// @notice Lists new assets on Hubs via the HubConfigurator. /// @param listings The asset listings to execute. function executeHubAssetListings(AssetListing[] calldata listings) external; @@ -429,4 +463,19 @@ interface IAaveV4ConfigEngine { /// @notice Updates target admin delays via AccessManager. /// @param updates The target admin delay updates to execute. function executeTargetAdminDelayUpdates(TargetAdminDelayUpdate[] calldata updates) external; + + /// @notice Sets per-asset minimum accrued fees percent on FeeSharesMinters. + /// @param configs The per-asset FeeSharesMinter configs to execute. + function executeFeeSharesMinterConfigs(FeeSharesMinterConfig[] calldata configs) external; + + /// @notice Sets the minimum accrued fees percent on FeeSharesMinters for every asset currently + /// listed on each Hub. + /// @param configs The hub-wide FeeSharesMinter configs to execute. + function executeFeeSharesMinterHubConfigs(FeeSharesMinterHubConfig[] calldata configs) external; + + /// @notice Registers or updates workflow authorizations on FeeSharesMinters. + /// @param configs The FeeSharesMinter workflow configs to execute. + function executeFeeSharesMinterWorkflowConfigs( + FeeSharesMinterWorkflowConfig[] calldata configs + ) external; } diff --git a/src/config-engine/libraries/FeeSharesMinterEngine.sol b/src/config-engine/libraries/FeeSharesMinterEngine.sol new file mode 100644 index 000000000..5b69e3093 --- /dev/null +++ b/src/config-engine/libraries/FeeSharesMinterEngine.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {IFeeSharesMinter} from 'src/utils/IFeeSharesMinter.sol'; +import {IAaveV4ConfigEngine} from 'src/config-engine/interfaces/IAaveV4ConfigEngine.sol'; + +/// @title FeeSharesMinterEngine +/// @author Aave Labs +/// @notice Library containing FeeSharesMinter configuration logic for AaveV4ConfigEngine. +library FeeSharesMinterEngine { + /// @notice Sets per-asset minimum accrued fees percent on FeeSharesMinters. + /// @param configs The per-asset FeeSharesMinter configs to execute. + function executeFeeSharesMinterConfigs( + IAaveV4ConfigEngine.FeeSharesMinterConfig[] calldata configs + ) external { + uint256 length = configs.length; + for (uint256 i; i < length; ++i) { + IFeeSharesMinter(configs[i].feeSharesMinter).setConfig( + configs[i].hub, + configs[i].assetId, + configs[i].minAccruedFeesPercent + ); + } + } + + /// @notice Sets the minimum accrued fees percent on FeeSharesMinters for every asset currently + /// listed on each Hub. + /// @param configs The hub-wide FeeSharesMinter configs to execute. + function executeFeeSharesMinterHubConfigs( + IAaveV4ConfigEngine.FeeSharesMinterHubConfig[] calldata configs + ) external { + uint256 length = configs.length; + for (uint256 i; i < length; ++i) { + uint256 assetCount = IHub(configs[i].hub).getAssetCount(); + for (uint256 assetId; assetId < assetCount; ++assetId) { + IFeeSharesMinter(configs[i].feeSharesMinter).setConfig( + configs[i].hub, + assetId, + configs[i].minAccruedFeesPercent + ); + } + } + } + + /// @notice Registers or updates workflow authorizations on FeeSharesMinters. + /// @param configs The FeeSharesMinter workflow configs to execute. + function executeFeeSharesMinterWorkflowConfigs( + IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[] calldata configs + ) external { + uint256 length = configs.length; + for (uint256 i; i < length; ++i) { + IFeeSharesMinter(configs[i].feeSharesMinter).setWorkflowConfig( + configs[i].workflowId, + configs[i].config + ); + } + } +} diff --git a/tests/config-engine/AaveV4Payload.t.sol b/tests/config-engine/AaveV4Payload.t.sol index 471319e41..bdf63981f 100644 --- a/tests/config-engine/AaveV4Payload.t.sol +++ b/tests/config-engine/AaveV4Payload.t.sol @@ -5,8 +5,12 @@ import {IAccessManaged} from 'src/dependencies/openzeppelin/IAccessManaged.sol'; import 'tests/config-engine/BaseConfigEngine.t.sol'; contract AaveV4PayloadTest is BaseConfigEngineTest { + bytes32 internal constant WORKFLOW_ID = keccak256('fee-minter:payload-test'); + bytes10 internal constant WORKFLOW_NAME = bytes10('fee-minter'); + AaveV4PayloadWrapper public payload; PositionManagerBaseWrapper public payloadPositionManager; + FeeSharesMinter public payloadMinter; function setUp() public override { super.setUp(); @@ -22,6 +26,8 @@ contract AaveV4PayloadTest is BaseConfigEngineTest { payloadPositionManager = new PositionManagerBaseWrapper(address(payload)); _seedFullEnvironment(); + + payloadMinter = _deployFeeSharesMinter(address(payload)); } function test_execute_emptyPayload_noReverts() public { @@ -1366,4 +1372,122 @@ contract AaveV4PayloadTest is BaseConfigEngineTest { assertEq(sc2.addCap, 2000); assertEq(sc2.drawCap, 1000); } + + function test_execute_feeSharesMinterConfigs() public { + uint256 assetId = _getAssetId(0, TOKEN_WETH); + + IAaveV4ConfigEngine.FeeSharesMinterConfig[] + memory configs = new IAaveV4ConfigEngine.FeeSharesMinterConfig[](1); + configs[0] = IAaveV4ConfigEngine.FeeSharesMinterConfig({ + feeSharesMinter: address(payloadMinter), + hub: address(hub1()), + assetId: assetId, + minAccruedFeesPercent: 3_50 + }); + payload.setFeeSharesMinterConfigs(configs); + + payload.execute(); + + assertEq(payloadMinter.getConfig(address(hub1()), assetId), 3_50); + } + + function test_execute_feeSharesMinterHubConfigs_setsAllAssets() public { + IAaveV4ConfigEngine.FeeSharesMinterHubConfig[] + memory configs = new IAaveV4ConfigEngine.FeeSharesMinterHubConfig[](1); + configs[0] = IAaveV4ConfigEngine.FeeSharesMinterHubConfig({ + feeSharesMinter: address(payloadMinter), + hub: address(hub1()), + minAccruedFeesPercent: 6_25 + }); + payload.setFeeSharesMinterHubConfigs(configs); + + payload.execute(); + + uint256 assetCount = hub1().getAssetCount(); + for (uint256 i; i < assetCount; ++i) { + assertEq(payloadMinter.getConfig(address(hub1()), i), 6_25); + } + } + + function test_execute_feeSharesMinterWorkflowConfigs() public { + IFeeSharesMinter.WorkflowConfig memory cfg = IFeeSharesMinter.WorkflowConfig({ + forwarder: makeAddr('payload-forwarder'), + owner: makeAddr('payload-workflow-owner'), + name: WORKFLOW_NAME, + isActive: true + }); + + IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[] + memory configs = new IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[](1); + configs[0] = IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig({ + feeSharesMinter: address(payloadMinter), + workflowId: WORKFLOW_ID, + config: cfg + }); + payload.setFeeSharesMinterWorkflowConfigs(configs); + + payload.execute(); + + IFeeSharesMinter.WorkflowConfig memory stored = payloadMinter.getWorkflowConfig(WORKFLOW_ID); + assertEq(stored.forwarder, cfg.forwarder); + assertEq(stored.owner, cfg.owner); + assertEq(stored.name, cfg.name); + assertTrue(stored.isActive); + } + + function test_execute_feeSharesMinter_workflowThenConfigs_combined() public { + IFeeSharesMinter.WorkflowConfig memory cfg = IFeeSharesMinter.WorkflowConfig({ + forwarder: makeAddr('payload-forwarder-combined'), + owner: makeAddr('payload-workflow-owner-combined'), + name: WORKFLOW_NAME, + isActive: true + }); + + IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[] + memory workflowConfigs = new IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[](1); + workflowConfigs[0] = IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig({ + feeSharesMinter: address(payloadMinter), + workflowId: WORKFLOW_ID, + config: cfg + }); + payload.setFeeSharesMinterWorkflowConfigs(workflowConfigs); + + IAaveV4ConfigEngine.FeeSharesMinterHubConfig[] + memory hubConfigs = new IAaveV4ConfigEngine.FeeSharesMinterHubConfig[](1); + hubConfigs[0] = IAaveV4ConfigEngine.FeeSharesMinterHubConfig({ + feeSharesMinter: address(payloadMinter), + hub: address(hub1()), + minAccruedFeesPercent: 1_00 + }); + payload.setFeeSharesMinterHubConfigs(hubConfigs); + + payload.execute(); + + IFeeSharesMinter.WorkflowConfig memory stored = payloadMinter.getWorkflowConfig(WORKFLOW_ID); + assertEq(stored.forwarder, cfg.forwarder); + + uint256 assetCount = hub1().getAssetCount(); + for (uint256 i; i < assetCount; ++i) { + assertEq(payloadMinter.getConfig(address(hub1()), i), 1_00); + } + } + + function test_execute_feeSharesMinter_revertsWith_unauthorized() public { + FeeSharesMinter externalMinter = new FeeSharesMinter(ADMIN); + + IAaveV4ConfigEngine.FeeSharesMinterConfig[] + memory configs = new IAaveV4ConfigEngine.FeeSharesMinterConfig[](1); + configs[0] = IAaveV4ConfigEngine.FeeSharesMinterConfig({ + feeSharesMinter: address(externalMinter), + hub: address(hub1()), + assetId: _getAssetId(0, TOKEN_WETH), + minAccruedFeesPercent: 1_00 + }); + payload.setFeeSharesMinterConfigs(configs); + + vm.expectRevert( + abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(payload)) + ); + payload.execute(); + } } diff --git a/tests/config-engine/BaseConfigEngine.t.sol b/tests/config-engine/BaseConfigEngine.t.sol index 50c8e64c6..c48a6b302 100644 --- a/tests/config-engine/BaseConfigEngine.t.sol +++ b/tests/config-engine/BaseConfigEngine.t.sol @@ -33,7 +33,10 @@ import {AccessManagerEngine} from 'src/config-engine/libraries/AccessManagerEngi import {HubEngine} from 'src/config-engine/libraries/HubEngine.sol'; import {SpokeEngine} from 'src/config-engine/libraries/SpokeEngine.sol'; import {PositionManagerEngine} from 'src/config-engine/libraries/PositionManagerEngine.sol'; +import {FeeSharesMinterEngine} from 'src/config-engine/libraries/FeeSharesMinterEngine.sol'; import {TokenizationSpokeDeployer} from 'src/config-engine/libraries/TokenizationSpokeDeployer.sol'; +import {FeeSharesMinter} from 'src/utils/FeeSharesMinter.sol'; +import {IFeeSharesMinter} from 'src/utils/IFeeSharesMinter.sol'; import {WETH9} from 'src/dependencies/weth/WETH9.sol'; import {TestnetERC20} from 'tests/helpers/mocks/TestnetERC20.sol'; @@ -657,6 +660,27 @@ abstract contract BaseConfigEngineTest is Test, Create2TestHelper { arr[0] = item; } + function _toFeeSharesMinterConfigArray( + IAaveV4ConfigEngine.FeeSharesMinterConfig memory item + ) internal pure returns (IAaveV4ConfigEngine.FeeSharesMinterConfig[] memory arr) { + arr = new IAaveV4ConfigEngine.FeeSharesMinterConfig[](1); + arr[0] = item; + } + + function _toFeeSharesMinterHubConfigArray( + IAaveV4ConfigEngine.FeeSharesMinterHubConfig memory item + ) internal pure returns (IAaveV4ConfigEngine.FeeSharesMinterHubConfig[] memory arr) { + arr = new IAaveV4ConfigEngine.FeeSharesMinterHubConfig[](1); + arr[0] = item; + } + + function _toFeeSharesMinterWorkflowConfigArray( + IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig memory item + ) internal pure returns (IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[] memory arr) { + arr = new IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[](1); + arr[0] = item; + } + function _keepCurrentIrData() internal pure @@ -670,4 +694,20 @@ abstract contract BaseConfigEngineTest is Test, Create2TestHelper { rateGrowthAfterOptimal: EngineFlags.KEEP_CURRENT_UINT32 }); } + + function _deployFeeSharesMinter(address owner) internal returns (FeeSharesMinter minter) { + minter = new FeeSharesMinter(owner); + vm.prank(ADMIN); + accessManager.grantRole(Roles.HUB_FEE_MINTER_ROLE, address(minter), 0); + } + + function _defaultWorkflowConfig() internal pure returns (IFeeSharesMinter.WorkflowConfig memory) { + return + IFeeSharesMinter.WorkflowConfig({ + forwarder: address(0xF0F0), + owner: address(0x0FF0), + name: bytes10('fee-minter'), + isActive: true + }); + } } diff --git a/tests/config-engine/FeeSharesMinterEngine.t.sol b/tests/config-engine/FeeSharesMinterEngine.t.sol new file mode 100644 index 000000000..a07f12bfa --- /dev/null +++ b/tests/config-engine/FeeSharesMinterEngine.t.sol @@ -0,0 +1,465 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/config-engine/BaseConfigEngine.t.sol'; + +import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; + +contract FeeSharesMinterEngineTest is BaseConfigEngineTest { + FeeSharesMinter internal minter; + + bytes32 internal constant WORKFLOW_ID = keccak256('fee-minter:test'); + bytes32 internal constant WORKFLOW_ID_2 = keccak256('fee-minter:test-2'); + bytes10 internal constant WORKFLOW_NAME = bytes10('fee-minter'); + + function setUp() public override { + super.setUp(); + _seedFullEnvironment(); + minter = _deployFeeSharesMinter(address(engine)); + } + + function test_executeFeeSharesMinterConfigs_setsValue() public { + uint256 assetId = _getAssetId(0, TOKEN_WETH); + + vm.expectCall( + address(minter), + abi.encodeCall(IFeeSharesMinter.setConfig, (address(hub1()), assetId, 1_25)) + ); + + vm.expectEmit(address(minter)); + emit IFeeSharesMinter.ConfigUpdated(address(hub1()), assetId, 1_25); + + engine.executeFeeSharesMinterConfigs( + _toFeeSharesMinterConfigArray(_buildConfig(assetId, 1_25)) + ); + + assertEq(minter.getConfig(address(hub1()), assetId), 1_25); + } + + function test_executeFeeSharesMinterConfigs_zeroDisables() public { + uint256 assetId = _getAssetId(0, TOKEN_DAI); + + engine.executeFeeSharesMinterConfigs( + _toFeeSharesMinterConfigArray(_buildConfig(assetId, 5_00)) + ); + assertEq(minter.getConfig(address(hub1()), assetId), 5_00); + + engine.executeFeeSharesMinterConfigs(_toFeeSharesMinterConfigArray(_buildConfig(assetId, 0))); + assertEq(minter.getConfig(address(hub1()), assetId), 0); + } + + function test_executeFeeSharesMinterConfigs_multiple() public { + IAaveV4ConfigEngine.FeeSharesMinterConfig[] + memory configs = new IAaveV4ConfigEngine.FeeSharesMinterConfig[](3); + configs[0] = _buildConfig(_getAssetId(0, TOKEN_WETH), 1_00); + configs[1] = _buildConfig(_getAssetId(0, TOKEN_USDX), 2_00); + configs[2] = _buildConfig(_getAssetId(0, TOKEN_DAI), 3_00); + + engine.executeFeeSharesMinterConfigs(configs); + + assertEq(minter.getConfig(address(hub1()), _getAssetId(0, TOKEN_WETH)), 1_00); + assertEq(minter.getConfig(address(hub1()), _getAssetId(0, TOKEN_USDX)), 2_00); + assertEq(minter.getConfig(address(hub1()), _getAssetId(0, TOKEN_DAI)), 3_00); + assertEq(minter.getConfig(address(hub1()), _getAssetId(0, TOKEN_WBTC)), 0); + } + + function test_executeFeeSharesMinterConfigs_acrossHubs() public { + IAaveV4ConfigEngine.FeeSharesMinterConfig[] + memory configs = new IAaveV4ConfigEngine.FeeSharesMinterConfig[](2); + configs[0] = IAaveV4ConfigEngine.FeeSharesMinterConfig({ + feeSharesMinter: address(minter), + hub: address(hub1()), + assetId: _getAssetId(0, TOKEN_WETH), + minAccruedFeesPercent: 1_00 + }); + configs[1] = IAaveV4ConfigEngine.FeeSharesMinterConfig({ + feeSharesMinter: address(minter), + hub: address(hub2()), + assetId: _getAssetId(1, TOKEN_WETH), + minAccruedFeesPercent: 4_00 + }); + + engine.executeFeeSharesMinterConfigs(configs); + + assertEq(minter.getConfig(address(hub1()), _getAssetId(0, TOKEN_WETH)), 1_00); + assertEq(minter.getConfig(address(hub2()), _getAssetId(1, TOKEN_WETH)), 4_00); + } + + function test_executeFeeSharesMinterConfigs_emptyArray_noOp() public { + vm.recordLogs(); + engine.executeFeeSharesMinterConfigs(new IAaveV4ConfigEngine.FeeSharesMinterConfig[](0)); + assertEq(vm.getRecordedLogs().length, 0); + } + + function test_executeFeeSharesMinterConfigs_revertsWith_unauthorized() public { + FeeSharesMinter externalMinter = new FeeSharesMinter(ADMIN); + + vm.expectRevert( + abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(engine)) + ); + engine.executeFeeSharesMinterConfigs( + _toFeeSharesMinterConfigArray( + IAaveV4ConfigEngine.FeeSharesMinterConfig({ + feeSharesMinter: address(externalMinter), + hub: address(hub1()), + assetId: _getAssetId(0, TOKEN_WETH), + minAccruedFeesPercent: 1_00 + }) + ) + ); + } + + function test_executeFeeSharesMinterConfigs_revertsWith_invalidPercent() public { + uint16 invalid = uint16(PercentageMath.PERCENTAGE_FACTOR) + 1; + vm.expectRevert(abi.encodeWithSelector(IFeeSharesMinter.InvalidConfig.selector, invalid)); + engine.executeFeeSharesMinterConfigs( + _toFeeSharesMinterConfigArray(_buildConfig(_getAssetId(0, TOKEN_WETH), invalid)) + ); + } + + function test_executeFeeSharesMinterConfigs_revertsWith_assetNotListed() public { + uint256 invalidAssetId = hub1().getAssetCount(); + + vm.expectRevert(IHub.AssetNotListed.selector); + engine.executeFeeSharesMinterConfigs( + _toFeeSharesMinterConfigArray(_buildConfig(invalidAssetId, 1_00)) + ); + } + + function test_fuzz_executeFeeSharesMinterConfigs(uint16 minAccruedFeesPercent) public { + minAccruedFeesPercent = uint16( + bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) + ); + uint256 assetId = _getAssetId(0, TOKEN_WETH); + + engine.executeFeeSharesMinterConfigs( + _toFeeSharesMinterConfigArray(_buildConfig(assetId, minAccruedFeesPercent)) + ); + + assertEq(minter.getConfig(address(hub1()), assetId), minAccruedFeesPercent); + } + + function test_fuzz_executeFeeSharesMinterConfigs_revertsWith_invalidPercent( + uint16 minAccruedFeesPercent + ) public { + minAccruedFeesPercent = uint16( + bound(minAccruedFeesPercent, PercentageMath.PERCENTAGE_FACTOR + 1, type(uint16).max) + ); + + vm.expectRevert( + abi.encodeWithSelector(IFeeSharesMinter.InvalidConfig.selector, minAccruedFeesPercent) + ); + engine.executeFeeSharesMinterConfigs( + _toFeeSharesMinterConfigArray(_buildConfig(_getAssetId(0, TOKEN_WETH), minAccruedFeesPercent)) + ); + } + + function test_executeFeeSharesMinterHubConfigs_setsAllAssetsInHub() public { + uint256 assetCount = hub1().getAssetCount(); + assertGt(assetCount, 0, 'preflight'); + + for (uint256 i; i < assetCount; ++i) { + vm.expectEmit(address(minter)); + emit IFeeSharesMinter.ConfigUpdated(address(hub1()), i, 2_50); + } + + engine.executeFeeSharesMinterHubConfigs( + _toFeeSharesMinterHubConfigArray(_buildHubConfig(address(hub1()), 2_50)) + ); + + for (uint256 i; i < assetCount; ++i) { + assertEq(minter.getConfig(address(hub1()), i), 2_50); + } + } + + function test_executeFeeSharesMinterHubConfigs_overwritesExisting() public { + engine.executeFeeSharesMinterConfigs( + _toFeeSharesMinterConfigArray(_buildConfig(_getAssetId(0, TOKEN_WETH), 9_00)) + ); + assertEq(minter.getConfig(address(hub1()), _getAssetId(0, TOKEN_WETH)), 9_00); + + engine.executeFeeSharesMinterHubConfigs( + _toFeeSharesMinterHubConfigArray(_buildHubConfig(address(hub1()), 2_50)) + ); + + uint256 assetCount = hub1().getAssetCount(); + for (uint256 i; i < assetCount; ++i) { + assertEq(minter.getConfig(address(hub1()), i), 2_50); + } + } + + function test_executeFeeSharesMinterHubConfigs_isolatesOtherHubs() public { + engine.executeFeeSharesMinterHubConfigs( + _toFeeSharesMinterHubConfigArray(_buildHubConfig(address(hub1()), 2_50)) + ); + + uint256 hub2AssetCount = hub2().getAssetCount(); + for (uint256 i; i < hub2AssetCount; ++i) { + assertEq(minter.getConfig(address(hub2()), i), 0); + } + } + + function test_executeFeeSharesMinterHubConfigs_multipleHubs() public { + IAaveV4ConfigEngine.FeeSharesMinterHubConfig[] + memory configs = new IAaveV4ConfigEngine.FeeSharesMinterHubConfig[](2); + configs[0] = _buildHubConfig(address(hub1()), 1_00); + configs[1] = _buildHubConfig(address(hub2()), 5_00); + + engine.executeFeeSharesMinterHubConfigs(configs); + + uint256 hub1AssetCount = hub1().getAssetCount(); + for (uint256 i; i < hub1AssetCount; ++i) { + assertEq(minter.getConfig(address(hub1()), i), 1_00); + } + uint256 hub2AssetCount = hub2().getAssetCount(); + for (uint256 i; i < hub2AssetCount; ++i) { + assertEq(minter.getConfig(address(hub2()), i), 5_00); + } + } + + function test_executeFeeSharesMinterHubConfigs_emptyHub_noOp() public { + (ISpoke newSpoke, ) = _deployNewSpoke(); + newSpoke; // unused + + address freshHub = makeAddr('fresh-hub'); + vm.mockCall( + freshHub, + abi.encodeWithSelector(IHub.getAssetCount.selector), + abi.encode(uint256(0)) + ); + + vm.recordLogs(); + engine.executeFeeSharesMinterHubConfigs( + _toFeeSharesMinterHubConfigArray(_buildHubConfig(freshHub, 1_00)) + ); + assertEq(vm.getRecordedLogs().length, 0); + } + + function test_executeFeeSharesMinterHubConfigs_emitsPerAsset() public { + vm.recordLogs(); + engine.executeFeeSharesMinterHubConfigs( + _toFeeSharesMinterHubConfigArray(_buildHubConfig(address(hub1()), 7_50)) + ); + assertEq(vm.getRecordedLogs().length, hub1().getAssetCount()); + } + + function test_executeFeeSharesMinterHubConfigs_revertsWith_invalidPercent() public { + uint16 invalid = uint16(PercentageMath.PERCENTAGE_FACTOR) + 1; + vm.expectRevert(abi.encodeWithSelector(IFeeSharesMinter.InvalidConfig.selector, invalid)); + engine.executeFeeSharesMinterHubConfigs( + _toFeeSharesMinterHubConfigArray(_buildHubConfig(address(hub1()), invalid)) + ); + } + + function test_executeFeeSharesMinterHubConfigs_revertsWith_unauthorized() public { + FeeSharesMinter externalMinter = new FeeSharesMinter(ADMIN); + + vm.expectRevert( + abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(engine)) + ); + engine.executeFeeSharesMinterHubConfigs( + _toFeeSharesMinterHubConfigArray( + IAaveV4ConfigEngine.FeeSharesMinterHubConfig({ + feeSharesMinter: address(externalMinter), + hub: address(hub1()), + minAccruedFeesPercent: 1_00 + }) + ) + ); + } + + function test_fuzz_executeFeeSharesMinterHubConfigs(uint16 minAccruedFeesPercent) public { + minAccruedFeesPercent = uint16( + bound(minAccruedFeesPercent, 0, PercentageMath.PERCENTAGE_FACTOR) + ); + + engine.executeFeeSharesMinterHubConfigs( + _toFeeSharesMinterHubConfigArray(_buildHubConfig(address(hub1()), minAccruedFeesPercent)) + ); + + uint256 assetCount = hub1().getAssetCount(); + for (uint256 i; i < assetCount; ++i) { + assertEq(minter.getConfig(address(hub1()), i), minAccruedFeesPercent); + } + } + + function test_executeFeeSharesMinterWorkflowConfigs_setsConfig() public { + IFeeSharesMinter.WorkflowConfig memory cfg = _defaultWorkflowConfig(); + + vm.expectCall( + address(minter), + abi.encodeCall(IFeeSharesMinter.setWorkflowConfig, (WORKFLOW_ID, cfg)) + ); + + vm.expectEmit(address(minter)); + emit IFeeSharesMinter.WorkflowConfigUpdated( + WORKFLOW_ID, + cfg.forwarder, + cfg.owner, + cfg.name, + cfg.isActive + ); + + engine.executeFeeSharesMinterWorkflowConfigs( + _toFeeSharesMinterWorkflowConfigArray(_buildWorkflowConfig(WORKFLOW_ID, cfg)) + ); + + IFeeSharesMinter.WorkflowConfig memory stored = minter.getWorkflowConfig(WORKFLOW_ID); + assertEq(stored.forwarder, cfg.forwarder); + assertEq(stored.owner, cfg.owner); + assertEq(stored.name, cfg.name); + assertTrue(stored.isActive); + } + + function test_executeFeeSharesMinterWorkflowConfigs_multiple() public { + IFeeSharesMinter.WorkflowConfig memory cfg1 = _defaultWorkflowConfig(); + IFeeSharesMinter.WorkflowConfig memory cfg2 = IFeeSharesMinter.WorkflowConfig({ + forwarder: makeAddr('forwarder-2'), + owner: makeAddr('owner-2'), + name: bytes10('second-wf'), + isActive: true + }); + + IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[] + memory configs = new IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[](2); + configs[0] = _buildWorkflowConfig(WORKFLOW_ID, cfg1); + configs[1] = _buildWorkflowConfig(WORKFLOW_ID_2, cfg2); + + engine.executeFeeSharesMinterWorkflowConfigs(configs); + + IFeeSharesMinter.WorkflowConfig memory stored1 = minter.getWorkflowConfig(WORKFLOW_ID); + IFeeSharesMinter.WorkflowConfig memory stored2 = minter.getWorkflowConfig(WORKFLOW_ID_2); + + assertEq(stored1.forwarder, cfg1.forwarder); + assertEq(stored1.owner, cfg1.owner); + assertEq(stored1.name, cfg1.name); + assertEq(stored2.forwarder, cfg2.forwarder); + assertEq(stored2.owner, cfg2.owner); + assertEq(stored2.name, cfg2.name); + } + + function test_executeFeeSharesMinterWorkflowConfigs_overwrite() public { + IFeeSharesMinter.WorkflowConfig memory cfg = _defaultWorkflowConfig(); + engine.executeFeeSharesMinterWorkflowConfigs( + _toFeeSharesMinterWorkflowConfigArray(_buildWorkflowConfig(WORKFLOW_ID, cfg)) + ); + + IFeeSharesMinter.WorkflowConfig memory cfg2 = IFeeSharesMinter.WorkflowConfig({ + forwarder: makeAddr('updated-forwarder'), + owner: makeAddr('updated-owner'), + name: bytes10('updated-wf'), + isActive: false + }); + engine.executeFeeSharesMinterWorkflowConfigs( + _toFeeSharesMinterWorkflowConfigArray(_buildWorkflowConfig(WORKFLOW_ID, cfg2)) + ); + + IFeeSharesMinter.WorkflowConfig memory stored = minter.getWorkflowConfig(WORKFLOW_ID); + assertEq(stored.forwarder, cfg2.forwarder); + assertEq(stored.owner, cfg2.owner); + assertEq(stored.name, cfg2.name); + assertFalse(stored.isActive); + } + + function test_executeFeeSharesMinterWorkflowConfigs_canDeactivate() public { + IFeeSharesMinter.WorkflowConfig memory cfg = _defaultWorkflowConfig(); + engine.executeFeeSharesMinterWorkflowConfigs( + _toFeeSharesMinterWorkflowConfigArray(_buildWorkflowConfig(WORKFLOW_ID, cfg)) + ); + + cfg.isActive = false; + engine.executeFeeSharesMinterWorkflowConfigs( + _toFeeSharesMinterWorkflowConfigArray(_buildWorkflowConfig(WORKFLOW_ID, cfg)) + ); + + IFeeSharesMinter.WorkflowConfig memory stored = minter.getWorkflowConfig(WORKFLOW_ID); + assertFalse(stored.isActive); + } + + function test_executeFeeSharesMinterWorkflowConfigs_emptyArray_noOp() public { + vm.recordLogs(); + engine.executeFeeSharesMinterWorkflowConfigs( + new IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[](0) + ); + assertEq(vm.getRecordedLogs().length, 0); + } + + function test_executeFeeSharesMinterWorkflowConfigs_revertsWith_unauthorized() public { + FeeSharesMinter externalMinter = new FeeSharesMinter(ADMIN); + + vm.expectRevert( + abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(engine)) + ); + engine.executeFeeSharesMinterWorkflowConfigs( + _toFeeSharesMinterWorkflowConfigArray( + IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig({ + feeSharesMinter: address(externalMinter), + workflowId: WORKFLOW_ID, + config: _defaultWorkflowConfig() + }) + ) + ); + } + + function test_fuzz_executeFeeSharesMinterWorkflowConfigs( + bytes32 workflowId, + address forwarder, + address owner, + bytes10 name, + bool isActive + ) public { + IFeeSharesMinter.WorkflowConfig memory cfg = IFeeSharesMinter.WorkflowConfig({ + forwarder: forwarder, + owner: owner, + name: name, + isActive: isActive + }); + + engine.executeFeeSharesMinterWorkflowConfigs( + _toFeeSharesMinterWorkflowConfigArray(_buildWorkflowConfig(workflowId, cfg)) + ); + + IFeeSharesMinter.WorkflowConfig memory stored = minter.getWorkflowConfig(workflowId); + assertEq(stored.forwarder, forwarder); + assertEq(stored.owner, owner); + assertEq(stored.name, name); + assertEq(stored.isActive, isActive); + } + + function _buildConfig( + uint256 assetId, + uint16 minAccruedFeesPercent + ) internal view returns (IAaveV4ConfigEngine.FeeSharesMinterConfig memory) { + return + IAaveV4ConfigEngine.FeeSharesMinterConfig({ + feeSharesMinter: address(minter), + hub: address(hub1()), + assetId: assetId, + minAccruedFeesPercent: minAccruedFeesPercent + }); + } + + function _buildHubConfig( + address hub, + uint16 minAccruedFeesPercent + ) internal view returns (IAaveV4ConfigEngine.FeeSharesMinterHubConfig memory) { + return + IAaveV4ConfigEngine.FeeSharesMinterHubConfig({ + feeSharesMinter: address(minter), + hub: hub, + minAccruedFeesPercent: minAccruedFeesPercent + }); + } + + function _buildWorkflowConfig( + bytes32 workflowId, + IFeeSharesMinter.WorkflowConfig memory cfg + ) internal view returns (IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig memory) { + return + IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig({ + feeSharesMinter: address(minter), + workflowId: workflowId, + config: cfg + }); + } +} diff --git a/tests/helpers/mocks/config-engine/AaveV4PayloadWrapper.sol b/tests/helpers/mocks/config-engine/AaveV4PayloadWrapper.sol index 32854a2f5..c60aac19b 100644 --- a/tests/helpers/mocks/config-engine/AaveV4PayloadWrapper.sol +++ b/tests/helpers/mocks/config-engine/AaveV4PayloadWrapper.sol @@ -43,6 +43,11 @@ contract AaveV4PayloadWrapper is AaveV4Payload { IAaveV4ConfigEngine.TargetFunctionRoleUpdate[] private _accessManagerTargetFunctionRoleUpdates; IAaveV4ConfigEngine.TargetAdminDelayUpdate[] private _accessManagerTargetAdminDelayUpdates; + // FeeSharesMinter action storage + IAaveV4ConfigEngine.FeeSharesMinterConfig[] private _feeSharesMinterConfigs; + IAaveV4ConfigEngine.FeeSharesMinterHubConfig[] private _feeSharesMinterHubConfigs; + IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[] private _feeSharesMinterWorkflowConfigs; + constructor(IAaveV4ConfigEngine configEngine) AaveV4Payload(configEngine) {} // Hook overrides @@ -223,6 +228,34 @@ contract AaveV4PayloadWrapper is AaveV4Payload { } } + // FeeSharesMinter setters + function setFeeSharesMinterConfigs( + IAaveV4ConfigEngine.FeeSharesMinterConfig[] memory items + ) external { + delete _feeSharesMinterConfigs; + for (uint256 i = 0; i < items.length; i++) { + _feeSharesMinterConfigs.push(items[i]); + } + } + + function setFeeSharesMinterHubConfigs( + IAaveV4ConfigEngine.FeeSharesMinterHubConfig[] memory items + ) external { + delete _feeSharesMinterHubConfigs; + for (uint256 i = 0; i < items.length; i++) { + _feeSharesMinterHubConfigs.push(items[i]); + } + } + + function setFeeSharesMinterWorkflowConfigs( + IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[] memory items + ) external { + delete _feeSharesMinterWorkflowConfigs; + for (uint256 i = 0; i < items.length; i++) { + _feeSharesMinterWorkflowConfigs.push(items[i]); + } + } + function hubAssetListings() public view @@ -413,4 +446,31 @@ contract AaveV4PayloadWrapper is AaveV4Payload { { return _positionManagerRoleRenouncements; } + + function feeSharesMinterConfigs() + public + view + override + returns (IAaveV4ConfigEngine.FeeSharesMinterConfig[] memory) + { + return _feeSharesMinterConfigs; + } + + function feeSharesMinterHubConfigs() + public + view + override + returns (IAaveV4ConfigEngine.FeeSharesMinterHubConfig[] memory) + { + return _feeSharesMinterHubConfigs; + } + + function feeSharesMinterWorkflowConfigs() + public + view + override + returns (IAaveV4ConfigEngine.FeeSharesMinterWorkflowConfig[] memory) + { + return _feeSharesMinterWorkflowConfigs; + } }