diff --git a/.gitmodules b/.gitmodules index 9141ff3..8c10ab4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,7 @@ path = lib/aave-helpers url = https://github.com/aave-dao/aave-helpers branch = main +[submodule "lib/aave-v4"] + path = lib/aave-v4 + url = https://github.com/aave/aave-v4 + branch = v0.5.9 diff --git a/README.md b/README.md index c7c5e99..5cbfd47 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,21 @@ ![header](./header.png) -The GHO direct minter is a generic facilitator that can inject GHO into an aave pool. +The GHO direct minter is a generic facilitator that can inject GHO into an Aave pool. ### Summary The `GhoDirectMinter` is a smart contract that can be used to mint & burn GHO directly into/from an Aave pool. In order to mint GHO the `GhoDirectMinter` will need to be registered as a `Facilitator` in the GHO contract. -This repository contains two contracts: +This repository contains the following contracts: -- [`GhoDirectMinter`](./src/GhoDirectMinter.sol) which contains the actual Facilitator -- [`LidoGHOListing`](./src/proposals/LidoGHOListing.sol) which is a reference implementation of a proposal to 1) list GHO on Aave Lido instance and 2) deploy and active a `GhoDirectMinter` facilitator. +- [`GhoDirectMinter`](./src/GhoDirectMinter.sol) — Facilitator for Aave v3 pools. +- [`GhoDirectMinterV4`](./src/GhoDirectMinterV4.sol) — Facilitator for the Aave v4 Hub. -### Specification +--- + +### GhoDirectMinter (v3) **Prerequisites:** @@ -29,13 +31,42 @@ The `GhoDirectMinter` offers the following functions: - `withdrawAndBurn` which allows a permissioned entity to withdraw GHO from the pool and burn it. - `transferExcessToTreasury` which allows the permissionless transfer of the accrued fee to the collector. -While default permissioned entity is the owner(likely the governance short executor), but the contract inherits from [UpgradeableOwnableWithGuardian](https://github.com/bgd-labs/solidity-utils/blob/main/src/contracts/access-control/UpgradeableOwnableWithGuardian.sol) which allows to share permissions with another party (e.g. the GHO stewards). +While default permissioned entity is the owner (likely the governance short executor), the contract inherits from [UpgradeableOwnableWithGuardian](https://github.com/bgd-labs/solidity-utils/blob/main/src/contracts/access-control/UpgradeableOwnableWithGuardian.sol) which allows to share permissions with another party (e.g. the GHO stewards). -### Risk considerations +**Risk considerations:** The `GhoDirectMinter` can only inject and remove available GHO from the pool. The actual maximum exposure of the reserve is managed via the `BucketSize` and the chosen `borrow cap`. +--- + +### GhoDirectMinterV4 + +The `GhoDirectMinterV4` is the Aave v4 equivalent. Instead of interacting with an Aave v3 Pool, it injects GHO as liquidity into an Aave v4 Hub. + +**Prerequisites:** + +- GHO must be registered as an asset on the Hub. +- the `GhoDirectMinterV4` must be registered as a **spoke** on the Hub for the GHO asset with `addCap` set to `MAX_ALLOWED_SPOKE_CAP` and `drawCap` set to `0`. +- the `GhoDirectMinterV4` must be registered as a GHO `Facilitator` with a non-zero bucket capacity. +- the Hub's `AccessManager` must grant `HUB_ADMIN_ROLE` to the entity that will call `addSpoke` (e.g. the governance executor). + +The `GhoDirectMinterV4` offers the following functions: + +- `mintAndSupply` — mints GHO directly to the Hub and calls `hub.add()` to register the added liquidity. +- `withdrawAndBurn` — calls `hub.remove()` to withdraw GHO and then burns it. +- `transferExcessToTreasury` — computes the excess shares (spoke balance above the facilitator bucket level) and transfers them to the Hub's fee receiver via `hub.payFeeShares()`. + +**Caveats:** + +- The Hub uses a share-based accounting model. Share-to-asset conversions may introduce small rounding differences (typically ±1 wei). The `transferExcessToTreasury` function underestimates excess shares to ensure the facilitator always remains at or above its bucket level. +- The constructor derives `ASSET_ID` from `hub.getAssetId(gho)`, so GHO must already be registered on the Hub at deployment time. + +**Risk considerations:** + +The `GhoDirectMinterV4` can only inject and remove available GHO from the Hub. +The actual maximum exposure is managed via the GHO `BucketSize`, the spoke's `addCap`, and Hub-level parameters. + ## Development This project uses [Foundry](https://getfoundry.sh). See the [book](https://book.getfoundry.sh/getting-started/installation.html) for detailed instructions on how to install and use Foundry. diff --git a/foundry.lock b/foundry.lock index 706cf63..4df9105 100644 --- a/foundry.lock +++ b/foundry.lock @@ -4,5 +4,11 @@ "name": "main", "rev": "0459d4ef721c6306a194a36a559a8462f9b19633" } + }, + "lib/aave-v4": { + "tag": { + "name": "v0.5.9", + "rev": "c3b92877ec49c791e7eaaf77afe73eabd9bb7b1e" + } } } \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index c7f740c..bbf661b 100644 --- a/foundry.toml +++ b/foundry.toml @@ -33,6 +33,7 @@ gnosis = "${RPC_GNOSIS}" zkEVM = "${RPC_ZKEVM}" celo = "${RPC_CELO}" zksync = "${RPC_ZKSYNC}" +devnet = "https://virtual.mainnet-aave.us-east.rpc.tenderly.co/38393fd3-0a79-4e60-b8cc-c6bb5903454a" [fmt] tab_width = 2 diff --git a/lib/aave-v4 b/lib/aave-v4 new file mode 160000 index 0000000..c3b9287 --- /dev/null +++ b/lib/aave-v4 @@ -0,0 +1 @@ +Subproject commit c3b92877ec49c791e7eaaf77afe73eabd9bb7b1e diff --git a/remappings.txt b/remappings.txt index 94cf46e..f8c559e 100644 --- a/remappings.txt +++ b/remappings.txt @@ -7,3 +7,5 @@ forge-std/=lib/aave-helpers/lib/forge-std/src/ openzeppelin-contracts-upgradeable/=lib/aave-helpers/lib/aave-address-book/lib/aave-v3-origin/lib/solidity-utils/lib/openzeppelin-contracts-upgradeable/ openzeppelin-contracts/=lib/aave-helpers/lib/aave-address-book/lib/aave-v3-origin/lib/solidity-utils/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/ solidity-utils/=lib/aave-helpers/lib/aave-address-book/lib/aave-v3-origin/lib/solidity-utils/src/ +aave-v4/=lib/aave-v4/src/ +lib/aave-v4/:src=lib/aave-v4/src/ diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 75d8a7a..038de39 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -7,8 +7,9 @@ import {ICollector} from "aave-v3-origin/contracts/treasury/ICollector.sol"; import { ITransparentProxyFactory } from "solidity-utils/contracts/transparent-proxy/interfaces/ITransparentProxyFactory.sol"; -import {GhoDirectMinter} from "../src/GhoDirectMinter.sol"; -import {IGhoToken} from "../src/interfaces/IGhoToken.sol"; +import {GhoDirectMinter} from "src/GhoDirectMinter.sol"; +import {GhoDirectMinterV4} from "src/GhoDirectMinterV4.sol"; +import {IGhoToken} from "src/interfaces/IGhoToken.sol"; import {AaveV3Ethereum, AaveV3EthereumAssets} from "aave-address-book/AaveV3Ethereum.sol"; import {AaveV3EthereumLido} from "aave-address-book/AaveV3EthereumLido.sol"; @@ -32,6 +33,21 @@ library DeploymentLibrary { ); } + function _deployV4Facilitator( + ITransparentProxyFactory proxyFactory, + address upgradeAdmin, + address hub, + address gho, + address council + ) internal returns (address) { + address impl = address(new GhoDirectMinterV4(hub, gho)); + return proxyFactory.create( + impl, + upgradeAdmin, + abi.encodeCall(GhoDirectMinterV4.initialize, (address(GovernanceV3Ethereum.EXECUTOR_LVL_1), council)) + ); + } + function _deployCore() internal returns (address) { address council = 0x8513e6F37dBc52De87b166980Fa3F50639694B60; diff --git a/src/GhoDirectMinterV4.sol b/src/GhoDirectMinterV4.sol new file mode 100644 index 0000000..f098d00 --- /dev/null +++ b/src/GhoDirectMinterV4.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import { + UpgradeableOwnableWithGuardian +} from "solidity-utils/contracts/access-control/UpgradeableOwnableWithGuardian.sol"; +import {Initializable} from "openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import {SafeERC20, IERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IHub} from "aave-v4/hub/interfaces/IHub.sol"; +import {IGhoDirectMinterV4} from "src/interfaces/IGhoDirectMinterV4.sol"; +import {IGhoToken} from "src/interfaces/IGhoToken.sol"; + +/// @title GhoDirectMinterV4 +/// @author Aave Labs +/// @notice A GHO facilitator that injects (mints) and removes (burns) GHO from an Aave V4 Hub. +/// @dev The GhoDirectMinterV4 is expected to be registered as a spoke on the Hub with infinite addCap. +contract GhoDirectMinterV4 is Initializable, UpgradeableOwnableWithGuardian, IGhoDirectMinterV4 { + IGhoToken internal immutable GHO; + IHub internal immutable HUB; + uint256 internal immutable ASSET_ID; + + /// @dev Constructor. + /// @param hub_ The address of the Aave v4 Hub. + /// @param gho_ The address of the GHO token. + constructor(address hub_, address gho_) { + _disableInitializers(); + HUB = IHub(hub_); + ASSET_ID = HUB.getAssetId(gho_); // reverts on invalid `underlying` + GHO = IGhoToken(gho_); + } + + /// @inheritdoc IGhoDirectMinterV4 + function initialize(address owner, address council) external virtual initializer { + __Ownable_With_Guardian_init(owner, council); + } + + /// @inheritdoc IGhoDirectMinterV4 + function mintAndSupply(uint256 amount) external onlyOwnerOrGuardian { + GHO.mint(address(HUB), amount); + HUB.add(ASSET_ID, amount); // this spoke is given infinite cap + } + + /// @inheritdoc IGhoDirectMinterV4 + function withdrawAndBurn(uint256 amount) external onlyOwnerOrGuardian { + HUB.remove(ASSET_ID, amount, address(this)); + GHO.burn(amount); + } + + /// @inheritdoc IGhoDirectMinterV4 + function transferExcessToTreasury() external { + (, uint256 level) = GHO.getFacilitatorBucket(address(this)); + uint256 balance = HUB.getSpokeAddedAssets(ASSET_ID, address(this)); + uint256 excess = balance - level; + // underestimate excess shares to ensure the facilitator remains at or above bucket level + uint256 excessShares = HUB.previewAddByAssets(ASSET_ID, excess); + if (excessShares > 0) { + HUB.payFeeShares(ASSET_ID, excessShares); + } + } + + /// @inheritdoc IGhoDirectMinterV4 + function hub() external view returns (address) { + return address(HUB); + } + + /// @inheritdoc IGhoDirectMinterV4 + function assetId() external view returns (uint256) { + return ASSET_ID; + } + + /// @inheritdoc IGhoDirectMinterV4 + function gho() external view returns (address) { + return address(GHO); + } +} diff --git a/src/interfaces/IGhoDirectMinterV4.sol b/src/interfaces/IGhoDirectMinterV4.sol new file mode 100644 index 0000000..51ad1af --- /dev/null +++ b/src/interfaces/IGhoDirectMinterV4.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/// @title IGhoDirectMinterV4 +/// @author Aave Labs +/// @notice Interface for the GhoDirectMinter, a GHO facilitator that injects (mints) and removes (burns) GHO from an Aave V4 Hub. +/// @dev The GhoDirectMinter is expected to be registered as a spoke on the Hub with infinite addCap. +interface IGhoDirectMinterV4 { + /// @notice Initializes the contract. + /// @param owner The address of the owner. + /// @param council The address of the guardian council. + function initialize(address owner, address council) external; + + /// @notice Mints GHO and adds it as liquidity to the Hub. + /// @dev Only callable by the owner or guardian. + /// @param amount The amount of GHO to mint and supply. + function mintAndSupply(uint256 amount) external; + + /// @notice Removes GHO liquidity from the Hub and burns it. + /// @dev Only callable by the owner or guardian. + /// @param amount The amount of GHO to withdraw and burn. + function withdrawAndBurn(uint256 amount) external; + + /// @notice Transfers excess GHO interest (added shares above facilitator bucket level) to the fee receiver. + /// @dev Callable by anyone. + /// @dev Due to rounding in the share conversion, the amount transferred may be slightly less than the true excess. + function transferExcessToTreasury() external; + + /// @notice Returns the address of the Aave v4 Hub. + /// @return The Hub contract address. + function hub() external view returns (address); + + /// @notice Returns the asset identifier for GHO in the Hub. + /// @return The asset identifier. + function assetId() external view returns (uint256); + + /// @notice Returns the address of the GHO token. + /// @return The GHO token address. + function gho() external view returns (address); +} diff --git a/test/GhoDirectMinterV4.t.sol b/test/GhoDirectMinterV4.t.sol new file mode 100644 index 0000000..9515f9a --- /dev/null +++ b/test/GhoDirectMinterV4.t.sol @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import {MiscEthereum} from "aave-address-book/MiscEthereum.sol"; +import {AaveV3EthereumAssets} from "aave-address-book/AaveV3Ethereum.sol"; +import {GovernanceV3Ethereum} from "aave-address-book/GovernanceV3Ethereum.sol"; +import {GhoEthereum} from "aave-address-book/GhoEthereum.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import { + ITransparentProxyFactory +} from "solidity-utils/contracts/transparent-proxy/interfaces/ITransparentProxyFactory.sol"; +import {IWithGuardian} from "solidity-utils/contracts/access-control/UpgradeableOwnableWithGuardian.sol"; +import {IHub} from "aave-v4/hub/interfaces/IHub.sol"; +import {IHubBase} from "aave-v4/hub/interfaces/IHubBase.sol"; +import {IAccessManager} from "aave-v4/dependencies/openzeppelin/IAccessManager.sol"; +import {IGhoDirectMinterV4} from "../src/interfaces/IGhoDirectMinterV4.sol"; +import {IGhoToken} from "../src/interfaces/IGhoToken.sol"; +import {DeploymentLibrary} from "../script/Deploy.s.sol"; + +contract GHODirectMinterV4_Test is Test { + // @dev deployments on mainnet fork + IHub internal hub = IHub(0x3Ed2C9829FBCab6015E331a0352F8ae148217D70); // core hub + + uint256 internal ghoAssetId; + address internal feeReceiver; + + address internal council = GhoEthereum.RISK_COUNCIL; + address internal owner = GovernanceV3Ethereum.EXECUTOR_LVL_1; + + IGhoDirectMinterV4 internal minter; + IGhoToken internal gho = IGhoToken(AaveV3EthereumAssets.GHO_UNDERLYING); + uint128 internal constant MINT_AMOUNT = 200_000 ether; + + function setUp() external { + vm.createSelectFork(vm.rpcUrl("devnet"), 25097390); + + minter = IGhoDirectMinterV4( + DeploymentLibrary._deployV4Facilitator( + ITransparentProxyFactory(MiscEthereum.TRANSPARENT_PROXY_FACTORY), owner, address(hub), address(gho), council + ) + ); + ghoAssetId = hub.getAssetId(address(gho)); + feeReceiver = hub.getAssetConfig(ghoAssetId).feeReceiver; + + // register minter as spoke on Hub with infinite addCap + vm.startPrank(owner); + hub.addSpoke( + ghoAssetId, + address(minter), + IHub.SpokeConfig({ + addCap: hub.MAX_ALLOWED_SPOKE_CAP(), drawCap: 0, riskPremiumThreshold: 0, active: true, halted: false + }) + ); + + // register minter as GHO facilitator + gho.addFacilitator(address(minter), "GhoDirectMinterCoreHub", MINT_AMOUNT); + vm.stopPrank(); + } + + function test_setup() public view { + assertEq(minter.hub(), address(hub)); + assertEq(minter.gho(), address(gho)); + assertEq(minter.assetId(), ghoAssetId); + assertEq(hub.getAsset(ghoAssetId).underlying, address(gho)); + address[] memory facilitators = gho.getFacilitatorsList(); + assertEq(facilitators[facilitators.length - 1], address(minter)); + assertEq(hub.getSpokeAddedAssets(ghoAssetId, address(minter)), 0); + } + + function test_mintAndSupply_owner(uint256 amount) public returns (uint256) { + return _mintAndSupply(amount, owner); + } + + function test_mintAndSupply_council(uint256 amount) external returns (uint256) { + return _mintAndSupply(amount, council); + } + + function test_mintAndSupply_revertsWith_InvalidCaller() external { + vm.expectRevert(abi.encodeWithSelector(IWithGuardian.OnlyGuardianOrOwnerInvalidCaller.selector, address(this))); + minter.mintAndSupply(100); + } + + function test_withdrawAndBurn_owner(uint256 supplyAmount, uint256 withdrawAmount) external { + _withdrawAndBurn(supplyAmount, withdrawAmount, owner); + } + + function test_withdrawAndBurn_council(uint256 supplyAmount, uint256 withdrawAmount) external { + _withdrawAndBurn(supplyAmount, withdrawAmount, council); + } + + function test_withdrawAndBurn_revertsWith_InvalidCaller() external { + vm.expectRevert(abi.encodeWithSelector(IWithGuardian.OnlyGuardianOrOwnerInvalidCaller.selector, address(this))); + minter.withdrawAndBurn(100); + } + + function test_transferExcessToTreasury() external { + uint256 amount = test_mintAndSupply_owner(1000 ether); + + // set up a borrower spoke that can draw GHO + address borrower = makeAddr("borrower"); + vm.prank(owner); + hub.addSpoke( + ghoAssetId, + borrower, + IHub.SpokeConfig({ + addCap: type(uint40).max, + drawCap: type(uint40).max, + riskPremiumThreshold: type(uint24).max, + active: true, + halted: false + }) + ); + + // generate some yield + vm.prank(borrower); + hub.draw(ghoAssetId, amount, makeAddr("borrowerRecipient")); + skip(365 days); + + uint256 feeReceiverSharesBefore = hub.getSpokeAddedShares(ghoAssetId, feeReceiver); + uint256 feeReceiverBalanceBefore = hub.getSpokeAddedAssets(ghoAssetId, feeReceiver); + (, uint256 level) = gho.getFacilitatorBucket(address(minter)); + uint256 spokeAddedAssets = hub.getSpokeAddedAssets(ghoAssetId, address(minter)); + assertGe(spokeAddedAssets, level); + + uint256 excess = spokeAddedAssets - level; + uint256 expectedShares = hub.previewAddByAssets(ghoAssetId, excess); + + minter.transferExcessToTreasury(); + + assertApproxEqAbs(hub.getSpokeAddedAssets(ghoAssetId, address(minter)), level, 1); + uint256 feeReceiverSharesAfter = hub.getSpokeAddedShares(ghoAssetId, feeReceiver); + assertApproxEqAbs(feeReceiverSharesAfter - feeReceiverSharesBefore, expectedShares, 1); + uint256 feeReceiverBalanceAfter = hub.getSpokeAddedAssets(ghoAssetId, feeReceiver); + assertApproxEqAbs(feeReceiverBalanceAfter - feeReceiverBalanceBefore, excess, 1); + } + + function test_mintAndSupply_exceedsBucketCapacity() external { + // mint full bucket capacity + vm.prank(owner); + minter.mintAndSupply(MINT_AMOUNT); + + // minting 1 more should revert (GHO bucket capacity exceeded) + vm.prank(owner); + vm.expectRevert(bytes("FACILITATOR_BUCKET_CAPACITY_EXCEEDED")); + minter.mintAndSupply(1); + } + + function test_mintAndSupply_zeroAmount() external { + vm.prank(owner); + vm.expectRevert(bytes("INVALID_MINT_AMOUNT")); + minter.mintAndSupply(0); + } + + function test_withdrawAndBurn_exceedsSpokeBalance() external { + vm.prank(owner); + minter.mintAndSupply(1000 ether); + + uint256 spokeBalance = hub.getSpokeAddedAssets(ghoAssetId, address(minter)); + + // withdrawing more than spoke balance underflows spoke.addedShares + vm.prank(owner); + vm.expectRevert(stdError.arithmeticError); + minter.withdrawAndBurn(spokeBalance + 1); + } + + function test_withdrawAndBurn_zeroBalance() external { + // withdrawing when nothing was supplied underflows spoke.addedShares + vm.prank(owner); + vm.expectRevert(stdError.arithmeticError); + minter.withdrawAndBurn(1); + } + + function test_transferExcessToTreasury_noExcess(uint256 amount) external { + amount = bound(amount, 2, MINT_AMOUNT); + vm.prank(owner); + minter.mintAndSupply(amount); + + (, uint256 level) = gho.getFacilitatorBucket(address(minter)); + uint256 balance = hub.getSpokeAddedAssets(ghoAssetId, address(minter)); + + uint256 feeReceiverSharesBefore = hub.getSpokeAddedShares(ghoAssetId, feeReceiver); + + if (balance < level) { + // balance < level due to share rounding → underflow revert + vm.expectRevert(stdError.arithmeticError); + minter.transferExcessToTreasury(); + } else { + // balance == level → excess is 0, no-op + minter.transferExcessToTreasury(); + uint256 feeReceiverSharesAfter = hub.getSpokeAddedShares(ghoAssetId, feeReceiver); + assertEq(feeReceiverSharesAfter, feeReceiverSharesBefore); + } + } + + function _mintAndSupply(uint256 amount, address caller) internal returns (uint256) { + amount = bound(amount, 2, MINT_AMOUNT); + + uint256 totalAddedAssetsBefore = hub.getAddedAssets(ghoAssetId); + uint256 minterAddedAssetsBefore = hub.getSpokeAddedAssets(ghoAssetId, address(minter)); + (, uint256 levelBefore) = gho.getFacilitatorBucket(address(minter)); + + vm.prank(caller); + minter.mintAndSupply(amount); + + (, uint256 levelAfter) = gho.getFacilitatorBucket(address(minter)); + assertApproxEqAbs(hub.getSpokeAddedAssets(ghoAssetId, address(minter)), minterAddedAssetsBefore + amount, 1); + assertApproxEqAbs(hub.getAddedAssets(ghoAssetId), totalAddedAssetsBefore + amount, 1); + // bucket level is exact + assertEq(levelAfter, levelBefore + amount); + + return amount; + } + + function _withdrawAndBurn(uint256 supplyAmount, uint256 withdrawAmount, address caller) internal { + uint256 amount = _mintAndSupply(supplyAmount, owner); + withdrawAmount = bound(withdrawAmount, 1, amount - 1); // rounding + + uint256 totalAddedAssetsBefore = hub.getAddedAssets(ghoAssetId); + (, uint256 levelBefore) = gho.getFacilitatorBucket(address(minter)); + + vm.prank(caller); + minter.withdrawAndBurn(withdrawAmount); + + (, uint256 levelAfter) = gho.getFacilitatorBucket(address(minter)); + assertApproxEqAbs(hub.getAddedAssets(ghoAssetId), totalAddedAssetsBefore - withdrawAmount, 2); + assertApproxEqAbs(hub.getSpokeAddedAssets(ghoAssetId, address(minter)), amount - withdrawAmount, 2); + assertEq(levelAfter, levelBefore - withdrawAmount); + } +}