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/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/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/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/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/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..a54c80d9f 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, @@ -438,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/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/src/utils/FeeSharesMinter.sol b/src/utils/FeeSharesMinter.sol new file mode 100644 index 000000000..d1cc8730a --- /dev/null +++ b/src/utils/FeeSharesMinter.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +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} from 'src/utils/IFeeSharesMinter.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; + +/// @title FeeSharesMinter +/// @author Aave Labs +/// @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; + + constructor(address owner) Ownable(owner) {} + + /// @inheritdoc IFeeSharesMinter + function setConfig( + address hub, + uint256 assetId, + uint16 minAccruedFeesPercent + ) external onlyOwner { + require( + minAccruedFeesPercent <= PercentageMath.PERCENTAGE_FACTOR, + InvalidConfig(minAccruedFeesPercent) + ); + require(assetId < IHub(hub).getAssetCount(), IHub.AssetNotListed()); + _minAccruedFeesPercent[hub][assetId] = minAccruedFeesPercent; + emit ConfigUpdated(hub, assetId, minAccruedFeesPercent); + } + + /// @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 `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 + function getConfig(address hub, uint256 assetId) external view returns (uint16) { + return _minAccruedFeesPercent[hub][assetId]; + } + + /// @inheritdoc IFeeSharesMinter + function getWorkflowConfig(bytes32 workflowId) external view returns (WorkflowConfig memory) { + return _workflowConfigs[workflowId]; + } + + /// @inheritdoc IFeeSharesMinter + function canMint(address hub, uint256 assetId) external view returns (bool) { + return _canMint(hub, assetId); + } + + /// @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; + } + + IHub targetHub = IHub(hub); + uint256 accruedFees = targetHub.getAssetAccruedFees(assetId); + uint256 totalAddedAssets = targetHub.getAddedAssets(assetId); + + if (totalAddedAssets == 0) { + return false; + } + if (accruedFees.percentDivDown(totalAddedAssets) < minAccruedFeesPercent) { + return false; + } + + return targetHub.previewAddByAssets(assetId, accruedFees) > 0; + } + + /// @inheritdoc 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 new file mode 100644 index 000000000..785e1d596 --- /dev/null +++ b/src/utils/IFeeSharesMinter.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity 0.8.28; + +import {IReceiver} from 'src/dependencies/chainlink/IReceiver.sol'; + +/// @title IFeeSharesMinter +/// @author Aave Labs +/// @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 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 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. + 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/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/contracts/utils/FeeSharesMinter.t.sol b/tests/contracts/utils/FeeSharesMinter.t.sol new file mode 100644 index 000000000..b8e00f5dd --- /dev/null +++ b/tests/contracts/utils/FeeSharesMinter.t.sol @@ -0,0 +1,666 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/setup/Base.t.sol'; + +contract FeeSharesMinterTest is Base { + using SafeCast for uint256; + using PercentageMath for uint256; + + 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 { + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, bob)); + 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 IFeeSharesMinter.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_setConfig_zeroDisables() public { + _setupHappyPath(daiAssetId, 1); + + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, 0); + assertEq(minter.getConfig(address(hub1), daiAssetId), 0); + + _assertCannotMint(address(hub1), daiAssetId); + } + + function test_fuzz_setConfig_revertsWith_InvalidConfig(uint16 minAccruedFeesPercent) public { + minAccruedFeesPercent = bound( + minAccruedFeesPercent, + PercentageMath.PERCENTAGE_FACTOR + 1, + type(uint16).max + ).toUint16(); + + vm.prank(ADMIN); + 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_getConfig_returnsZero_whenUnset() public view { + assertEq(minter.getConfig(address(hub1), daiAssetId), 0); + } + + function test_getConfig_returnsLatestSetValue() public { + vm.startPrank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, 100); + assertEq(minter.getConfig(address(hub1), daiAssetId), 100); + + 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(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.setWorkflowConfig( + newId, + IFeeSharesMinter.WorkflowConfig({ + forwarder: newForwarder, + owner: newOwner, + name: newName, + isActive: true + }) + ); + + IFeeSharesMinter.WorkflowConfig memory stored = minter.getWorkflowConfig(newId); + assertEq(stored.forwarder, newForwarder); + assertEq(stored.owner, newOwner); + assertEq(stored.name, newName); + assertTrue(stored.isActive); + } + + 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.setWorkflowConfig( + secondId, + IFeeSharesMinter.WorkflowConfig({ + forwarder: secondForwarder, + owner: secondOwner, + name: secondName, + isActive: true + }) + ); + + // Both workflows can independently submit valid reports + _callOnReport(FORWARDER, WORKFLOW_ID, WORKFLOW_NAME, WORKFLOW_OWNER, address(hub1), daiAssetId); + + _setupHappyPath(wethAssetId, 1); + _callOnReport(secondForwarder, secondId, secondName, secondOwner, address(hub1), wethAssetId); + } + + 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_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_onReport_success() public { + _performAndAssertSuccess({ + addAmount: 1000e18, + drawAmount: 900e18, + skipTime: 365 days, + minAccruedFeesPercent: 10 + }); + } + + function test_fuzz_onReport_success( + uint256 addAmount, + uint256 drawAmount, + uint256 skipTime, + uint16 minAccruedFeesPercent + ) public { + 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(); + + _performAndAssertSuccess(addAmount, drawAmount, skipTime, minAccruedFeesPercent); + } + + function test_canMint_returnsTrue_whenAllConditionsMet() public { + _setupHappyPath(daiAssetId, 1); + _assertCanMint(address(hub1), daiAssetId); + } + + 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_canMint_returnsFalse_disabledByZeroConfig() public { + _setupHappyPath(daiAssetId, 1); + + 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'); + + _assertCannotMint(address(hub1), daiAssetId); + + vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); + _callOnReportDefault(address(hub1), daiAssetId); + } + + function test_onReport_revertsWith_ConditionsNotMet_noAddedAssets() public { + _setupHappyPath(daiAssetId, 1); + + vm.prank(ADMIN); + minter.setConfig(address(hub1), wethAssetId, 1); + assertEq(hub1.getAddedAssets(wethAssetId), 0, 'Total added assets should be zero'); + + _assertCannotMint(address(hub1), wethAssetId); + + vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); + _callOnReportDefault(address(hub1), wethAssetId); + } + + function test_onReport_revertsWith_ConditionsNotMet_percentThresholdNotMet_withMinShares() + public + { + _setupHappyPath(daiAssetId, 1); + + uint16 highThreshold = 50_00; + vm.prank(ADMIN); + minter.setConfig(address(hub1), daiAssetId, highThreshold); + + uint256 fees = hub1.getAssetAccruedFees(daiAssetId); + assertGt(hub1.previewAddByAssets(daiAssetId, fees), 0, 'At least 1 share would be minted'); + assertLt( + fees, + hub1.getAddedAssets(daiAssetId).percentMulDown(highThreshold), + 'Fees must be < threshold of total' + ); + + _assertCannotMint(address(hub1), daiAssetId); + + vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); + _callOnReportDefault(address(hub1), daiAssetId); + } + + function test_fuzz_onReport_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); + + _assertCannotMint(address(hub1), daiAssetId); + + vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); + _callOnReportDefault(address(hub1), daiAssetId); + } + + function test_onReport_revertsWith_ConditionsNotMet_MinShareNotMet_nonzeroFees() public { + 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); + + // Single change: mint to inflate exchange rate, then skip a short period so the + // newly-accrued fees round to zero shares + _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'); + + _assertCannotMint(address(hub1), daiAssetId); + + vm.expectRevert(IFeeSharesMinter.ConditionsNotMet.selector); + _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 { + _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 + }); + _assertCanMint(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))); + _callOnReportDefault(address(hub1), daiAssetId); + + uint256 sharesAfter = hub1.getSpokeAddedShares(daiAssetId, feeReceiver); + assertEq(sharesAfter - sharesBefore, expectedMintedShares, 'fee shares minted to receiver'); + _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 _assertCanMint(address hub, uint256 assetId) internal view { + assertTrue(minter.canMint(hub, assetId), 'canMint should be true'); + } + + function _assertCannotMint(address hub, uint256 assetId) internal view { + assertFalse(minter.canMint(hub, assetId), 'canMint should be false'); + } +} 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/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/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; + } } 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/setup/Base.t.sol b/tests/setup/Base.t.sol index f6f511f8e..598658935 100644 --- a/tests/setup/Base.t.sol +++ b/tests/setup/Base.t.sol @@ -55,6 +55,11 @@ import { IBasicInterestRateStrategy } from 'src/hub/AssetInterestRateStrategy.sol'; +// 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'; import {TreasurySpoke, ITreasurySpoke} from 'src/spoke/TreasurySpoke.sol'; diff --git a/tests/utils/BatchTestProcedures.sol b/tests/utils/BatchTestProcedures.sol index ed33f852e..c38439cea 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 @@ -629,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( @@ -637,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), @@ -645,6 +665,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( @@ -663,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'); } } } @@ -674,6 +707,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 +909,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;