diff --git a/.gitmodules b/.gitmodules index 78d4027..6e94d7a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,17 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "lib/aave-v3-periphery"] + path = lib/aave-v3-periphery + url = https://github.com/aave/aave-v3-periphery +[submodule "lib/chainlink"] + path = lib/chainlink + url = https://github.com/smartcontractkit/chainlink + branch = v1.13.0 [submodule "lib/aave-helpers"] path = lib/aave-helpers url = https://github.com/bgd-labs/aave-helpers +[submodule "lib/aave-governance-v3-robot"] + path = lib/aave-governance-v3-robot + url = https://github.com/bgd-labs/aave-governance-v3-robot + branch = fix/operator diff --git a/README.md b/README.md index c21dcd0..af66b33 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ An up to date address can be fetched from the respective [address-book pool libr The `stataToken` is not natively integrated into the aave protocol and therefore cannot hook into the emissionManager. This means a `reward` added **after** `statToken` creation needs to be registered manually on the token via the permissionless `refreshRewardTokens()` method. -As this process is not currently automated users might be missing out on rewards until the method is called. + +This process is currently automated using [RefreshRewardRobot](./src/robots/RefreshRewardsRobot.sol). ## Security procedures diff --git a/lib/aave-governance-v3-robot b/lib/aave-governance-v3-robot new file mode 160000 index 0000000..b3c251a --- /dev/null +++ b/lib/aave-governance-v3-robot @@ -0,0 +1 @@ +Subproject commit b3c251a837e1e472f1bc2ca9252f559ad5337a76 diff --git a/lib/aave-helpers b/lib/aave-helpers index 1daafea..6567dae 160000 --- a/lib/aave-helpers +++ b/lib/aave-helpers @@ -1 +1 @@ -Subproject commit 1daafea0e7f7baafa82d2b386e284e3850abf048 +Subproject commit 6567dae5143253858eeff93a8de4bd8c0563f353 diff --git a/lib/chainlink b/lib/chainlink new file mode 160000 index 0000000..594387e --- /dev/null +++ b/lib/chainlink @@ -0,0 +1 @@ +Subproject commit 594387e4795adeed7fd32893dfecc7ae04b987ed diff --git a/remappings.txt b/remappings.txt index eedc58c..1f88a1f 100644 --- a/remappings.txt +++ b/remappings.txt @@ -8,3 +8,5 @@ aave-helpers/=lib/aave-helpers/src/ aave-v3-core/=lib/aave-helpers/lib/aave-address-book/lib/aave-v3-core/ aave-v3-periphery/=lib/aave-helpers/lib/aave-address-book/lib/aave-v3-periphery/ @aave/periphery-v3/=lib/aave-helpers/lib/aave-address-book/lib/aave-v3-periphery/ +chainlink/=lib/chainlink/contracts/ +aave-governance-v3-robot/=lib/aave-governance-v3-robot/src/ \ No newline at end of file diff --git a/scripts/Robots.s.sol b/scripts/Robots.s.sol new file mode 100644 index 0000000..0875ea5 --- /dev/null +++ b/scripts/Robots.s.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {EthereumScript, PolygonScript, AvalancheScript, ArbitrumScript, OptimismScript, MetisScript, BaseScript, BNBScript, GnosisScript} from 'aave-helpers/ScriptUtils.sol'; +import {AaveV3Ethereum, IPool} from 'aave-address-book/AaveV3Ethereum.sol'; +import {AaveV3Polygon} from 'aave-address-book/AaveV3Polygon.sol'; +import {AaveV3Avalanche} from 'aave-address-book/AaveV3Avalanche.sol'; +import {AaveV3Optimism} from 'aave-address-book/AaveV3Optimism.sol'; +import {AaveV3Arbitrum} from 'aave-address-book/AaveV3Arbitrum.sol'; +import {AaveV3Metis} from 'aave-address-book/AaveV3Metis.sol'; +import {AaveV3Base} from 'aave-address-book/AaveV3Base.sol'; +import {AaveV3BNB} from 'aave-address-book/AaveV3BNB.sol'; +import {AaveV3Gnosis} from 'aave-address-book/AaveV3Gnosis.sol'; +import {GasCappedRefreshRewardsRobot} from '../src/robots/GasCappedRefreshRewardsRobot.sol'; +import {GelatoGasCappedRefreshRewardsRobot} from '../src/robots/GelatoGasCappedRefreshRewardsRobot.sol'; +import {RefreshRewardsRobot} from '../src/robots/RefreshRewardsRobot.sol'; + +// make deploy-ledger contract=scripts/Robots.s.sol:DeployMainnet chain=mainnet +contract DeployMainnet is EthereumScript { + function run() external broadcast { + GasCappedRefreshRewardsRobot robot = new GasCappedRefreshRewardsRobot( + AaveV3Ethereum.STATIC_A_TOKEN_FACTORY, + AaveV3Ethereum.DEFAULT_INCENTIVES_CONTROLLER, + 0x169E633A2D1E6c10dD91238Ba11c4A708dfEF37C // chainlink fast gas feed + ); + robot.setMaxGasPrice(150 gwei); + } +} + +// make deploy-ledger contract=scripts/Robots.s.sol:DeployPolygon chain=polygon +contract DeployPolygon is PolygonScript { + function run() external broadcast { + new RefreshRewardsRobot( + AaveV3Polygon.STATIC_A_TOKEN_FACTORY, + AaveV3Polygon.DEFAULT_INCENTIVES_CONTROLLER + ); + } +} + +// make deploy-ledger contract=scripts/Robots.s.sol:DeployAvalanche chain=avalanche +contract DeployAvalanche is AvalancheScript { + function run() external broadcast { + new RefreshRewardsRobot( + AaveV3Avalanche.STATIC_A_TOKEN_FACTORY, + AaveV3Avalanche.DEFAULT_INCENTIVES_CONTROLLER + ); + } +} + +// make deploy-ledger contract=scripts/Robots.s.sol:DeployOptimism chain=optimism +contract DeployOptimism is OptimismScript { + function run() external broadcast { + new RefreshRewardsRobot( + AaveV3Optimism.STATIC_A_TOKEN_FACTORY, + AaveV3Optimism.DEFAULT_INCENTIVES_CONTROLLER + ); + } +} + +// make deploy-ledger contract=scripts/Robots.s.sol:DeployArbitrum chain=arbitrum +contract DeployArbitrum is ArbitrumScript { + function run() external broadcast { + new RefreshRewardsRobot( + AaveV3Arbitrum.STATIC_A_TOKEN_FACTORY, + AaveV3Arbitrum.DEFAULT_INCENTIVES_CONTROLLER + ); + } +} + +// make deploy-ledger contract=scripts/Robots.s.sol:DeployBase chain=base +contract DeployBase is BaseScript { + function run() external broadcast { + new RefreshRewardsRobot( + AaveV3Base.STATIC_A_TOKEN_FACTORY, + AaveV3Base.DEFAULT_INCENTIVES_CONTROLLER + ); + } +} + +// make deploy-ledger contract=scripts/Robots.s.sol:DeployBNB chain=bnb +contract DeployBNB is BNBScript { + function run() external broadcast { + new RefreshRewardsRobot( + AaveV3BNB.STATIC_A_TOKEN_FACTORY, + AaveV3BNB.DEFAULT_INCENTIVES_CONTROLLER + ); + } +} + +// make deploy-ledger contract=scripts/Robots.s.sol:DeployMetis chain=metis +contract DeployMetis is MetisScript { + function run() external broadcast { + new GelatoGasCappedRefreshRewardsRobot( + AaveV3Metis.STATIC_A_TOKEN_FACTORY, + AaveV3Metis.DEFAULT_INCENTIVES_CONTROLLER + ); + } +} + +// make deploy-ledger contract=scripts/Robots.s.sol:DeployGnosis chain=gnosis +contract DeployGnosis is GnosisScript { + function run() external broadcast { + new GelatoGasCappedRefreshRewardsRobot( + AaveV3Gnosis.STATIC_A_TOKEN_FACTORY, + AaveV3Gnosis.DEFAULT_INCENTIVES_CONTROLLER + ); + } +} \ No newline at end of file diff --git a/src/interfaces/IRefreshRewardsRobot.sol b/src/interfaces/IRefreshRewardsRobot.sol new file mode 100644 index 0000000..5d7ffe2 --- /dev/null +++ b/src/interfaces/IRefreshRewardsRobot.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {AutomationCompatibleInterface} from 'chainlink/src/v0.8/interfaces/automation/AutomationCompatibleInterface.sol'; +import {IStaticATokenFactory} from './IStaticATokenFactory.sol'; +import {IRewardsController} from 'aave-v3-periphery/contracts/rewards/interfaces/IRewardsController.sol'; + +/** + * @title IRefreshRewardsRobot + * @author BGD Labs + * @notice Defines the interface for the contract to automate refresh rewards for staticATokens. + **/ +interface IRefreshRewardsRobot is AutomationCompatibleInterface { + /** + * @notice emitted when rewards are refreshed for a staticAToken. + * @param staticAToken address of the staticAToken for which rewards have been refreshed. + */ + event RefreshSucceeded(address indexed staticAToken); + + /** + * @notice method to check if the staticAToken is disabled for automation. + * @param staticAToken staticAToken to check if disabled for refresh rewards automation. + **/ + function isDisabled(address staticAToken) external view returns (bool); + + /** + * @notice method to disable automation for the staticAToken. + * @param staticAToken staticAToken to disable automation for refresh rewards. + * @param disable bool true to disable automation, false to enable it back. + **/ + function disableAutomation(address staticAToken, bool disable) external; + + /** + * @notice method to get the maximum number of actions that can be performed by the robot in one performUpkeep. + * @return max number of actions. + */ + function MAX_ACTIONS() external returns (uint256); + + /** + * @notice method to get the rewards controller of the protocol. + * @return address of the aave rewards controller. + */ + function REWARDS_CONTROLLER() external returns (IRewardsController); + + /** + * @notice method to get the static a token factory. + * @return address of the static a token factory contract. + */ + function STATIC_A_TOKEN_FACTORY() external returns (IStaticATokenFactory); +} diff --git a/src/robots/GasCappedRefreshRewardsRobot.sol b/src/robots/GasCappedRefreshRewardsRobot.sol new file mode 100644 index 0000000..a1bd4b3 --- /dev/null +++ b/src/robots/GasCappedRefreshRewardsRobot.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import {AutomationCompatibleInterface} from 'chainlink/src/v0.8/interfaces/automation/AutomationCompatibleInterface.sol'; +import {GasCappedRobotBase} from 'aave-governance-v3-robot/contracts/gasprice-capped-robots/GasCappedRobotBase.sol'; +import {RefreshRewardsRobot} from './RefreshRewardsRobot.sol'; + +/** + * @title GasCappedRefreshRewardsRobot + * @author BGD Labs + * @notice Automation contract to automate refresh rewards for staticATokens if a reward + * is added after staticAToken creation to register the missing rewards. + * The difference from RefreshRewardsRobot is that automation is only + * performed when the network gas price in within the maximum configured range. + */ +contract GasCappedRefreshRewardsRobot is RefreshRewardsRobot, GasCappedRobotBase { + /** + * @param staticATokenFactory address of the static a token factory contract. + * @param rewardsController address of the rewards controller of the protocol. + * @param gasPriceOracle address of the gas price oracle contract. + */ + constructor( + address staticATokenFactory, + address rewardsController, + address gasPriceOracle + ) + RefreshRewardsRobot(staticATokenFactory, rewardsController) + GasCappedRobotBase(gasPriceOracle) + {} + + /** + * @inheritdoc AutomationCompatibleInterface + * @dev runs off-chain, checks if there is a reward added after statToken creation which needs to be registered. + * also checks that the gas price of the network in within range to perform actions. + */ + function checkUpkeep(bytes memory) public view virtual override returns (bool, bytes memory) { + if (!isGasPriceInRange()) return (false, ''); + + return super.checkUpkeep(''); + } +} diff --git a/src/robots/GelatoGasCappedRefreshRewardsRobot.sol b/src/robots/GelatoGasCappedRefreshRewardsRobot.sol new file mode 100644 index 0000000..8015e7b --- /dev/null +++ b/src/robots/GelatoGasCappedRefreshRewardsRobot.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import {GasCappedRefreshRewardsRobot} from './GasCappedRefreshRewardsRobot.sol'; +import {IGasPriceCappedRobot} from 'aave-governance-v3-robot/interfaces/IGasPriceCappedRobot.sol'; + +/** + * @title GelatoGasCappedRefreshRewardsRobot + * @author BGD Labs + * @notice Automation contract to automate refresh rewards for staticATokens. + * The difference from GasCappedRefreshRewardsRobot is that we use tx.gasprice + * instead of gas price oracle in order to limit the execution of the robot. + */ +contract GelatoGasCappedRefreshRewardsRobot is GasCappedRefreshRewardsRobot { + /** + * @param staticATokenFactory address of the static a token factory contract. + * @param rewardsController address of the rewards controller of the protocol. + */ + constructor( + address staticATokenFactory, + address rewardsController + ) GasCappedRefreshRewardsRobot(staticATokenFactory, rewardsController, address(0)) {} + + /** + * @inheritdoc GasCappedRefreshRewardsRobot + * @dev the returned bytes is specific to gelato and is encoded with the function selector. + */ + function checkUpkeep(bytes memory) public view override returns (bool, bytes memory) { + (bool upkeepNeeded, bytes memory encodedPayloadIdsToExecute) = super.checkUpkeep(''); + return (upkeepNeeded, abi.encodeCall(this.performUpkeep, encodedPayloadIdsToExecute)); + } + + /// @inheritdoc IGasPriceCappedRobot + function isGasPriceInRange() public view virtual override returns (bool) { + if (tx.gasprice > _maxGasPrice) { + return false; + } + return true; + } +} diff --git a/src/robots/RefreshRewardsRobot.sol b/src/robots/RefreshRewardsRobot.sol new file mode 100644 index 0000000..a1eb284 --- /dev/null +++ b/src/robots/RefreshRewardsRobot.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import {IStaticATokenFactory} from '../interfaces/IStaticATokenFactory.sol'; +import {IRefreshRewardsRobot, AutomationCompatibleInterface} from '../interfaces/IRefreshRewardsRobot.sol'; +import {IStaticATokenLM} from '../interfaces/IStaticATokenLM.sol'; +import {IRewardsController} from 'aave-v3-periphery/contracts/rewards/interfaces/IRewardsController.sol'; +import {Ownable} from 'solidity-utils/contracts/oz-common/Ownable.sol'; + +/** + * @title RefreshRewardsRobot + * @author BGD Labs + * @notice Automation contract to automate refresh rewards for staticATokens if a reward + * is added after staticAToken creation to register the missing rewards. + */ +contract RefreshRewardsRobot is Ownable, IRefreshRewardsRobot { + mapping(address => bool) internal disabledStaticATokens; + + /// @inheritdoc IRefreshRewardsRobot + IStaticATokenFactory public immutable STATIC_A_TOKEN_FACTORY; + + /// @inheritdoc IRefreshRewardsRobot + IRewardsController public immutable REWARDS_CONTROLLER; + + /// @inheritdoc IRefreshRewardsRobot + uint256 public constant MAX_ACTIONS = 10; + + /** + * @param staticATokenFactory address of the static a token factory contract. + * @param rewardsController address of the rewards controller of the protocol. + */ + constructor(address staticATokenFactory, address rewardsController) { + STATIC_A_TOKEN_FACTORY = IStaticATokenFactory(staticATokenFactory); + REWARDS_CONTROLLER = IRewardsController(rewardsController); + } + + /** + * @inheritdoc AutomationCompatibleInterface + * @dev runs off-chain, checks if there is a reward added after statToken creation which needs to be registered. + */ + function checkUpkeep(bytes memory) public view virtual override returns (bool, bytes memory) { + address[] memory staticATokensToRefresh = new address[](MAX_ACTIONS); + address[] memory staticATokens = STATIC_A_TOKEN_FACTORY.getStaticATokens(); + uint256 actionsCount = 0; + + for (uint i = 0; i < staticATokens.length; i++) { + address aTokenAddress = address(IStaticATokenLM(staticATokens[i]).aToken()); + address[] memory rewards = REWARDS_CONTROLLER.getRewardsByAsset(aTokenAddress); + + for (uint256 j = 0; j < rewards.length; j++) { + bool isRegisteredReward = IStaticATokenLM(staticATokens[i]).isRegisteredRewardToken( + rewards[j] + ); + if (!isRegisteredReward && actionsCount < MAX_ACTIONS && !isDisabled(staticATokens[i])) { + staticATokensToRefresh[actionsCount] = staticATokens[i]; + actionsCount++; + break; + } + } + } + + if (actionsCount > 0) { + // we do not know the length in advance, so we init arrays with MAX_ACTIONS + // and then squeeze the array using mstore + assembly { + mstore(staticATokensToRefresh, actionsCount) + } + bytes memory performData = abi.encode(staticATokensToRefresh); + return (true, performData); + } + + return (false, ''); + } + + /** + * @dev executes refreshRewardTokens() on the staticAToken to register the missing rewards + * @param performData array of staticATokens for which refresh needs to be performed + */ + function performUpkeep(bytes calldata performData) external override { + address[] memory staticATokensToRefresh = abi.decode(performData, (address[])); + + for (uint256 i = 0; i < staticATokensToRefresh.length; i++) { + IStaticATokenLM(staticATokensToRefresh[i]).refreshRewardTokens(); + emit RefreshSucceeded(staticATokensToRefresh[i]); + } + } + + /// @inheritdoc IRefreshRewardsRobot + function isDisabled(address staticAToken) public view returns (bool) { + return disabledStaticATokens[staticAToken]; + } + + /// @inheritdoc IRefreshRewardsRobot + function disableAutomation(address staticAToken, bool disable) external onlyOwner { + disabledStaticATokens[staticAToken] = disable; + } +} diff --git a/tests/GasCappedRefreshRewardsRobot.t.sol b/tests/GasCappedRefreshRewardsRobot.t.sol new file mode 100644 index 0000000..d0952e1 --- /dev/null +++ b/tests/GasCappedRefreshRewardsRobot.t.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.10; + +import {GasCappedRefreshRewardsRobot} from '../src/robots/GasCappedRefreshRewardsRobot.sol'; +import {AggregatorInterface} from 'aave-address-book/AaveV3.sol'; +import './RefreshRewardsRobot.t.sol'; + +contract GasCappedRefreshRewardsRobotTest is RefreshRewardsRobotTest { + address public constant FAST_GAS_FEED = 0xd1cC11c5102bE7Dd8919715E6b04e1Af1e43fdc4; + uint256 public constant CURRENT_GAS_PRICE = 10 gwei; + + event MaxGasPriceSet(uint256 indexed maxGasPrice); + + function setUp() public virtual override { + super.setUp(); + vm.stopPrank(); + + vm.startPrank(GUARDIAN); + robotKeeper = new GasCappedRefreshRewardsRobot( + address(factory), + AaveV3Avalanche.DEFAULT_INCENTIVES_CONTROLLER, + FAST_GAS_FEED + ); + GasCappedRefreshRewardsRobot(address(robotKeeper)).setMaxGasPrice(CURRENT_GAS_PRICE); + + vm.stopPrank(); + + // mock the gas price feed to return current gas price as the deployed one is a placeholder feed + vm.mockCall( + FAST_GAS_FEED, + abi.encodeWithSelector(AggregatorInterface.latestAnswer.selector), + abi.encode(CURRENT_GAS_PRICE) + ); + } + + function test_setMaxGasPrice(uint256 newMaxGasPrice) public { + vm.expectEmit(); + emit MaxGasPriceSet(newMaxGasPrice); + + vm.startPrank(GUARDIAN); + GasCappedRefreshRewardsRobot(address(robotKeeper)).setMaxGasPrice(newMaxGasPrice); + vm.stopPrank(); + + assertEq(GasCappedRefreshRewardsRobot(address(robotKeeper)).getMaxGasPrice(), newMaxGasPrice); + + vm.expectRevert('Ownable: caller is not the owner'); + vm.startPrank(address(5)); + GasCappedRefreshRewardsRobot(address(robotKeeper)).setMaxGasPrice(newMaxGasPrice); + vm.stopPrank(); + } + + function test_isGasPriceInRange() public virtual { + assertEq(GasCappedRefreshRewardsRobot(address(robotKeeper)).isGasPriceInRange(), true); + + vm.startPrank(GUARDIAN); + GasCappedRefreshRewardsRobot(address(robotKeeper)).setMaxGasPrice(CURRENT_GAS_PRICE - 1); + vm.stopPrank(); + + assertEq(GasCappedRefreshRewardsRobot(address(robotKeeper)).isGasPriceInRange(), false); + } + + function test_robotExecutionOnlyWhenGasPriceInRange() public virtual { + // set the max gas price of the robot to lesser than the current gas price + vm.startPrank(GUARDIAN); + GasCappedRefreshRewardsRobot(address(robotKeeper)).setMaxGasPrice(CURRENT_GAS_PRICE - 1); + vm.stopPrank(); + + address wethStaticAToken = factory.getStaticAToken(AaveV3AvalancheAssets.WETHe_UNDERLYING); + address wbtcStaticAToken = factory.getStaticAToken(AaveV3AvalancheAssets.WBTCe_UNDERLYING); + address maiStaticAToken = factory.getStaticAToken(AaveV3AvalancheAssets.MAI_UNDERLYING); + address fraxStaticAToken = factory.getStaticAToken(AaveV3AvalancheAssets.FRAX_UNDERLYING); + + _createNewLM(); + + // robot did not run as the network gas price is more than the max configured gas price of the robot + bool didRobotRun = _checkAndPerformUpKeep(robotKeeper); + assertEq(didRobotRun, false); + + vm.startPrank(GUARDIAN); + GasCappedRefreshRewardsRobot(address(robotKeeper)).setMaxGasPrice(CURRENT_GAS_PRICE); + vm.stopPrank(); + + didRobotRun = _checkAndPerformUpKeep(robotKeeper); + assertEq(didRobotRun, true); + + assertEq(IStaticATokenLM(wethStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), true); + assertEq(IStaticATokenLM(wbtcStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), true); + assertEq(IStaticATokenLM(maiStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), true); + assertEq(IStaticATokenLM(fraxStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), true); + } +} diff --git a/tests/GelatoGasCappedRefreshRewardsRobot.t.sol b/tests/GelatoGasCappedRefreshRewardsRobot.t.sol new file mode 100644 index 0000000..664ee3e --- /dev/null +++ b/tests/GelatoGasCappedRefreshRewardsRobot.t.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import {GelatoGasCappedRefreshRewardsRobot} from '../src/robots/GelatoGasCappedRefreshRewardsRobot.sol'; +import './GasCappedRefreshRewardsRobot.t.sol'; + +contract GelatoGasCappedRefreshRewardsRobotTest is GasCappedRefreshRewardsRobotTest { + function setUp() public virtual override { + super.setUp(); + + vm.startPrank(GUARDIAN); + robotKeeper = new GelatoGasCappedRefreshRewardsRobot( + address(factory), + AaveV3Avalanche.DEFAULT_INCENTIVES_CONTROLLER + ); + GasCappedRefreshRewardsRobot(address(robotKeeper)).setMaxGasPrice(CURRENT_GAS_PRICE); + + vm.stopPrank(); + + // set the gasPrice of the network, as the default is 0 + vm.txGasPrice(CURRENT_GAS_PRICE); + } + + function _checkAndPerformUpKeep( + RefreshRewardsRobot refreshRewardsRobot + ) internal override returns (bool) { + (bool shouldRunKeeper, bytes memory encodedPerformData) = refreshRewardsRobot.checkUpkeep(''); + if (shouldRunKeeper) { + (bool status, ) = address(refreshRewardsRobot).call(encodedPerformData); + assertTrue(status, 'Perform Upkeep Failed'); + } + return shouldRunKeeper; + } +} diff --git a/tests/RefreshRewardsRobot.t.sol b/tests/RefreshRewardsRobot.t.sol new file mode 100644 index 0000000..480a185 --- /dev/null +++ b/tests/RefreshRewardsRobot.t.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.10; + +import {AaveV3Avalanche, AaveV3AvalancheAssets, IPool} from 'aave-address-book/AaveV3Avalanche.sol'; +import {RefreshRewardsRobot} from '../src/robots/RefreshRewardsRobot.sol'; +import {IEmissionManager, RewardsDataTypes, IEACAggregatorProxy} from 'aave-v3-periphery/contracts/rewards/interfaces/IEmissionManager.sol'; +import {ITransferStrategyBase} from 'aave-v3-periphery/contracts/rewards/interfaces/ITransferStrategyBase.sol'; +import './TestBase.sol'; + +contract RefreshRewardsRobotTest is BaseTest { + uint256 constant TOTAL_DISTRIBUTION = 10000 ether; + uint88 constant DURATION_DISTRIBUTION = 180 days; + + struct EmissionPerAsset { + address asset; + uint256 emission; + } + + IEACAggregatorProxy REWARD_ORACLE = IEACAggregatorProxy(AaveV3AvalancheAssets.AAVEe_ORACLE); + + ITransferStrategyBase TRANSFER_STRATEGY = + ITransferStrategyBase(0x190110114Eff8B111123BEa9b517Fc86b677D94A); + + IPool public override pool = IPool(AaveV3Avalanche.POOL); + RefreshRewardsRobot robotKeeper; + + address public constant override UNDERLYING = 0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB; + address public constant override A_TOKEN = 0xe50fA9b3c56FfB159cB0FCA61F5c9D750e8128c8; + address public constant EMISSION_ADMIN = 0xa35b76E4935449E33C56aB24b23fcd3246f13470; + address public REWARD_TOKEN = AaveV3AvalancheAssets.AAVEe_UNDERLYING; + address public GUARDIAN = address(22); + + function setUp() public virtual override { + vm.createSelectFork(vm.rpcUrl('avalanche'), 25016463); + + super.setUp(); + vm.stopPrank(); + + vm.prank(GUARDIAN); + robotKeeper = new RefreshRewardsRobot( + address(factory), + AaveV3Avalanche.DEFAULT_INCENTIVES_CONTROLLER + ); + } + + function testRefreshRewards() public { + address wethStaticAToken = factory.getStaticAToken(AaveV3AvalancheAssets.WETHe_UNDERLYING); + address wbtcStaticAToken = factory.getStaticAToken(AaveV3AvalancheAssets.WBTCe_UNDERLYING); + address maiStaticAToken = factory.getStaticAToken(AaveV3AvalancheAssets.MAI_UNDERLYING); + address fraxStaticAToken = factory.getStaticAToken(AaveV3AvalancheAssets.FRAX_UNDERLYING); + + _createNewLM(); + + assertEq(IStaticATokenLM(wethStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), false); + assertEq(IStaticATokenLM(wbtcStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), false); + assertEq(IStaticATokenLM(maiStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), false); + assertEq(IStaticATokenLM(fraxStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), false); + + bool didRobotRun = _checkAndPerformUpKeep(robotKeeper); + assertTrue(didRobotRun); + + assertEq(IStaticATokenLM(wethStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), true); + assertEq(IStaticATokenLM(wbtcStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), true); + assertEq(IStaticATokenLM(maiStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), true); + assertEq(IStaticATokenLM(fraxStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), true); + } + + function test_disableAutomation() public { + address wethStaticAToken = factory.getStaticAToken(AaveV3AvalancheAssets.WETHe_UNDERLYING); + address wbtcStaticAToken = factory.getStaticAToken(AaveV3AvalancheAssets.WBTCe_UNDERLYING); + address maiStaticAToken = factory.getStaticAToken(AaveV3AvalancheAssets.MAI_UNDERLYING); + address fraxStaticAToken = factory.getStaticAToken(AaveV3AvalancheAssets.FRAX_UNDERLYING); + + _createNewLM(); + + assertEq(IStaticATokenLM(wethStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), false); + assertEq(IStaticATokenLM(wbtcStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), false); + assertEq(IStaticATokenLM(maiStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), false); + assertEq(IStaticATokenLM(fraxStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), false); + + vm.startPrank(GUARDIAN); + robotKeeper.disableAutomation(wethStaticAToken, true); + robotKeeper.disableAutomation(wbtcStaticAToken, true); + robotKeeper.disableAutomation(maiStaticAToken, true); + robotKeeper.disableAutomation(fraxStaticAToken, true); + vm.stopPrank(); + + assertTrue(robotKeeper.isDisabled(wethStaticAToken)); + assertTrue(robotKeeper.isDisabled(wbtcStaticAToken)); + assertTrue(robotKeeper.isDisabled(maiStaticAToken)); + assertTrue(robotKeeper.isDisabled(wethStaticAToken)); + + bool didRobotRun = _checkAndPerformUpKeep(robotKeeper); + assertFalse(didRobotRun); + + assertEq(IStaticATokenLM(wethStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), false); + assertEq(IStaticATokenLM(wbtcStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), false); + assertEq(IStaticATokenLM(maiStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), false); + assertEq(IStaticATokenLM(fraxStaticAToken).isRegisteredRewardToken(REWARD_TOKEN), false); + } + + function test_auth_disableAutomation() public { + address caller = address(54); + address staticAToken = factory.getStaticAToken(AaveV3AvalancheAssets.WETHe_UNDERLYING); + + vm.prank(caller); + vm.expectRevert(bytes('Ownable: caller is not the owner')); + robotKeeper.disableAutomation(staticAToken, true); + + vm.prank(GUARDIAN); + robotKeeper.disableAutomation(staticAToken, true); + assertTrue(robotKeeper.isDisabled(staticAToken)); + } + + function _checkAndPerformUpKeep( + RefreshRewardsRobot refreshRewardsRobot + ) internal virtual returns (bool) { + (bool shouldRunKeeper, bytes memory performData) = refreshRewardsRobot.checkUpkeep(''); + if (shouldRunKeeper) { + refreshRewardsRobot.performUpkeep(performData); + } + return shouldRunKeeper; + } + + function _createNewLM() internal { + deal(REWARD_TOKEN, EMISSION_ADMIN, TOTAL_DISTRIBUTION); + vm.stopPrank(); + + vm.startPrank(EMISSION_ADMIN); + IEmissionManager(AaveV3Avalanche.EMISSION_MANAGER).configureAssets(_getAssetConfigs()); + vm.stopPrank(); + } + + function _getAssetConfigs() internal view returns (RewardsDataTypes.RewardsConfigInput[] memory) { + uint32 distributionEnd = uint32(block.timestamp + DURATION_DISTRIBUTION); + + EmissionPerAsset[] memory emissionsPerAsset = _getEmissionsPerAsset(); + + RewardsDataTypes.RewardsConfigInput[] + memory configs = new RewardsDataTypes.RewardsConfigInput[](emissionsPerAsset.length); + for (uint256 i = 0; i < emissionsPerAsset.length; i++) { + configs[i] = RewardsDataTypes.RewardsConfigInput({ + emissionPerSecond: _toUint88(emissionsPerAsset[i].emission / DURATION_DISTRIBUTION), + totalSupply: 0, // IMPORTANT this will not be taken into account by the contracts, so 0 is fine + distributionEnd: distributionEnd, + asset: emissionsPerAsset[i].asset, + reward: REWARD_TOKEN, + transferStrategy: TRANSFER_STRATEGY, + rewardOracle: REWARD_ORACLE + }); + } + return configs; + } + + function _getEmissionsPerAsset() internal pure returns (EmissionPerAsset[] memory) { + EmissionPerAsset[] memory emissionsPerAsset = new EmissionPerAsset[](4); + emissionsPerAsset[0] = EmissionPerAsset({ + asset: AaveV3AvalancheAssets.WBTCe_A_TOKEN, + emission: TOTAL_DISTRIBUTION / 4 // 25% of the distribution + }); + emissionsPerAsset[1] = EmissionPerAsset({ + asset: AaveV3AvalancheAssets.FRAX_A_TOKEN, + emission: TOTAL_DISTRIBUTION / 4 // 25% of the distribution + }); + emissionsPerAsset[2] = EmissionPerAsset({ + asset: AaveV3AvalancheAssets.MAI_A_TOKEN, + emission: TOTAL_DISTRIBUTION / 4 // 25% of the distribution + }); + emissionsPerAsset[3] = EmissionPerAsset({ + asset: AaveV3AvalancheAssets.WETHe_A_TOKEN, + emission: TOTAL_DISTRIBUTION / 4 // 25% of the distribution + }); + uint256 totalDistribution; + for (uint256 i = 0; i < emissionsPerAsset.length; i++) { + totalDistribution += emissionsPerAsset[i].emission; + } + require(totalDistribution == TOTAL_DISTRIBUTION, 'INVALID_SUM_OF_EMISSIONS'); + + return emissionsPerAsset; + } + + function _toUint88(uint256 value) internal pure returns (uint88) { + require(value <= type(uint88).max, "SafeCast: value doesn't fit in 88 bits"); + return uint88(value); + } +}