Skip to content
Open
Show file tree
Hide file tree
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 Feb 17, 2026
9ec1d11
chore: Cleanup
CheyenneAtapour Feb 17, 2026
4dc1448
feat: Make contract usable with all hubs
CheyenneAtapour Feb 17, 2026
0f6dab4
fix: Pr comments
CheyenneAtapour Feb 17, 2026
4f0492e
fix: test comment
CheyenneAtapour Feb 17, 2026
d9bfd8d
fix: Address pr comments
CheyenneAtapour Feb 25, 2026
cf0e864
Merge remote-tracking branch 'origin/main' into feat/fee-minter
CheyenneAtapour Feb 25, 2026
ba91946
fix: address pr comments
CheyenneAtapour Feb 26, 2026
729a05f
feat: Integrate with chainlink automation
CheyenneAtapour Feb 26, 2026
89d75b4
fix: test suite
CheyenneAtapour Feb 27, 2026
521c61c
chore: cleanup
CheyenneAtapour Feb 27, 2026
3a54457
fix: Cleanup natspec
CheyenneAtapour Feb 28, 2026
05bcd6c
fix: typo
CheyenneAtapour Mar 4, 2026
aba7d8c
Merge remote-tracking branch 'origin/main' into feat/fee-minter
CheyenneAtapour Mar 10, 2026
55f3330
Merge remote-tracking branch 'origin/main' into feat/fee-minter
CheyenneAtapour Mar 12, 2026
60d2395
Merge remote-tracking branch 'origin/main' into feat/fee-minter
CheyenneAtapour Mar 25, 2026
01fc11f
merge in main
CheyenneAtapour Mar 28, 2026
d6c0ac0
fix: Address pr comments
CheyenneAtapour Mar 28, 2026
9a0ce16
fix: Address pr comments
CheyenneAtapour Mar 28, 2026
36d151c
fix: Some pr comments
CheyenneAtapour Apr 9, 2026
976361e
Merge remote-tracking branch 'origin/main' into feat/fee-minter
CheyenneAtapour Apr 9, 2026
e0244e0
merge in main
CheyenneAtapour Apr 9, 2026
6e155aa
fix: Remaining pr comments
CheyenneAtapour Apr 9, 2026
39c7c5a
feat: Remove time component from fee minter
CheyenneAtapour Apr 9, 2026
56fbaf0
fix: Remove extraneous functions
CheyenneAtapour Apr 9, 2026
7bb20ec
fix : address pr comments & cleanup
Kogaroshi Apr 15, 2026
55f1f82
fix : renaming
Kogaroshi Apr 15, 2026
c0c322d
feat : add FeeSharesMinter to deploy engine
Kogaroshi Apr 15, 2026
8bb19cc
fix: address fee minter comments (#1299)
yan-man Apr 16, 2026
98c4cc4
fix: address comments
avniculae May 21, 2026
c85cb6f
feat: migrate FeeSharesMinter to CRE
avniculae May 22, 2026
9010c1f
feat: add FeeSharesMinter support in config engine
avniculae May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/hub/FeeSharesMinterBase.sol
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';
Comment thread
DhairyaSethi marked this conversation as resolved.
Outdated

/// @title FeeSharesMinterBase
/// @author Aave Labs
/// @notice Contract to mint fee shares on the Hub when specific conditions are met.
contract FeeSharesMinterBase is Ownable {
Comment thread
Kogaroshi marked this conversation as resolved.
Outdated
Comment thread
DhairyaSethi marked this conversation as resolved.
Outdated
struct MintConfig {
uint256 minTimeInterval;
uint256 minUnrealizedFeePercent; // 1e4 = 100% (basis points)
}
Comment thread
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();
}
Comment thread
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) {
Comment thread
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;
Comment thread
DhairyaSethi marked this conversation as resolved.
Outdated

// Check if accruedFees / totalAddedAssets >= minUnrealizedFeePercent (in bps)
if ((accruedFees * 10000) / totalAddedAssets < config.minUnrealizedFeePercent) {
Comment thread
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) {
Comment thread
Kogaroshi marked this conversation as resolved.
Outdated
return false;
}

return true;
}
}
188 changes: 188 additions & 0 deletions tests/unit/Hub/FeeSharesMinterBase.t.sol
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
Comment thread
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');
}
}
Loading