diff --git a/.github/workflows/comment.yml b/.github/workflows/comment.yml index fd82721..ec080f6 100644 --- a/.github/workflows/comment.yml +++ b/.github/workflows/comment.yml @@ -16,8 +16,5 @@ permissions: jobs: comment: - uses: bgd-labs/github-workflows/.github/workflows/comment.yml@main - secrets: - # the provided PAT needs write and write permissions on issues and pull requests - # the PAT is only really needed when commenting of prs from forks - READ_ONLY_PAT: ${{ secrets.READ_ONLY_PAT }} + uses: aave-dao/github-workflows/.github/workflows/comment.yml@main + secrets: inherit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 09c5649..dfa05bd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,7 @@ permissions: jobs: lint: - uses: bgd-labs/github-workflows/.github/workflows/foundry-lint.yml@main + uses: aave-dao/github-workflows/.github/workflows/foundry-lint.yml@main test: - uses: bgd-labs/github-workflows/.github/workflows/foundry-test.yml@main + uses: aave-dao/github-workflows/.github/workflows/foundry-test.yml@main + secrets: inherit 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..c7550ee 100644 --- a/foundry.lock +++ b/foundry.lock @@ -2,7 +2,7 @@ "lib/aave-helpers": { "branch": { "name": "main", - "rev": "0459d4ef721c6306a194a36a559a8462f9b19633" + "rev": "5364521bed05b3b261a21811a66cba7e23ea0cb6" } } -} \ No newline at end of file +} diff --git a/lib/aave-helpers b/lib/aave-helpers index 0459d4e..5364521 160000 --- a/lib/aave-helpers +++ b/lib/aave-helpers @@ -1 +1 @@ -Subproject commit 0459d4ef721c6306a194a36a559a8462f9b19633 +Subproject commit 5364521bed05b3b261a21811a66cba7e23ea0cb6 diff --git a/remappings.txt b/remappings.txt index 94cf46e..9552b24 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-helpers/lib/aave-address-book/lib/aave-v4/src/ +lib/aave-helpers/lib/aave-address-book/lib/aave-v4/:src=lib/aave-helpers/lib/aave-address-book/lib/aave-v4/src/ diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 75d8a7a..c91b80a 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -7,13 +7,16 @@ 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"; import {GovernanceV3Ethereum} from "aave-address-book/GovernanceV3Ethereum.sol"; import {MiscEthereum} from "aave-address-book/MiscEthereum.sol"; +import {GhoEthereum} from "aave-address-book/GhoEthereum.sol"; +import {AaveV4EthereumHubs} from "aave-address-book/AaveV4Ethereum.sol"; library DeploymentLibrary { function _deployFacilitator( @@ -32,6 +35,31 @@ 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 _deployV4Core() internal returns (address) { + return _deployV4Facilitator( + ITransparentProxyFactory(MiscEthereum.TRANSPARENT_PROXY_FACTORY), + GovernanceV3Ethereum.EXECUTOR_LVL_1, + address(AaveV4EthereumHubs.CORE_HUB), + GhoEthereum.GHO_TOKEN, + GhoEthereum.RISK_COUNCIL + ); + } + function _deployCore() internal returns (address) { address council = 0x8513e6F37dBc52De87b166980Fa3F50639694B60; @@ -51,3 +79,9 @@ contract DeployCore is EthereumScript { DeploymentLibrary._deployCore(); } } + +contract DeployV4Core is EthereumScript { + function run() external broadcast { + DeploymentLibrary._deployV4Core(); + } +} diff --git a/src/GhoDirectMinterV4.sol b/src/GhoDirectMinterV4.sol new file mode 100644 index 0000000..e290b2a --- /dev/null +++ b/src/GhoDirectMinterV4.sol @@ -0,0 +1,74 @@ +// 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 {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..20db568 --- /dev/null +++ b/src/interfaces/IGhoDirectMinterV4.sol @@ -0,0 +1,37 @@ +// 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. + function hub() external view returns (address); + + /// @notice Returns the asset identifier for GHO in the Hub. + function assetId() external view returns (uint256); + + /// @notice Returns the address of the GHO token. + function gho() external view returns (address); +} diff --git a/test/GhoDirectMinterV4.t.sol b/test/GhoDirectMinterV4.t.sol new file mode 100644 index 0000000..40c641a --- /dev/null +++ b/test/GhoDirectMinterV4.t.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import {GovernanceV3Ethereum} from "aave-address-book/GovernanceV3Ethereum.sol"; +import {GhoEthereum} from "aave-address-book/GhoEthereum.sol"; +import {AaveV4Ethereum, AaveV4EthereumHubs} from "aave-address-book/AaveV4Ethereum.sol"; +import {IWithGuardian} from "solidity-utils/contracts/access-control/UpgradeableOwnableWithGuardian.sol"; +import {IHub} from "aave-v4/hub/interfaces/IHub.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 { + IHub internal hub = AaveV4EthereumHubs.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(GhoEthereum.GHO_TOKEN); + uint128 internal constant MINT_AMOUNT = 200_000 ether; + + function setUp() external { + vm.createSelectFork(vm.rpcUrl("mainnet"), 26155052); + + minter = IGhoDirectMinterV4(DeploymentLibrary._deployV4Core()); + ghoAssetId = hub.getAssetId(address(gho)); + feeReceiver = hub.getAssetConfig(ghoAssetId).feeReceiver; + + // register minter as spoke on Hub with infinite addCap + vm.startPrank(owner); + AaveV4Ethereum.HUB_CONFIGURATOR + .addSpoke( + address(hub), + address(minter), + ghoAssetId, + 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(uint256 supplyAmount, uint256 drawAmount, uint256 elapsed) external { + uint256 amount = _mintAndSupply(bound(supplyAmount, 1 ether, MINT_AMOUNT), owner); + drawAmount = bound(drawAmount, 1, amount); + elapsed = bound(elapsed, 1 days, 5 * 365 days); + + // set up a borrower spoke that can draw GHO + address borrower = makeAddr("borrower"); + vm.prank(owner); + AaveV4Ethereum.HUB_CONFIGURATOR + .addSpoke( + address(hub), + borrower, + ghoAssetId, + 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, drawAmount, makeAddr("borrowerRecipient")); + skip(elapsed); + + 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, 2); + uint256 feeReceiverSharesAfter = hub.getSpokeAddedShares(ghoAssetId, feeReceiver); + assertApproxEqAbs(feeReceiverSharesAfter - feeReceiverSharesBefore, expectedShares, 1); + uint256 feeReceiverBalanceAfter = hub.getSpokeAddedAssets(ghoAssetId, feeReceiver); + assertApproxEqAbs(feeReceiverBalanceAfter - feeReceiverBalanceBefore, excess, 2); + } + + 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, 2); + assertApproxEqAbs(hub.getAddedAssets(ghoAssetId), totalAddedAssetsBefore + amount, 2); + // 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, 3); + assertApproxEqAbs(hub.getSpokeAddedAssets(ghoAssetId, address(minter)), amount - withdrawAmount, 3); + assertEq(levelAfter, levelBefore - withdrawAmount); + } +}