-
Notifications
You must be signed in to change notification settings - Fork 102
feat: Add FeeSharesMinter contract
#1216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
CheyenneAtapour
wants to merge
32
commits into
main
Choose a base branch
from
feat/fee-minter
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 3 commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
75e9f55
feat: Simple contract to mint fee shares
CheyenneAtapour 9ec1d11
chore: Cleanup
CheyenneAtapour 4dc1448
feat: Make contract usable with all hubs
CheyenneAtapour 0f6dab4
fix: Pr comments
CheyenneAtapour 4f0492e
fix: test comment
CheyenneAtapour d9bfd8d
fix: Address pr comments
CheyenneAtapour cf0e864
Merge remote-tracking branch 'origin/main' into feat/fee-minter
CheyenneAtapour ba91946
fix: address pr comments
CheyenneAtapour 729a05f
feat: Integrate with chainlink automation
CheyenneAtapour 89d75b4
fix: test suite
CheyenneAtapour 521c61c
chore: cleanup
CheyenneAtapour 3a54457
fix: Cleanup natspec
CheyenneAtapour 05bcd6c
fix: typo
CheyenneAtapour aba7d8c
Merge remote-tracking branch 'origin/main' into feat/fee-minter
CheyenneAtapour 55f3330
Merge remote-tracking branch 'origin/main' into feat/fee-minter
CheyenneAtapour 60d2395
Merge remote-tracking branch 'origin/main' into feat/fee-minter
CheyenneAtapour 01fc11f
merge in main
CheyenneAtapour d6c0ac0
fix: Address pr comments
CheyenneAtapour 9a0ce16
fix: Address pr comments
CheyenneAtapour 36d151c
fix: Some pr comments
CheyenneAtapour 976361e
Merge remote-tracking branch 'origin/main' into feat/fee-minter
CheyenneAtapour e0244e0
merge in main
CheyenneAtapour 6e155aa
fix: Remaining pr comments
CheyenneAtapour 39c7c5a
feat: Remove time component from fee minter
CheyenneAtapour 56fbaf0
fix: Remove extraneous functions
CheyenneAtapour 7bb20ec
fix : address pr comments & cleanup
Kogaroshi 55f1f82
fix : renaming
Kogaroshi c0c322d
feat : add FeeSharesMinter to deploy engine
Kogaroshi 8bb19cc
fix: address fee minter comments (#1299)
yan-man 98c4cc4
fix: address comments
avniculae c85cb6f
feat: migrate FeeSharesMinter to CRE
avniculae 9010c1f
feat: add FeeSharesMinter support in config engine
avniculae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| // 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 | ||
| /// @author Aave Labs | ||
| /// @notice Contract to mint fee shares on the Hub when specific conditions are met. | ||
| contract FeeSharesMinterBase is Ownable { | ||
|
Kogaroshi marked this conversation as resolved.
Outdated
DhairyaSethi marked this conversation as resolved.
Outdated
|
||
| struct MintConfig { | ||
| uint256 minTimeInterval; | ||
| uint256 minUnrealizedFeePercent; // 1e4 = 100% (basis points) | ||
| } | ||
|
Kogaroshi marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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(); | ||
|
|
||
| /// @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. | ||
| 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(address hub, uint256 assetId) external { | ||
| if (!_checkExecute(hub, assetId)) { | ||
| revert ConditionsNotMet(); | ||
| } | ||
|
DhairyaSethi marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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(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(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(address hub, uint256 assetId) internal view returns (bool) { | ||
|
DhairyaSethi marked this conversation as resolved.
Outdated
|
||
| MintConfig memory config = _configs[hub][assetId]; | ||
|
|
||
| // Check mint interval | ||
| if (block.timestamp - lastMintTime[hub][assetId] < config.minTimeInterval) { | ||
| return false; | ||
| } | ||
|
|
||
| IHub hubContract = IHub(hub); | ||
| uint256 accruedFees = hubContract.getAssetAccruedFees(assetId); | ||
|
|
||
| uint256 totalAddedAssets = hubContract.getAddedAssets(assetId); | ||
| if (totalAddedAssets == 0) return false; | ||
|
DhairyaSethi marked this conversation as resolved.
Outdated
|
||
|
|
||
| // Check if accruedFees / totalAddedAssets >= minUnrealizedFeePercent (in bps) | ||
| if ((accruedFees * 10000) / totalAddedAssets < config.minUnrealizedFeePercent) { | ||
|
Kogaroshi marked this conversation as resolved.
Outdated
|
||
| return false; | ||
| } | ||
|
|
||
| // Ensure at least 1 fee share is minted | ||
| uint256 expectedShares = hubContract.previewAddByAssets(assetId, accruedFees); | ||
| if (expectedShares < 1) { | ||
|
Kogaroshi marked this conversation as resolved.
Outdated
|
||
| return false; | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| // 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); | ||
|
|
||
| // 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(address(hub1), 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(address(hub1), daiAssetId, config); | ||
|
|
||
| // Generate fees | ||
| // Add 1000 DAI, borrow 100 DAI | ||
|
CheyenneAtapour marked this conversation as resolved.
Outdated
|
||
| _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(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_execute_revertsWith_TimeIntervalNotMet() public { | ||
| FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.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: 8 days | ||
| }); | ||
|
|
||
| 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(address(hub1), daiAssetId); | ||
| } | ||
|
|
||
| function test_execute_revertsWith_MinShareNotMet() public { | ||
| FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ | ||
| minTimeInterval: 0, | ||
| minUnrealizedFeePercent: 0 | ||
| }); | ||
| vm.prank(ADMIN); | ||
| minter.setConfig(address(hub1), 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(address(hub1), daiAssetId)); | ||
|
|
||
| vm.expectRevert(FeeSharesMinterBase.ConditionsNotMet.selector); | ||
| minter.execute(address(hub1), daiAssetId); | ||
| } | ||
|
|
||
| function test_execute_revertsWith_PercentThresholdNotMet() public { | ||
| FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ | ||
| minTimeInterval: 0, | ||
| minUnrealizedFeePercent: 5000 // 50% threshold | ||
| }); | ||
| 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: 1 days | ||
| }); | ||
|
|
||
| assertFalse(minter.checkExecute(address(hub1), daiAssetId)); | ||
|
|
||
| vm.expectRevert(FeeSharesMinterBase.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 | ||
| FeeSharesMinterBase.MintConfig memory config = FeeSharesMinterBase.MintConfig({ | ||
| minTimeInterval: 0, | ||
| minUnrealizedFeePercent: 1 // 1 BPS | ||
| }); | ||
| 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 | ||
| ); | ||
|
|
||
| assertTrue(minter.checkExecute(address(hub1), 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(address(hub1), daiAssetId), 'Should fail just below 1 bps'); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.