diff --git a/Makefile b/Makefile index a93614417..36ebce6d6 100644 --- a/Makefile +++ b/Makefile @@ -39,8 +39,30 @@ deploy-precompile :; $(if ${dry},, --broadcast --verify) \ # Step 2: Deploy contracts + grant roles to deployer -# `make deploy-contracts` +# `make deploy-contracts script=AaveV4DeployBase` deploy-contracts :; - FOUNDRY_PROFILE=${chain} forge clean && forge script scripts/deploy/AaveV4DeployBatch.s.sol:AaveV4DeployBatchScript \ + FOUNDRY_PROFILE=${chain} forge clean && forge script scripts/deploy/${script}.s.sol:${script} \ + --rpc-url ${chain} --account ${account} --slow \ + $(if ${dry},, --broadcast --verify) \ + +# Step 3: Configure the market and halt every listed asset on the Hub +# `make configure-market chain=base account= script=AaveV4ConfigureBase` +configure-market :; + FOUNDRY_PROFILE=${chain} forge script scripts/config/${script}.s.sol:${script} \ + --rpc-url ${chain} --account ${account} --slow \ + $(if ${dry},, --broadcast) \ + +# Step 4: Hand the market over and verify the deployer holds nothing +# `make relinquish-market chain=base account= script=AaveV4RelinquishBase` +relinquish-market :; + FOUNDRY_PROFILE=${chain} forge script scripts/config/${script}.s.sol:${script} \ + --rpc-url ${chain} --account ${account} --slow \ + $(if ${dry},, --broadcast) \ + +# Deploys the AaveV4ConfigEngine governance payloads delegatecall into. Independent of the steps +# above: the engine is stateless and sits at a deterministic address. +# `make deploy-config-engine chain=base account= script=DeployBaseConfigEngine` +deploy-config-engine :; + FOUNDRY_PROFILE=${chain} forge script scripts/config/${script}.s.sol:${script} \ --rpc-url ${chain} --account ${account} --slow \ $(if ${dry},, --broadcast --verify) \ diff --git a/config/base-config.json b/config/base-config.json new file mode 100644 index 000000000..b1602f1ca --- /dev/null +++ b/config/base-config.json @@ -0,0 +1,5 @@ +{ + "report": "output/reports/deployments/base.json", + "deployer": "0x0000000000000000000000000000000000000000", + "assets": [] +} diff --git a/config/base.json b/config/base.json new file mode 100644 index 000000000..2eea183db --- /dev/null +++ b/config/base.json @@ -0,0 +1,21 @@ +{ + "accessManagerAdmin": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "proxyAdminOwner": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "treasurySpokeOwner": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "gatewayOwner": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "positionManagerOwner": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "hubConfiguratorAdmin": "0x1111111111111111111111111111111111111111", + "spokeConfiguratorAdmin": "0x1111111111111111111111111111111111111111", + "governanceExecutor": "0x9390B1735def18560c509E2d0bc090E9d6BA257a", + "hubAdmin": "0x0000000000000000000000000000000000000000", + "spokeAdmin": "0x0000000000000000000000000000000000000000", + "nativeWrapper": "0x4200000000000000000000000000000000000006", + "deployNativeTokenGateway": true, + "deploySignatureGateway": true, + "deployPositionManagers": true, + "grantRoles": false, + "hubLabels": ["core"], + "spokeLabels": ["main"], + "spokeMaxReservesLimits": [], + "salt": "0xd72c0dea5ad283a0bacbf56534163edcc6b58ca0c36cc3607fcb4ad4d73597d5" +} diff --git a/docs/base-deploy.md b/docs/base-deploy.md new file mode 100644 index 000000000..fceb14ac7 --- /dev/null +++ b/docs/base-deploy.md @@ -0,0 +1,139 @@ +# Aave V4 on Base — deploy runbook + +Base mainnet, chain id 8453. One Hub (`core`) and one Spoke (`main`), deployed fully halted and handed over to the V4 Security Council. + +Inputs live in `config/base.json` and `config/base-config.json`. Scripts are `scripts/deploy/AaveV4DeployBase.s.sol`, `scripts/config/AaveV4ConfigureBase.s.sol`, `scripts/config/AaveV4RelinquishBase.s.sol` and `scripts/config/DeployBaseConfigEngine.s.sol`. `tests/deployments/AaveV4BaseDeployConfig.t.sol` pins both input files and `tests/deployments/AaveV4BaseConfigureAndRelinquish.t.sol` runs the whole path against a local deployment. + +**Two things are still missing before a real run:** the V4 Security Council executor address, and the launch set with its risk parameters. See [What is still open](#what-is-still-open). + +## The end state + +The market reproduces what the live **Ethereum** V4 market runs with. That map was read off the chain itself rather than inferred, and the tests assert it. + +**Roles.** The Security Council Safe admins the AccessManager. Its executor is what actually executes the Council's configuration payloads, so it holds the two configurator domain admin roles — alongside the Council itself and the DAO's own governance executor, which can both reach the configurators directly. + +| Role | Holder | +| ------------------------------------- | -------------------------------------------------------- | +| `0` ACCESS_MANAGER_ADMIN | Security Council **+** governance executor | +| `101` HUB_CONFIGURATOR_ROLE | the HubConfigurator | +| `200` HUB_CONFIGURATOR_DOMAIN_ADMIN | Council **+** Council executor **+** governance executor | +| `301` SPOKE_CONFIGURATOR_ROLE | the SpokeConfigurator | +| `400` SPOKE_CONFIGURATOR_DOMAIN_ADMIN | Council **+** Council executor **+** governance executor | +| `100`, `102`, `103`, `300`, `302` | nobody | + +The five empty roles reach the Hub and Spokes directly rather than through a configurator, and are unheld on both live markets: nothing at launch calls `mintFeeShares`, `eliminateDeficit` or the user position updaters, and role `0` can grant them when something does. `config/base.json` therefore carries `hubAdmin` and `spokeAdmin` as the zero address, and `AaveV4BaseHandover.verifyRoleHolders` asserts those roles are empty rather than only asserting the deployer is not in them. + +The two configurator domain admin roles carry the same three holders, which is Ethereum's shape. Avalanche differs — it grants neither role to the Council and keeps the governance executor off role `400` — but that asymmetry has no counterpart in how the market is operated, and the Council holding role `0` could grant itself both at any time regardless. `test_relinquishGrantsTheEthereumRoleMap` pins the exact member count of each role, so an extra holder fails the test rather than passing unnoticed. + +**Ownership.** Everything ends up with the Security Council, which is how both live markets read on-chain today. + +| Contract | Owner after deploy | Owner after handover | +| ----------------------------------------------------------- | ------------------ | ------------------------- | +| Hub / Spoke / TreasurySpoke / TokenizationSpoke ProxyAdmins | Council | Council | +| TreasurySpoke | Council | Council | +| Giver / Taker / Config position managers | deployer | Council (after accepting) | +| NativeTokenGateway, SignatureGateway | deployer | Council (after accepting) | + +The split exists because `PositionManagerBase.registerSpoke` is `onlyOwner` and configuration has to call it to wire each manager to each Spoke. `AaveV4DeployBase` therefore forces `gatewayOwner` and `positionManagerOwner` to the deployer at deploy time, and the handover transfers them onward. Everything else belongs to the Council from the deploy transaction onwards, which is what keeps the TreasurySpoke from needing an `Ownable2Step` acceptance of its own. + +**The market is halted.** Configuration halts each asset on the Hub as it lists it, which sets `halted = true` on every Spoke registered for that asset — the main Spoke, the treasury spoke that `addAsset` registers as fee receiver, and the tokenization spoke. `Hub` rejects every liquidity operation against a halted spoke with `SpokeHalted`. + +There is no `unhaltAsset`. Going live is one `updateSpokeHalted(hub, assetId, spoke, false)` per asset-spoke pair, from an address holding role `200`. The Spoke-side reserve flags are left unpaused, so the halt is the only thing holding the market closed and the only thing to undo. + +## Addresses + +| Field | Address | Source | +| ------------------------------------------------- | -------------------------------------------- | ---------------------------------- | +| Security Council (owner, roles `0`, `200`, `400`) | `0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9` | `MiscEthereum.V4_SECURITY_COUNCIL` | +| Council executor (roles `200`, `400`) | **`0x1111…1111` placeholder** | not deployed on Base yet | +| Governance executor (roles `0`, `200`, `400`) | `0x9390B1735def18560c509E2d0bc090E9d6BA257a` | `GovernanceV3Base.EXECUTOR_LVL_1` | + +The Security Council Safe is at the same address on Ethereum, Avalanche and Arc, so Base is expected to match — but **it has no code on Base today**, and neither does any V4 Security Council executor. The executor address is chain-specific (Avalanche and Arc differ), so it cannot be predicted the way the Safe can. + +`AaveV4DeployBase` rejects the placeholder whenever `block.chainid` is 8453, which is what makes a real deploy impossible until `hubConfiguratorAdmin`, `spokeConfiguratorAdmin` and `governanceExecutor` are all filled in. Local and test runs are exempt, so the tests still exercise the full wiring. `test_deployInputs` and `test_handoverTargets` assert the placeholder is still there: replace the assertions and `config/base.json` together. + +## Prerequisites + +1. **`RPC_BASE`** set in `.env`. +2. **`ETHERSCAN_API_KEY_BASE`** set in `.env` for `--verify`. Base is already wired up in `foundry.toml`, both as an RPC endpoint and an Etherscan chain. +3. **The Council executor address**, replacing the placeholder as described above. + +The Safe Singleton Factory at `0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7` is already deployed on Base, so no request to [safe-singleton-factory](https://github.com/safe-global/safe-singleton-factory) is needed. `Create2Utils` reverts with `MissingCreate2Factory` if it ever goes missing. + +## Steps + +```bash +# 1. LiquidationLogic, written to FOUNDRY_LIBRARIES in .env +make deploy-precompile chain=base account= + +# 2. AccessManager, configurators, treasury spoke, core hub, main spoke, gateways, position managers +make deploy-contracts chain=base account= script=AaveV4DeployBase + +# 3. roles, liquidation configs, position manager wiring, asset listings, then halt each asset +make configure-market chain=base account= script=AaveV4ConfigureBase + +# 4. hand the market over and prove the deployer holds nothing +make relinquish-market chain=base account= script=AaveV4RelinquishBase + +# the config engine governance payloads delegatecall into; order-independent of the above +make deploy-config-engine chain=base account= script=DeployBaseConfigEngine +``` + +Add `dry=true` to any target to simulate. Step 2 writes its report to `output/reports/deployments/base-.json`; point `report` in `config/base-config.json` at it, and set `deployer` to the address you are broadcasting from, before step 3. + +After step 4 the Council has one thing left to do: `acceptOwnership()` on each of the five managers and gateways, which step 4 lists by address on completion. Until it does, the deployer still owns them — which means `registerSpoke`, `renouncePositionManagerRole` and, since the rescue guardian is `owner()`, `rescueToken` and `rescueNative`. Close that window promptly. + +See `src/deployments/README.md` for what the orchestration does and why the library pre-deploy is a separate step. + +## Configuration is direct calls, not a payload + +Every `HubConfigurator` and `SpokeConfigurator` function is `external restricted`, gated per target function on the AccessManager. An EOA holding the role calls them directly, which is what the configuration script does. `AaveV4ConfigEngine` is not used here: it is invoked by delegatecall, and a forge script broadcasting from an EOA cannot delegatecall. The engine is the path for governance payloads once the market is handed over — including the payload that unhalts it — which is why the domain admin roles end up with the Council executor. + +`config/base.json` sets `grantRoles` to false, so the deploy wires every selector to its role but grants no role to anyone, and leaves the deployer holding the AccessManager admin role. That is the window step 3 runs in. One thing `grantRoles: false` does that the input documentation does not spell out: it skips granting the configurators the roles they call the Hub and Spokes with (`101` and `301`). Without those grants a configurator call reverts even when the caller holds the domain admin role, so `AaveV4BaseConfiguration` grants them — permanently, since they are part of the end state. + +This works because the AccessManager carries no delays on a fresh deploy: nothing in the deploy path calls `setGrantDelay` or `setTargetAdminDelay`, and every grant uses an execution delay of zero. A non-zero delay would defer the deployer's self-grants and revert the calls that follow, so `AaveV4BaseConfiguration.requireNoDelays` asserts it rather than assuming it. + +## The asset list + +`config/base-config.json` carries one entry per asset: + +```json +{ + "symbol": "WETH", + "underlying": "0x…", + "priceSource": "0x…", + "tokenize": true +} +``` + +**It is empty today.** Configuration then grants the roles, applies the liquidation configs and wires the position managers, and lists nothing — which is a valid run, and the state the market would launch in with no assets. Filling the list in needs no Solidity change. + +The scripts reject anything that is not a live contract: + +- **`underlying`** — `HubConfigurator.addAsset` reads `decimals()` off it. +- **`priceSource`** — `AaveOracle.setReserveSource` requires the feed's `decimals()` to equal 8 and reads a price from it during `addReserve`. + +That is all the on-chain checks can do. A capped adapter built against the wrong base feed reports 8 decimals like any other, so the price source has to be verified off-chain before it reaches this config. + +`tokenize` deploys a `TokenizationSpoke` for the asset and registers it on the Hub supply-only, the way every Avalanche core asset has one. Its share token follows the live naming — `Wrapped Aave Core WETH` / `waCoreWETH` — and its ProxyAdmin owner is passed explicitly, so it lands on the Council rather than on whoever ran the script. `AaveV4BaseHandover.verifyProxyAdmins` walks the Hub's registered Spokes rather than only the ones the deploy produced, so a tokenization spoke whose ProxyAdmin went elsewhere — from this script or from a later listing payload — fails the handover verification with `UnexpectedOwner`. + +Adding a second Spoke means a second `spokeLabels` entry; configuration registers every Spoke for every asset. + +### Launch parameters + +`AaveV4BaseParameters` holds the parameters every asset is listed with. They are not risk parameters in the usual sense: the market launches halted, every reserve non-collateral, non-borrowable and with zero caps, and the real listing parameters arrive in the first governance payload through the config engine. + +Each value sits at the neutral end of what its own validation accepts rather than at a literal zero, because three of them reject zero: + +| Parameter | Value | Why not zero | +| --------------------- | -------- | ------------------------------------------------------------ | +| `optimalUsageRatio` | `1_00` | `AssetInterestRateStrategy.MIN_OPTIMAL_RATIO` | +| `maxLiquidationBonus` | `100_00` | must be at least `PERCENTAGE_FACTOR`, which is a 0.00% bonus | +| `targetHealthFactor` | `1e18` | must be at least `Spoke.HEALTH_FACTOR_LIQUIDATION_THRESHOLD` | + +Everything else — caps, collateral factor, liquidation fee, liquidity fee, rate slopes, risk premium threshold, tokenization add cap — is zero. + +## What is still open + +- **The V4 Security Council executor on Base.** Blocks a real deploy; the placeholder guard enforces that. +- **The launch set and its risk parameters.** `config/base-config.json` is empty and `AaveV4BaseParameters` carries neutral values. Both can be filled in without touching the scripts. diff --git a/resources/config-engine.svg b/resources/config-engine.svg index 692644695..c8da26cc3 100644 --- a/resources/config-engine.svg +++ b/resources/config-engine.svg @@ -1,4 +1,4 @@ - + @@ -16,127 +16,139 @@ - + - + - Governance Executor + PayloadsController - + CALL - + - - AaveV4Payload - (abstract — inherits) + + Executor - + DELEGATECALL - + - - AaveV4ConfigEngine + + AaveV4Payload + (abstract — inherits) + + + + DELEGATECALL + + + + + + AaveV4ConfigEngine - - - DELEGATECALL + + DELEGATECALL - + - + - - DELEGATECALL + + DELEGATECALL - + - - HubEngine + + HubEngine - - SpokeEngine + + SpokeEngine - - AccessManagerEngine + + AccessManagerEngine - - PositionManagerEngine + + PositionManagerEngine - - CALL + + CALL - - CALL + + CALL - - CALL + + CALL - - CALL + + CALL - + - - HubConfigurator + + HubConfigurator - - SpokeConfigurator + + SpokeConfigurator - - AccessManager + + AccessManager - - PositionManager + + PositionManager - + - - CALL (solid) + + CALL (solid) - - DELEGATECALL (dashed) + + DELEGATECALL (dashed) - - All code executes in the Governance Executor's context via delegatecall chain. + + Inside the whole delegatecall chain, address(this) is the Executor and msg.sender is the PayloadsController. + + + msg.sender must never be used for ownership or permissions; external calls originate from the Executor. - + Neither the ConfigEngine nor the sub-engines hold any storage, permissions, or admin keys. diff --git a/scripts/config/AaveV4BaseConfigEngine.sol b/scripts/config/AaveV4BaseConfigEngine.sol new file mode 100644 index 000000000..2cb353ff5 --- /dev/null +++ b/scripts/config/AaveV4BaseConfigEngine.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {AaveV4ConfigEngine} from 'src/config-engine/AaveV4ConfigEngine.sol'; +import {Create2Utils} from 'src/deployments/utils/libraries/Create2Utils.sol'; + +/// @title AaveV4BaseConfigEngine +/// @author Aave Labs +/// @notice Deploys and locates the `AaveV4ConfigEngine` for the Base market. +/// @dev The engine is what governance payloads delegatecall into to maintain the market after +/// launch — including the first payload, which unhalts the market. It is stateless and holds no +/// permissions, so one instance serves every future payload and it is deployed on its own rather +/// than by the deploy orchestration. +/// +/// Deployed through the Safe Singleton Factory under a fixed salt, so its address does not depend +/// on the deployer or its nonce, and `predictedAddress` recomputes it rather than the deployment +/// report having to record it. +/// +/// It is not deployer-independent in the stronger sense, though. The engine links five engine +/// libraries, and `type(...).creationCode` is only linked bytecode once forge has resolved those +/// library addresses — so the engine's address is a function of where they land. Forge deploys them +/// by CREATE2, which is deterministic for a given library bytecode, and reuses any already on +/// chain; but a toolchain that placed them elsewhere would move the engine too. Recompute from this +/// repo, and treat a recorded deployed address as authoritative over a recomputation elsewhere. +library AaveV4BaseConfigEngine { + /// @dev Fixed salt for the Base config engine. Bump the suffix if the engine is ever redeployed, + /// since `Create2Utils` refuses to deploy twice to the same address. + bytes32 internal constant SALT = keccak256('AAVE_V4_BASE_CONFIG_ENGINE_V1'); + + /// @notice Deploys the config engine at its deterministic address. + /// @dev Reverts with `ContractAlreadyDeployed` if it is already there, so re-running is safe. + /// @return The address of the deployed config engine. + function deploy() internal returns (address) { + return Create2Utils.create2Deploy(SALT, type(AaveV4ConfigEngine).creationCode); + } + + /// @notice The address the config engine has, or will have, on any chain. + /// @return The deterministic config engine address. + function predictedAddress() internal pure returns (address) { + return Create2Utils.computeCreate2Address(SALT, type(AaveV4ConfigEngine).creationCode); + } +} diff --git a/scripts/config/AaveV4BaseConfigInputs.sol b/scripts/config/AaveV4BaseConfigInputs.sol new file mode 100644 index 000000000..b7b0f51c2 --- /dev/null +++ b/scripts/config/AaveV4BaseConfigInputs.sol @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {Vm} from 'forge-std/Vm.sol'; + +/// @title AaveV4BaseConfigInputs +/// @author Aave Labs +/// @notice Reads the inputs shared by the Base configuration and handover scripts: the addresses of +/// a deployed Base market, the handover targets, and the assets to list. +library AaveV4BaseConfigInputs { + Vm internal constant vm = Vm(address(uint160(uint256(keccak256('hevm cheat code'))))); + + /// @dev Deploy inputs, which also carry the handover targets. + string internal constant DEPLOY_CONFIG_PATH = 'config/base.json'; + /// @dev Configuration inputs. + string internal constant CONFIG_PATH = 'config/base-config.json'; + /// @dev ERC-1967 admin slot, holding the ProxyAdmin address of a transparent proxy. + bytes32 internal constant ERC1967_ADMIN_SLOT = + 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; + + /// @notice Addresses of a deployed Base market, read back from its deployment report. + /// @dev spokes Spoke proxies, in the order the spoke labels are declared in the deploy inputs. + struct Market { + address accessManager; + address hubConfigurator; + address spokeConfigurator; + address treasurySpoke; + address hub; + address irStrategy; + address[] spokes; + address nativeTokenGateway; + address signatureGateway; + address giverPositionManager; + address takerPositionManager; + address configPositionManager; + } + + /// @notice The addresses the deployer hands the market over to. + /// @dev `securityCouncil` owns the market and admins the AccessManager. `councilExecutor` holds + /// the two configurator domain admin roles, so it must be the address that executes configuration + /// payloads rather than the Safe that owns it. `governanceExecutor` is the DAO's own executor, + /// which co-holds the AccessManager admin role and the Hub configurator domain admin role. + struct Handover { + address securityCouncil; + address councilExecutor; + address governanceExecutor; + address proxyAdminOwner; + address treasurySpokeOwner; + address gatewayOwner; + address positionManagerOwner; + } + + /// @notice An asset to list, with the price feed its Spoke reserves read it through. + /// @dev tokenize Whether a tokenization spoke is deployed for the asset and registered on the Hub. + struct Asset { + string symbol; + address underlying; + address priceSource; + bool tokenize; + } + + /// @notice Thrown when the deploy inputs declare anything other than a single Hub. + error SingleHubExpected(); + /// @notice Thrown when an asset or its price source is left unset in the configuration inputs. + error AddressNotSet(string symbol, string field); + /// @notice Thrown when an asset or its price source has no code, which every configuration call + /// on it would revert on. + error NotAContract(string symbol, string field); + /// @notice Thrown when the deployer is not recorded in the configuration inputs. + error DeployerNotSet(); + + /// @notice Reads the deployed market addresses from the report named in the configuration inputs. + /// @return market The addresses of the deployed Base market. + function readMarket() internal view returns (Market memory market) { + string memory deployJson = vm.readFile(DEPLOY_CONFIG_PATH); + string memory report = vm.readFile(vm.parseJsonString(vm.readFile(CONFIG_PATH), '.report')); + + string[] memory hubLabels = vm.parseJsonStringArray(deployJson, '.hubLabels'); + require(hubLabels.length == 1, SingleHubExpected()); + string[] memory spokeLabels = vm.parseJsonStringArray(deployJson, '.spokeLabels'); + + market.accessManager = vm.parseJsonAddress(report, '$.accessManager'); + market.hubConfigurator = vm.parseJsonAddress(report, '$.hubConfigurator'); + market.spokeConfigurator = vm.parseJsonAddress(report, '$.spokeConfigurator'); + market.treasurySpoke = vm.parseJsonAddress(report, '$.treasurySpoke'); + market.hub = vm.parseJsonAddress(report, string.concat('$.hub.', hubLabels[0])); + market.irStrategy = vm.parseJsonAddress(report, string.concat('$.irStrategy.', hubLabels[0])); + + market.spokes = new address[](spokeLabels.length); + for (uint256 i; i < spokeLabels.length; ++i) { + market.spokes[i] = vm.parseJsonAddress(report, string.concat('$.spoke.', spokeLabels[i])); + } + + market.nativeTokenGateway = _optionalAddress(report, '$.nativeTokenGateway'); + market.signatureGateway = _optionalAddress(report, '$.signatureGateway'); + market.giverPositionManager = _optionalAddress(report, '$.giverPositionManager'); + market.takerPositionManager = _optionalAddress(report, '$.takerPositionManager'); + market.configPositionManager = _optionalAddress(report, '$.configPositionManager'); + } + + /// @notice Reads the handover targets from the deploy inputs. + /// @dev The role fields are unused at deploy time while `grantRoles` is false, and are applied by + /// the handover script instead. `hubAdmin` and `spokeAdmin` are deliberately not read: the Hub and + /// Spoke roles they would fill are left unheld, matching the live Ethereum and Avalanche markets. + /// @return handover The addresses to hand the market over to. + function readHandover() internal view returns (Handover memory handover) { + string memory json = vm.readFile(DEPLOY_CONFIG_PATH); + + handover.securityCouncil = vm.parseJsonAddress(json, '.accessManagerAdmin'); + handover.councilExecutor = vm.parseJsonAddress(json, '.hubConfiguratorAdmin'); + handover.governanceExecutor = vm.parseJsonAddress(json, '.governanceExecutor'); + handover.proxyAdminOwner = vm.parseJsonAddress(json, '.proxyAdminOwner'); + handover.treasurySpokeOwner = vm.parseJsonAddress(json, '.treasurySpokeOwner'); + handover.gatewayOwner = vm.parseJsonAddress(json, '.gatewayOwner'); + handover.positionManagerOwner = vm.parseJsonAddress(json, '.positionManagerOwner'); + } + + /// @notice Reads the assets to list. + /// @dev The list is empty until the launch set and its risk parameters are decided, which + /// configures the market without listing anything. Filling it in needs no change here. + /// @return assets The assets, in the order they are declared in the configuration inputs. + function readAssets() internal view returns (Asset[] memory assets) { + string memory json = vm.readFile(CONFIG_PATH); + + uint256 count; + while (vm.keyExistsJson(json, _assetPath(count))) { + ++count; + } + + assets = new Asset[](count); + for (uint256 i; i < count; ++i) { + string memory path = _assetPath(i); + assets[i] = Asset({ + symbol: vm.parseJsonString(json, string.concat(path, '.symbol')), + underlying: vm.parseJsonAddress(json, string.concat(path, '.underlying')), + priceSource: vm.parseJsonAddress(json, string.concat(path, '.priceSource')), + tokenize: vm.parseJsonBool(json, string.concat(path, '.tokenize')) + }); + } + } + + /// @notice Reads the address that ran the deployment and configuration. + /// @dev Needed to assert it holds nothing after the handover. The deployment report does not + /// record it, so it is configured explicitly. + /// @return deployer The deploying address. + function readDeployer() internal view returns (address deployer) { + deployer = vm.parseJsonAddress(vm.readFile(CONFIG_PATH), '.deployer'); + require(deployer != address(0), DeployerNotSet()); + } + + /// @notice Reverts unless every configured asset and price source is a live contract. + /// @dev `HubConfigurator.addAsset` reads `decimals()` off the underlying, and + /// `AaveOracle.setReserveSource` checks the price source decimals and reads a price from it. + /// + /// It does not validate that the price source is the *right* feed, and nothing here does: a + /// capped adapter built against the wrong base feed reports 8 decimals like any other. The price + /// source is verified off-chain, before it reaches this config. + /// @param assets The assets read from the configuration inputs. + function requireLiveAssets(Asset[] memory assets) internal view { + for (uint256 i; i < assets.length; ++i) { + Asset memory a = assets[i]; + require(a.underlying != address(0), AddressNotSet(a.symbol, 'underlying')); + require(a.priceSource != address(0), AddressNotSet(a.symbol, 'priceSource')); + require(a.underlying.code.length > 0, NotAContract(a.symbol, 'underlying')); + require(a.priceSource.code.length > 0, NotAContract(a.symbol, 'priceSource')); + } + } + + /// @notice Returns the ProxyAdmin of a transparent proxy. + /// @param proxy The proxy to read. + /// @return The ProxyAdmin address. + function proxyAdmin(address proxy) internal view returns (address) { + return address(uint160(uint256(vm.load(proxy, ERC1967_ADMIN_SLOT)))); + } + + function _assetPath(uint256 index) private pure returns (string memory) { + return string.concat('.assets[', vm.toString(index), ']'); + } + + function _optionalAddress(string memory json, string memory key) private view returns (address) { + return vm.keyExistsJson(json, key) ? vm.parseJsonAddress(json, key) : address(0); + } +} diff --git a/scripts/config/AaveV4BaseConfiguration.sol b/scripts/config/AaveV4BaseConfiguration.sol new file mode 100644 index 000000000..ba4333c46 --- /dev/null +++ b/scripts/config/AaveV4BaseConfiguration.sol @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {AaveV4BaseConfigInputs} from 'scripts/config/AaveV4BaseConfigInputs.sol'; +import {AaveV4BaseParameters} from 'scripts/config/AaveV4BaseParameters.sol'; +import {AaveV4TokenizationSpokeBatch} from 'src/deployments/batches/AaveV4TokenizationSpokeBatch.sol'; +import {Roles} from 'src/deployments/utils/libraries/Roles.sol'; +import {AaveV4HubRolesProcedure} from 'src/deployments/procedures/roles/AaveV4HubRolesProcedure.sol'; +import {AaveV4SpokeRolesProcedure} from 'src/deployments/procedures/roles/AaveV4SpokeRolesProcedure.sol'; +import {AaveV4HubConfiguratorRolesProcedure} from 'src/deployments/procedures/roles/AaveV4HubConfiguratorRolesProcedure.sol'; +import {AaveV4SpokeConfiguratorRolesProcedure} from 'src/deployments/procedures/roles/AaveV4SpokeConfiguratorRolesProcedure.sol'; +import {IAccessManager} from 'src/dependencies/openzeppelin/IAccessManager.sol'; +import {IERC20Metadata} from 'src/dependencies/openzeppelin/IERC20Metadata.sol'; +import {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol'; +import {IAssetInterestRateStrategy} from 'src/hub/interfaces/IAssetInterestRateStrategy.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {IHubConfigurator} from 'src/hub/interfaces/IHubConfigurator.sol'; +import {IPositionManagerBase} from 'src/position-manager/interfaces/IPositionManagerBase.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {ISpokeConfigurator} from 'src/spoke/interfaces/ISpokeConfigurator.sol'; + +/// @title AaveV4BaseConfiguration +/// @author Aave Labs +/// @notice Configures a Base market as the deployer: grants the roles configuration needs, wires +/// every position manager and gateway to every Spoke, lists the configured assets on the Hub and +/// its Spokes, and halts each of them on the Hub. +/// @dev Runs against a deployment made with `grantRoles` false, which leaves the deployer holding +/// the AccessManager admin role and every selector wired to a role that nobody holds yet. The +/// deployer therefore takes the two configurator domain admin roles and passes the configurators +/// the roles they call the Hub and Spokes with, before configuring. +/// +/// The asset list is empty until the launch set is decided, in which case this configures the +/// market — roles, liquidation configs and manager wiring — and lists nothing. See +/// `AaveV4BaseParameters` and docs/base-deploy.md. +library AaveV4BaseConfiguration { + /// @notice Thrown when the AccessManager carries a non-zero delay, which would defer the + /// deployer's self-granted roles and revert every configuration call that follows. + error UnexpectedDelay(); + /// @notice Thrown when no owner is given for the tokenization spoke proxy admins. + error InvalidProxyAdminOwner(); + /// @notice Thrown when a position manager is not owned by the deployer, so `registerSpoke` on it + /// would revert. The Base deploy script is what arranges that ownership. + error ManagerNotOwnedByDeployer(address manager, address owner); + + /// @notice Grants the roles configuration needs, applies the per-Spoke liquidation configs, wires + /// the position managers and gateways, lists every configured asset on the Hub and its Spokes, + /// deploys each one's tokenization spoke, and halts each asset on the Hub. + /// @dev The halt comes last per asset so it also reaches that asset's tokenization spoke, which + /// `haltAsset` only sees once it is registered on the Hub. + /// @param market The deployed Base market. + /// @param deployer The address holding the AccessManager admin role. + /// @param assets The assets to list. + /// @param proxyAdminOwner The owner of each tokenization spoke's ProxyAdmin. + /// @return assetIds The Hub asset ids of the listed assets, in the order they were configured. + function configure( + AaveV4BaseConfigInputs.Market memory market, + address deployer, + AaveV4BaseConfigInputs.Asset[] memory assets, + address proxyAdminOwner + ) internal returns (uint256[] memory assetIds) { + require(proxyAdminOwner != address(0), InvalidProxyAdminOwner()); + + requireNoDelays(market); + grantConfigurationRoles(market, deployer); + setLiquidationConfigs(market); + wirePositionManagers(market, deployer); + + assetIds = new uint256[](assets.length); + for (uint256 i; i < assets.length; ++i) { + uint256 assetId = listAssetOnHub(market, assets[i].underlying); + listAssetOnSpokes(market, assetId, assets[i].priceSource); + if (assets[i].tokenize) { + deployTokenizationSpoke(market, assets[i], assetId, proxyAdminOwner); + } + IHubConfigurator(market.hubConfigurator).haltAsset(market.hub, assetId); + + assetIds[i] = assetId; + } + } + + /// @notice Reverts if the AccessManager would defer a role grant or an admin action. + /// @dev A non-zero role grant delay or target admin delay would make the self-grants take effect + /// only after the delay, so every configuration call afterwards would revert. + /// @param market The deployed Base market. + function requireNoDelays(AaveV4BaseConfigInputs.Market memory market) internal view { + IAccessManager accessManager = IAccessManager(market.accessManager); + + require(accessManager.getTargetAdminDelay(market.accessManager) == 0, UnexpectedDelay()); + require( + accessManager.getRoleGrantDelay(Roles.HUB_CONFIGURATOR_DOMAIN_ADMIN_ROLE) == 0, + UnexpectedDelay() + ); + require( + accessManager.getRoleGrantDelay(Roles.SPOKE_CONFIGURATOR_DOMAIN_ADMIN_ROLE) == 0, + UnexpectedDelay() + ); + require(accessManager.getRoleGrantDelay(Roles.HUB_CONFIGURATOR_ROLE) == 0, UnexpectedDelay()); + require(accessManager.getRoleGrantDelay(Roles.SPOKE_CONFIGURATOR_ROLE) == 0, UnexpectedDelay()); + } + + /// @notice Grants the deployer both configurator domain admin roles, and each configurator the + /// role it calls the Hub or Spokes with. + /// @dev The two configurator grants are permanent: `grantRoles: false` skips them at deploy time, + /// and without them a configurator call reverts even when its caller holds the domain admin role. + /// @param market The deployed Base market. + /// @param deployer The address holding the AccessManager admin role. + function grantConfigurationRoles( + AaveV4BaseConfigInputs.Market memory market, + address deployer + ) internal { + AaveV4HubConfiguratorRolesProcedure.grantHubConfiguratorAllRoles({ + accessManager: market.accessManager, + admin: deployer + }); + AaveV4SpokeConfiguratorRolesProcedure.grantSpokeConfiguratorAllRoles({ + accessManager: market.accessManager, + admin: deployer + }); + + AaveV4HubRolesProcedure.grantHubRole({ + accessManager: market.accessManager, + role: Roles.HUB_CONFIGURATOR_ROLE, + admin: market.hubConfigurator + }); + AaveV4SpokeRolesProcedure.grantSpokeRole({ + accessManager: market.accessManager, + role: Roles.SPOKE_CONFIGURATOR_ROLE, + admin: market.spokeConfigurator + }); + } + + /// @notice Applies the launch liquidation config to every Spoke. + /// @dev Per Spoke rather than per reserve, so this runs once rather than per listed asset. + /// @param market The deployed Base market. + function setLiquidationConfigs(AaveV4BaseConfigInputs.Market memory market) internal { + for (uint256 i; i < market.spokes.length; ++i) { + ISpokeConfigurator(market.spokeConfigurator).updateLiquidationConfig( + market.spokes[i], + AaveV4BaseParameters.liquidationConfig() + ); + } + } + + /// @notice Wires every deployed position manager and gateway to every Spoke, both halves. + /// @dev A manager is inert unless the Spoke has it active and the manager has the Spoke + /// registered: `Spoke` checks `isPositionManagerActive`, the manager checks `onlyRegisteredSpoke`. + /// + /// Both halves run here. `updatePositionManager` needs the SpokeConfigurator domain admin role, + /// which the deployer holds during configuration; `registerSpoke` is `onlyOwner` on the manager, + /// which is why `AaveV4DeployBase` gives the deployer initial ownership of the managers and + /// gateways rather than the Council. Ownership moves to the Council during the handover, leaving + /// the Council nothing to do here beyond accepting it. + /// @param market The deployed Base market. + /// @param deployer The address that owns the managers during configuration. + function wirePositionManagers( + AaveV4BaseConfigInputs.Market memory market, + address deployer + ) internal { + address[5] memory managers = [ + market.giverPositionManager, + market.takerPositionManager, + market.configPositionManager, + market.nativeTokenGateway, + market.signatureGateway + ]; + + for (uint256 i; i < managers.length; ++i) { + if (managers[i] == address(0)) continue; + require( + Ownable(managers[i]).owner() == deployer, + ManagerNotOwnedByDeployer(managers[i], Ownable(managers[i]).owner()) + ); + + for (uint256 j; j < market.spokes.length; ++j) { + ISpokeConfigurator(market.spokeConfigurator).updatePositionManager({ + spoke: market.spokes[j], + positionManager: managers[i], + active: true + }); + IPositionManagerBase(managers[i]).registerSpoke(market.spokes[j], true); + } + } + } + + /// @notice Lists an asset on the Hub with the launch rate curve and liquidity fee. + /// @param market The deployed Base market. + /// @param underlying The underlying asset to list. + /// @return The Hub asset id of the listed asset. + function listAssetOnHub( + AaveV4BaseConfigInputs.Market memory market, + address underlying + ) internal returns (uint256) { + IAssetInterestRateStrategy.InterestRateData memory irData = IAssetInterestRateStrategy + .InterestRateData({ + optimalUsageRatio: AaveV4BaseParameters.OPTIMAL_USAGE_RATIO, + baseDrawnRate: AaveV4BaseParameters.BASE_DRAWN_RATE, + rateGrowthBeforeOptimal: AaveV4BaseParameters.RATE_GROWTH_BEFORE_OPTIMAL, + rateGrowthAfterOptimal: AaveV4BaseParameters.RATE_GROWTH_AFTER_OPTIMAL + }); + + return + IHubConfigurator(market.hubConfigurator).addAsset({ + hub: market.hub, + underlying: underlying, + feeReceiver: market.treasurySpoke, + liquidityFee: AaveV4BaseParameters.LIQUIDITY_FEE, + irStrategy: market.irStrategy, + irData: abi.encode(irData) + }); + } + + /// @notice Registers every Spoke for the asset and lists the reserve on each of them. + /// @param market The deployed Base market. + /// @param assetId The Hub asset id. + /// @param priceSource The price feed for the asset. + function listAssetOnSpokes( + AaveV4BaseConfigInputs.Market memory market, + uint256 assetId, + address priceSource + ) internal { + uint256[] memory assetIds = new uint256[](1); + assetIds[0] = assetId; + + IHub.SpokeConfig[] memory configs = new IHub.SpokeConfig[](1); + configs[0] = IHub.SpokeConfig({ + addCap: AaveV4BaseParameters.ADD_CAP, + drawCap: AaveV4BaseParameters.DRAW_CAP, + riskPremiumThreshold: AaveV4BaseParameters.RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }); + + ISpoke.ReserveConfig memory config = ISpoke.ReserveConfig({ + collateralRisk: AaveV4BaseParameters.COLLATERAL_RISK, + paused: false, + frozen: false, + borrowable: AaveV4BaseParameters.BORROWABLE, + receiveSharesEnabled: AaveV4BaseParameters.RECEIVE_SHARES_ENABLED + }); + ISpoke.DynamicReserveConfig memory dynamicConfig = ISpoke.DynamicReserveConfig({ + collateralFactor: AaveV4BaseParameters.COLLATERAL_FACTOR, + maxLiquidationBonus: AaveV4BaseParameters.MAX_LIQUIDATION_BONUS, + liquidationFee: AaveV4BaseParameters.LIQUIDATION_FEE + }); + + for (uint256 i; i < market.spokes.length; ++i) { + IHubConfigurator(market.hubConfigurator).addSpokeToAssets({ + hub: market.hub, + spoke: market.spokes[i], + assetIds: assetIds, + configs: configs + }); + ISpokeConfigurator(market.spokeConfigurator).addReserve({ + spoke: market.spokes[i], + hub: market.hub, + assetId: assetId, + priceSource: priceSource, + config: config, + dynamicConfig: dynamicConfig + }); + } + } + + /// @notice Deploys the asset's tokenization spoke and registers it on the Hub as supply-only. + /// @dev Must run after `listAssetOnHub`: `TokenizationSpoke`'s constructor resolves the asset id + /// off the Hub and reverts if the asset is not listed. + /// + /// The ProxyAdmin owner is passed explicitly, so it lands on the market's owner rather than on + /// whoever ran the configuration. The config engine's `TokenizationSpokeDeployer` takes it + /// explicitly too as of #1321, so either route is safe now; this path uses + /// `AaveV4TokenizationSpokeBatch` because configuration here runs as direct calls from an EOA + /// rather than as a delegatecalled payload. + /// @param market The deployed Base market. + /// @param asset The asset being tokenized. + /// @param assetId The Hub asset id of that asset. + /// @param proxyAdminOwner The owner of the tokenization spoke's ProxyAdmin. + /// @return The tokenization spoke proxy. + function deployTokenizationSpoke( + AaveV4BaseConfigInputs.Market memory market, + AaveV4BaseConfigInputs.Asset memory asset, + uint256 assetId, + address proxyAdminOwner + ) internal returns (address) { + string memory assetSymbol = IERC20Metadata(asset.underlying).symbol(); + + address proxy = new AaveV4TokenizationSpokeBatch({ + hub_: market.hub, + underlying_: asset.underlying, + proxyAdminOwner_: proxyAdminOwner, + shareName_: AaveV4BaseParameters.tokenizationShareName(assetSymbol), + shareSymbol_: AaveV4BaseParameters.tokenizationShareSymbol(assetSymbol), + salt_: keccak256(abi.encode(market.hub, asset.underlying, 'tokenizationSpoke')) + }).getReport().tokenizationSpokeProxy; + + IHubConfigurator(market.hubConfigurator).addSpoke({ + hub: market.hub, + spoke: proxy, + assetId: assetId, + config: IHub.SpokeConfig({ + addCap: AaveV4BaseParameters.TOKENIZATION_ADD_CAP, + drawCap: 0, + riskPremiumThreshold: AaveV4BaseParameters.RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + }); + + return proxy; + } +} diff --git a/scripts/config/AaveV4BaseHandover.sol b/scripts/config/AaveV4BaseHandover.sol new file mode 100644 index 000000000..28af18773 --- /dev/null +++ b/scripts/config/AaveV4BaseHandover.sol @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {AaveV4BaseConfigInputs} from 'scripts/config/AaveV4BaseConfigInputs.sol'; +import {Roles} from 'src/deployments/utils/libraries/Roles.sol'; +import {AaveV4AccessManagerRolesProcedure} from 'src/deployments/procedures/roles/AaveV4AccessManagerRolesProcedure.sol'; +import {AaveV4HubConfiguratorRolesProcedure} from 'src/deployments/procedures/roles/AaveV4HubConfiguratorRolesProcedure.sol'; +import {AaveV4SpokeConfiguratorRolesProcedure} from 'src/deployments/procedures/roles/AaveV4SpokeConfiguratorRolesProcedure.sol'; +import {IAccessManagerEnumerable} from 'src/access/interfaces/IAccessManagerEnumerable.sol'; +import {IAccessManager} from 'src/dependencies/openzeppelin/IAccessManager.sol'; +import {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol'; +import {Ownable2Step} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; + +/// @title AaveV4BaseHandover +/// @author Aave Labs +/// @notice Hands a Base market over from the deployer to the V4 Security Council and the DAO's +/// governance executor, and proves the deployer holds nothing afterwards. +/// @dev Runs after `AaveV4BaseConfiguration`. Roles reach their end-state holders before the +/// deployer drops its own, because revoking the AccessManager admin role first would strand the +/// rest. +/// +/// The end state reproduces the live Ethereum V4 market: +/// +/// | role | holder | +/// | --------------------------------------- | -------------------------------------------------- | +/// | 0 ACCESS_MANAGER_ADMIN | Security Council + governance executor | +/// | 101 HUB_CONFIGURATOR_ROLE | the HubConfigurator | +/// | 200 HUB_CONFIGURATOR_DOMAIN_ADMIN | Council + Council executor + governance executor | +/// | 301 SPOKE_CONFIGURATOR_ROLE | the SpokeConfigurator | +/// | 400 SPOKE_CONFIGURATOR_DOMAIN_ADMIN | Council + Council executor + governance executor | +/// | 100, 102, 103, 300, 302 | nobody | +/// +/// Roles 100, 102, 103, 300 and 302 reach the Hub and Spokes directly rather than through a +/// configurator, and are left unheld on both live markets: nothing at launch calls `mintFeeShares`, +/// `eliminateDeficit` or the user position updaters, and role 0 can grant them when something does. +/// +/// The two configurator domain admin roles carry the same three holders, which is what Ethereum runs +/// with. Avalanche differs — it grants neither role to the Council and keeps the governance executor +/// off role 400 — but that asymmetry has no counterpart in how the market is operated, and the +/// Council holding role 0 could grant itself both at any time regardless. +library AaveV4BaseHandover { + /// @notice Thrown when the deployer still holds a role after the handover. + error RoleNotRelinquished(uint64 role); + /// @notice Thrown when a role did not reach its end-state holder. + error RoleNotGranted(uint64 role, address account); + /// @notice Thrown when a role that must be left unheld has a member. + error RoleNotEmpty(uint64 role); + /// @notice Thrown when a contract is not owned by its end-state holder. + error UnexpectedOwner(address target, address owner); + /// @notice Thrown when a Spoke registered on the Hub is not a transparent proxy, so it has no + /// ProxyAdmin whose owner can be checked. + error NotAProxy(address target); + /// @notice Thrown when an asset is still live on a Spoke registered for it. + error AssetNotHalted(uint256 assetId, address spoke); + + /// @notice Grants every role to its end-state holder, starts the manager ownership transfers, + /// then drops the deployer's roles. + /// @param market The deployed Base market. + /// @param targets The addresses to hand the market over to. + /// @param deployer The address currently holding the AccessManager admin role. + function relinquish( + AaveV4BaseConfigInputs.Market memory market, + AaveV4BaseConfigInputs.Handover memory targets, + address deployer + ) internal { + grantHandoverRoles(market, targets); + transferManagerOwnership(market, targets); + dropDeployerRoles(market, targets, deployer); + } + + /// @notice Reverts unless the deployer holds no role and every role and ownership sits with its + /// end-state holder. + /// @dev Enumerates every role in `Roles`, every proxy admin and every listed asset rather than + /// sampling, since a role or ownership left behind is not recoverable once the deployer is out. + /// @param market The deployed Base market. + /// @param targets The addresses the market was handed over to. + /// @param deployer The address that ran the deployment and configuration. + function verify( + AaveV4BaseConfigInputs.Market memory market, + AaveV4BaseConfigInputs.Handover memory targets, + address deployer + ) internal view { + verifyDeployerHoldsNoRole(market, deployer); + verifyRoleHolders(market, targets); + verifyProxyAdmins(market, targets.proxyAdminOwner); + verifyOwnerships(market, targets); + verifyAssetsHalted(market); + } + + /// @notice Grants the AccessManager admin role and the two configurator domain admin roles to + /// their end-state holders. + /// @dev The Council's own role 0 grant is not here: `dropDeployerRoles` makes it, last, as it + /// hands the AccessManager over. + /// @param market The deployed Base market. + /// @param targets The addresses to hand the market over to. + function grantHandoverRoles( + AaveV4BaseConfigInputs.Market memory market, + AaveV4BaseConfigInputs.Handover memory targets + ) internal { + AaveV4AccessManagerRolesProcedure.grantAccessManagerAdminRole({ + accessManager: market.accessManager, + adminToAdd: targets.governanceExecutor + }); + + address[3] memory admins = configuratorAdmins(targets); + for (uint256 i; i < admins.length; ++i) { + AaveV4HubConfiguratorRolesProcedure.grantHubConfiguratorAllRoles({ + accessManager: market.accessManager, + admin: admins[i] + }); + AaveV4SpokeConfiguratorRolesProcedure.grantSpokeConfiguratorAllRoles({ + accessManager: market.accessManager, + admin: admins[i] + }); + } + } + + /// @notice The three addresses that hold both configurator domain admin roles. + /// @param targets The addresses the market is handed over to. + /// @return The Council, its executor and the governance executor. + function configuratorAdmins( + AaveV4BaseConfigInputs.Handover memory targets + ) internal pure returns (address[3] memory) { + return [targets.securityCouncil, targets.councilExecutor, targets.governanceExecutor]; + } + + /// @notice Starts the ownership transfer of every position manager and gateway to the Council. + /// @dev These are the only contracts the deployer owns, because configuration needs `onlyOwner` + /// access to `registerSpoke` on them. `PositionManagerBase` is `Ownable2Step`, so this records a + /// pending owner and the Council completes it with one `acceptOwnership` per contract — the whole + /// of what the Council has to do to take the market over. + /// + /// Until it accepts, the deployer still owns them, which means `registerSpoke`, + /// `renouncePositionManagerRole` and — since the rescue guardian is `owner()` — `rescueToken` and + /// `rescueNative`. That window should be closed promptly. + /// @param market The deployed Base market. + /// @param targets The addresses to hand the market over to. + function transferManagerOwnership( + AaveV4BaseConfigInputs.Market memory market, + AaveV4BaseConfigInputs.Handover memory targets + ) internal { + if (market.nativeTokenGateway != address(0)) { + Ownable2Step(market.nativeTokenGateway).transferOwnership(targets.gatewayOwner); + } + if (market.signatureGateway != address(0)) { + Ownable2Step(market.signatureGateway).transferOwnership(targets.gatewayOwner); + } + if (market.giverPositionManager != address(0)) { + Ownable2Step(market.giverPositionManager).transferOwnership(targets.positionManagerOwner); + Ownable2Step(market.takerPositionManager).transferOwnership(targets.positionManagerOwner); + Ownable2Step(market.configPositionManager).transferOwnership(targets.positionManagerOwner); + } + } + + /// @notice Revokes the deployer's configurator domain admin roles and moves the AccessManager + /// admin role to the Security Council. + /// @dev The AccessManager admin role goes last: without it the deployer cannot revoke anything. + /// @param market The deployed Base market. + /// @param targets The addresses to hand the market over to. + /// @param deployer The address currently holding the AccessManager admin role. + function dropDeployerRoles( + AaveV4BaseConfigInputs.Market memory market, + AaveV4BaseConfigInputs.Handover memory targets, + address deployer + ) internal { + IAccessManager accessManager = IAccessManager(market.accessManager); + + accessManager.revokeRole(Roles.HUB_CONFIGURATOR_DOMAIN_ADMIN_ROLE, deployer); + accessManager.revokeRole(Roles.SPOKE_CONFIGURATOR_DOMAIN_ADMIN_ROLE, deployer); + + AaveV4AccessManagerRolesProcedure.replaceDefaultAdminRole({ + accessManager: market.accessManager, + adminToAdd: targets.securityCouncil, + adminToRemove: deployer + }); + } + + /// @notice Reverts if the deployer still holds any role defined in `Roles`. + /// @param market The deployed Base market. + /// @param deployer The address that ran the deployment and configuration. + function verifyDeployerHoldsNoRole( + AaveV4BaseConfigInputs.Market memory market, + address deployer + ) internal view { + uint64[10] memory roles = [ + Roles.ACCESS_MANAGER_ADMIN_ROLE, + Roles.HUB_DOMAIN_ADMIN_ROLE, + Roles.HUB_CONFIGURATOR_ROLE, + Roles.HUB_FEE_MINTER_ROLE, + Roles.HUB_DEFICIT_ELIMINATOR_ROLE, + Roles.HUB_CONFIGURATOR_DOMAIN_ADMIN_ROLE, + Roles.SPOKE_DOMAIN_ADMIN_ROLE, + Roles.SPOKE_CONFIGURATOR_ROLE, + Roles.SPOKE_USER_POSITION_UPDATER_ROLE, + Roles.SPOKE_CONFIGURATOR_DOMAIN_ADMIN_ROLE + ]; + + for (uint256 i; i < roles.length; ++i) { + (bool isMember, ) = IAccessManager(market.accessManager).hasRole(roles[i], deployer); + require(!isMember, RoleNotRelinquished(roles[i])); + } + } + + /// @notice Reverts unless every role sits with its end-state holder, and unless the roles that + /// are meant to be unheld are empty. + /// @param market The deployed Base market. + /// @param targets The addresses the market was handed over to. + function verifyRoleHolders( + AaveV4BaseConfigInputs.Market memory market, + AaveV4BaseConfigInputs.Handover memory targets + ) internal view { + _requireRole(market, Roles.ACCESS_MANAGER_ADMIN_ROLE, targets.securityCouncil); + _requireRole(market, Roles.ACCESS_MANAGER_ADMIN_ROLE, targets.governanceExecutor); + + address[3] memory admins = configuratorAdmins(targets); + for (uint256 i; i < admins.length; ++i) { + _requireRole(market, Roles.HUB_CONFIGURATOR_DOMAIN_ADMIN_ROLE, admins[i]); + _requireRole(market, Roles.SPOKE_CONFIGURATOR_DOMAIN_ADMIN_ROLE, admins[i]); + } + + // the configurators keep calling the Hub and Spokes on the Council's behalf + _requireRole(market, Roles.HUB_CONFIGURATOR_ROLE, market.hubConfigurator); + _requireRole(market, Roles.SPOKE_CONFIGURATOR_ROLE, market.spokeConfigurator); + + _requireRoleEmpty(market, Roles.HUB_DOMAIN_ADMIN_ROLE); + _requireRoleEmpty(market, Roles.HUB_FEE_MINTER_ROLE); + _requireRoleEmpty(market, Roles.HUB_DEFICIT_ELIMINATOR_ROLE); + _requireRoleEmpty(market, Roles.SPOKE_DOMAIN_ADMIN_ROLE); + _requireRoleEmpty(market, Roles.SPOKE_USER_POSITION_UPDATER_ROLE); + } + + /// @notice Reverts unless every ProxyAdmin in the market is owned by its end-state holder. + /// @dev The Hub, the configured Spokes and the TreasurySpoke take their ProxyAdmin owner from the + /// deploy inputs. Every other Spoke the Hub has registered is walked too, because a + /// TokenizationSpoke is deployed during configuration or by a later listing payload rather than by + /// the deploy, and takes its ProxyAdmin owner from whichever of the two deployed it — which is the + /// one place this ownership can diverge from the rest of the market. + /// @param market The deployed Base market. + /// @param proxyAdminOwner The address every ProxyAdmin must be owned by. + function verifyProxyAdmins( + AaveV4BaseConfigInputs.Market memory market, + address proxyAdminOwner + ) internal view { + _requireProxyAdminOwner(market.hub, proxyAdminOwner); + for (uint256 i; i < market.spokes.length; ++i) { + _requireProxyAdminOwner(market.spokes[i], proxyAdminOwner); + } + _requireProxyAdminOwner(market.treasurySpoke, proxyAdminOwner); + + IHub hub = IHub(market.hub); + uint256 assetCount = hub.getAssetCount(); + + for (uint256 assetId; assetId < assetCount; ++assetId) { + uint256 spokeCount = hub.getSpokeCount(assetId); + for (uint256 i; i < spokeCount; ++i) { + _requireProxyAdminOwner(hub.getSpokeAddress(assetId, i), proxyAdminOwner); + } + } + } + + /// @notice Reverts unless every ownership sits with, or is pending acceptance by, its end-state + /// holder. + /// @dev The TreasurySpoke is owned by the Council from the deploy transaction onwards and is + /// checked here to prove the deployer never was its owner. The managers and gateways are + /// `Ownable2Step` and the deployer owns them until the Council accepts, so either state passes. + /// @param market The deployed Base market. + /// @param targets The addresses the market was handed over to. + function verifyOwnerships( + AaveV4BaseConfigInputs.Market memory market, + AaveV4BaseConfigInputs.Handover memory targets + ) internal view { + _requireOwner(market.treasurySpoke, targets.treasurySpokeOwner); + + if (market.nativeTokenGateway != address(0)) { + _requireOwnerOrPending(market.nativeTokenGateway, targets.gatewayOwner); + } + if (market.signatureGateway != address(0)) { + _requireOwnerOrPending(market.signatureGateway, targets.gatewayOwner); + } + if (market.giverPositionManager != address(0)) { + _requireOwnerOrPending(market.giverPositionManager, targets.positionManagerOwner); + _requireOwnerOrPending(market.takerPositionManager, targets.positionManagerOwner); + _requireOwnerOrPending(market.configPositionManager, targets.positionManagerOwner); + } + } + + /// @notice Reverts unless every asset on the Hub is halted on every Spoke registered for it. + /// @param market The deployed Base market. + function verifyAssetsHalted(AaveV4BaseConfigInputs.Market memory market) internal view { + IHub hub = IHub(market.hub); + uint256 assetCount = hub.getAssetCount(); + + for (uint256 assetId; assetId < assetCount; ++assetId) { + uint256 spokeCount = hub.getSpokeCount(assetId); + for (uint256 i; i < spokeCount; ++i) { + address spoke = hub.getSpokeAddress(assetId, i); + require(hub.getSpokeConfig(assetId, spoke).halted, AssetNotHalted(assetId, spoke)); + } + } + } + + function _requireRole( + AaveV4BaseConfigInputs.Market memory market, + uint64 role, + address account + ) private view { + (bool isMember, ) = IAccessManager(market.accessManager).hasRole(role, account); + require(isMember, RoleNotGranted(role, account)); + } + + /// @dev `AccessManagerEnumerable` tracks role members, so a role meant to be unheld can be + /// asserted empty rather than only asserted not to hold the addresses this script knows about. + function _requireRoleEmpty( + AaveV4BaseConfigInputs.Market memory market, + uint64 role + ) private view { + require( + IAccessManagerEnumerable(market.accessManager).getRoleMemberCount(role) == 0, + RoleNotEmpty(role) + ); + } + + function _requireProxyAdminOwner(address proxy, address expectedOwner) private view { + address admin = AaveV4BaseConfigInputs.proxyAdmin(proxy); + require(admin != address(0), NotAProxy(proxy)); + _requireOwner(admin, expectedOwner); + } + + /// @dev Passes if the target is already owned by `expectedOwner`, or if it is the pending owner of + /// an `Ownable2Step` transfer that has been started but not accepted. + function _requireOwnerOrPending(address target, address expectedOwner) private view { + address owner = Ownable(target).owner(); + if (owner == expectedOwner) return; + + require(Ownable2Step(target).pendingOwner() == expectedOwner, UnexpectedOwner(target, owner)); + } + + function _requireOwner(address target, address expectedOwner) private view { + address owner = Ownable(target).owner(); + require(owner == expectedOwner, UnexpectedOwner(target, owner)); + } +} diff --git a/scripts/config/AaveV4BaseParameters.sol b/scripts/config/AaveV4BaseParameters.sol new file mode 100644 index 000000000..f1eb1cf15 --- /dev/null +++ b/scripts/config/AaveV4BaseParameters.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +/// @title AaveV4BaseParameters +/// @author Aave Labs +/// @notice The parameters every asset is listed with on the Base market at launch. +/// @dev The market launches halted, so these are not risk parameters in the usual sense: every +/// reserve is listed non-collateral, non-borrowable and with zero caps, and the Hub halt on top of +/// that closes it entirely. The real listing parameters arrive in the first governance payload, +/// through the config engine, once the market is live. +/// +/// Each value is at the neutral end of what its own validation accepts rather than a literal zero, +/// because three of them reject zero. Those three are named below; everything else is zero. +library AaveV4BaseParameters { + /// @dev No fee taken on liquidity. + uint256 internal constant LIQUIDITY_FEE = 0; + + /// @dev `AssetInterestRateStrategy.MIN_OPTIMAL_RATIO`, the lowest value the strategy accepts. + uint16 internal constant OPTIMAL_USAGE_RATIO = 1_00; + uint32 internal constant BASE_DRAWN_RATE = 0; + uint32 internal constant RATE_GROWTH_BEFORE_OPTIMAL = 0; + uint32 internal constant RATE_GROWTH_AFTER_OPTIMAL = 0; + + /// @dev The Spoke can neither add nor draw the asset. + uint40 internal constant ADD_CAP = 0; + uint40 internal constant DRAW_CAP = 0; + uint24 internal constant RISK_PREMIUM_THRESHOLD = 0; + + /// @dev The asset is not usable as collateral and is not borrowable. + uint24 internal constant COLLATERAL_RISK = 0; + uint16 internal constant COLLATERAL_FACTOR = 0; + uint16 internal constant LIQUIDATION_FEE = 0; + bool internal constant BORROWABLE = false; + bool internal constant RECEIVE_SHARES_ENABLED = false; + /// @dev `PercentageMath.PERCENTAGE_FACTOR`, a 0.00% bonus and the lowest value + /// `Spoke._validateDynamicReserveConfig` accepts. + uint32 internal constant MAX_LIQUIDATION_BONUS = 100_00; + + /// @dev `Spoke.HEALTH_FACTOR_LIQUIDATION_THRESHOLD`, the lowest target health factor accepted. + uint128 internal constant TARGET_HEALTH_FACTOR = 1e18; + /// @dev Must be strictly below the liquidation threshold. + uint64 internal constant HEALTH_FACTOR_FOR_MAX_BONUS = 1e18 - 1; + uint16 internal constant LIQUIDATION_BONUS_FACTOR = 0; + + /// @dev Cap a tokenization spoke is registered on the Hub with. Zero like every other cap, so the + /// share token exists and is wired but nothing can be added through it until governance raises it. + uint40 internal constant TOKENIZATION_ADD_CAP = 0; + + /// @notice The liquidation configuration applied to every Spoke. + /// @dev Per Spoke rather than per reserve, so it is set once before any asset is listed. + /// @return The launch liquidation configuration. + function liquidationConfig() internal pure returns (ISpoke.LiquidationConfig memory) { + return + ISpoke.LiquidationConfig({ + targetHealthFactor: TARGET_HEALTH_FACTOR, + healthFactorForMaxBonus: HEALTH_FACTOR_FOR_MAX_BONUS, + liquidationBonusFactor: LIQUIDATION_BONUS_FACTOR + }); + } + + /// @dev The Hub the tokenization spokes hang off, as it appears in their share token names. Must + /// match the single entry of `hubLabels` in config/base.json. + string internal constant HUB_NAME = 'Core'; + + /// @notice The share token name of an asset's tokenization spoke. + /// @dev Follows the underlying's own symbol, as on Ethereum and Avalanche: the WAVAX share token + /// of the Avalanche core hub is `Wrapped Aave Core WAVAX`. + /// @param assetSymbol The underlying's symbol. + /// @return The share token name. + function tokenizationShareName(string memory assetSymbol) internal pure returns (string memory) { + return string.concat('Wrapped Aave ', HUB_NAME, ' ', assetSymbol); + } + + /// @notice The share token symbol of an asset's tokenization spoke. + /// @param assetSymbol The underlying's symbol. + /// @return The share token symbol. + function tokenizationShareSymbol( + string memory assetSymbol + ) internal pure returns (string memory) { + return string.concat('wa', HUB_NAME, assetSymbol); + } +} diff --git a/scripts/config/AaveV4ConfigureBase.s.sol b/scripts/config/AaveV4ConfigureBase.s.sol new file mode 100644 index 000000000..ee7683679 --- /dev/null +++ b/scripts/config/AaveV4ConfigureBase.s.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {AaveV4BaseConfigInputs} from 'scripts/config/AaveV4BaseConfigInputs.sol'; +import {AaveV4BaseConfiguration} from 'scripts/config/AaveV4BaseConfiguration.sol'; + +import {Script} from 'forge-std/Script.sol'; +import {console2 as console} from 'forge-std/console2.sol'; + +/// @title AaveV4ConfigureBase +/// @author Aave Labs +/// @notice Configures the Base market from config/base-config.json, then halts every asset it +/// listed on the Hub. +/// @dev Run after the deploy script and before `AaveV4RelinquishBase`. The asset list is empty until +/// the launch set is decided, in which case this grants the roles, applies the liquidation configs +/// and wires the position managers without listing anything. See `AaveV4BaseParameters` and +/// docs/base-deploy.md. +contract AaveV4ConfigureBase is Script { + /// @notice Reads the inputs and configures the market as the broadcasting deployer. + function run() external { + AaveV4BaseConfigInputs.Market memory market = AaveV4BaseConfigInputs.readMarket(); + AaveV4BaseConfigInputs.Handover memory targets = AaveV4BaseConfigInputs.readHandover(); + AaveV4BaseConfigInputs.Asset[] memory assets = AaveV4BaseConfigInputs.readAssets(); + AaveV4BaseConfigInputs.requireLiveAssets(assets); + + if (assets.length == 0) { + console.log('no assets configured: listing nothing'); + } + for (uint256 i; i < assets.length; ++i) { + console.log('listing', assets[i].symbol, assets[i].underlying); + } + + vm.startBroadcast(); + (, address deployer, ) = vm.readCallers(); + AaveV4BaseConfiguration.configure(market, deployer, assets, targets.proxyAdminOwner); + vm.stopBroadcast(); + } +} diff --git a/scripts/config/AaveV4RelinquishBase.s.sol b/scripts/config/AaveV4RelinquishBase.s.sol new file mode 100644 index 000000000..cb905c30d --- /dev/null +++ b/scripts/config/AaveV4RelinquishBase.s.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {AaveV4BaseConfigInputs} from 'scripts/config/AaveV4BaseConfigInputs.sol'; +import {AaveV4BaseHandover} from 'scripts/config/AaveV4BaseHandover.sol'; + +import {Script} from 'forge-std/Script.sol'; +import {console2 as console} from 'forge-std/console2.sol'; + +/// @title AaveV4RelinquishBase +/// @author Aave Labs +/// @notice Hands the Base market over to the V4 Security Council and the DAO's governance executor, +/// and verifies the deployer holds nothing afterwards. +/// @dev Run last, after `AaveV4ConfigureBase`. The verification reverts the whole broadcast if any +/// role or ownership is left behind, so a successful run is the proof of a complete handover. +/// +/// One step is left for the Council: the position managers and gateways are `Ownable2Step`, so this +/// records it as their pending owner and the Council completes each with `acceptOwnership`. +contract AaveV4RelinquishBase is Script { + /// @notice Reads the inputs, hands the market over as the broadcasting deployer, then verifies. + function run() external { + AaveV4BaseConfigInputs.Market memory market = AaveV4BaseConfigInputs.readMarket(); + AaveV4BaseConfigInputs.Handover memory targets = AaveV4BaseConfigInputs.readHandover(); + + vm.startBroadcast(); + (, address deployer, ) = vm.readCallers(); + AaveV4BaseHandover.relinquish(market, targets, deployer); + AaveV4BaseHandover.verify(market, targets, deployer); + vm.stopBroadcast(); + + console.log('handed over to security council', targets.securityCouncil); + console.log( + 'council executor holds the configurator domain admin roles', + targets.councilExecutor + ); + console.log('awaiting acceptOwnership from the council on:'); + _logPending(market.nativeTokenGateway, 'nativeTokenGateway'); + _logPending(market.signatureGateway, 'signatureGateway'); + _logPending(market.giverPositionManager, 'giverPositionManager'); + _logPending(market.takerPositionManager, 'takerPositionManager'); + _logPending(market.configPositionManager, 'configPositionManager'); + } + + function _logPending(address target, string memory name) private pure { + if (target != address(0)) console.log(' ', name, target); + } +} diff --git a/scripts/config/DeployBaseConfigEngine.s.sol b/scripts/config/DeployBaseConfigEngine.s.sol new file mode 100644 index 000000000..7bf773668 --- /dev/null +++ b/scripts/config/DeployBaseConfigEngine.s.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {AaveV4BaseConfigEngine} from 'scripts/config/AaveV4BaseConfigEngine.sol'; + +import {Script} from 'forge-std/Script.sol'; +import {console2 as console} from 'forge-std/console2.sol'; + +/// @title DeployBaseConfigEngine +/// @author Aave Labs +/// @notice Deploys the `AaveV4ConfigEngine` the Council's payloads delegatecall into. +/// @dev Independent of the market deploy: the engine is stateless, holds no permissions and sits at +/// a deterministic address, so it can be deployed before or after the market and nothing needs to +/// record where it went. Re-running is safe — `Create2Utils` reverts rather than deploying twice. +/// +/// Five engine libraries are deployed and linked by forge ahead of this script's body, so they +/// appear as additional deployments in the broadcast. +contract DeployBaseConfigEngine is Script { + /// @notice Deploys the config engine, or reports it is already deployed. + /// @return engine The config engine address. + function run() external returns (address engine) { + engine = AaveV4BaseConfigEngine.predictedAddress(); + + if (engine.code.length > 0) { + console.log('AaveV4ConfigEngine already deployed at', engine); + return engine; + } + + vm.startBroadcast(); + engine = AaveV4BaseConfigEngine.deploy(); + vm.stopBroadcast(); + + console.log('AaveV4ConfigEngine deployed at', engine); + } +} diff --git a/scripts/deploy/AaveV4DeployBase.s.sol b/scripts/deploy/AaveV4DeployBase.s.sol new file mode 100644 index 000000000..c16b19829 --- /dev/null +++ b/scripts/deploy/AaveV4DeployBase.s.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {AaveV4DeployBatchBaseScript} from 'scripts/deploy/AaveV4DeployBatchBase.s.sol'; +import {InputUtils} from 'src/deployments/utils/libraries/InputUtils.sol'; + +/// @title AaveV4DeployBase +/// @author Aave Labs +/// @notice Base deploy script (chain id 8453). Deploy inputs are read from config/base.json. +contract AaveV4DeployBase is AaveV4DeployBatchBaseScript { + /// @dev Path to the Base deploy inputs, relative to the project root. + string internal constant DEPLOY_CONFIG_PATH = 'config/base.json'; + + /// @dev Stands in for the V4 Security Council executor, which is not deployed on Base yet. Both + /// configurator domain admin fields of config/base.json carry it. + address internal constant PLACEHOLDER_ADDRESS = 0x1111111111111111111111111111111111111111; + + /// @notice Thrown when a deploy on Base itself would read the placeholder address. + error PlaceholderAddress(string field); + + /// @dev Constructor. + constructor() AaveV4DeployBatchBaseScript('base') {} + + /// @dev Base mainnet. + function _expectedChainId() internal pure virtual override returns (uint256) { + return 8453; + } + + /// @dev Gives the deployer initial ownership of the position managers and gateways, because + /// `PositionManagerBase.registerSpoke` is `onlyOwner` and `AaveV4BaseConfiguration` has to call it + /// to wire them to the Spokes. The configured values are the end-state targets, applied by + /// `AaveV4RelinquishBase` as an `Ownable2Step` transfer the Council then accepts. + /// + /// Every other ownership is left as configured: the Council owns the ProxyAdmins and the + /// TreasurySpoke from the deploy transaction onwards, since configuration never touches them and + /// the TreasurySpoke would otherwise need an `Ownable2Step` acceptance of its own. + function _loadWarningsAndSanitizeInputs( + InputUtils.FullDeployInputs memory inputs, + address deployer + ) internal virtual override returns (InputUtils.FullDeployInputs memory) { + InputUtils.FullDeployInputs memory sanitizedInputs = super._loadWarningsAndSanitizeInputs( + inputs, + deployer + ); + + sanitizedInputs.gatewayOwner = deployer; + sanitizedInputs.positionManagerOwner = deployer; + + return sanitizedInputs; + } + + /// @dev Reads the FullDeployInputs from config/base.json. + /// + /// `hubAdmin` and `spokeAdmin` are zero on purpose: the Hub and Spoke roles they would fill are + /// left unheld, as on the live Ethereum and Avalanche markets. `grantRoles` is false, so the + /// deploy reads neither. + function _getDeployInputs() + internal + view + virtual + override + returns (InputUtils.FullDeployInputs memory inputs) + { + string memory json = vm.readFile(DEPLOY_CONFIG_PATH); + + uint256[] memory rawLimits = vm.parseJsonUintArray(json, '.spokeMaxReservesLimits'); + uint16[] memory spokeMaxReservesLimits = new uint16[](rawLimits.length); + for (uint256 i; i < rawLimits.length; ++i) { + spokeMaxReservesLimits[i] = uint16(rawLimits[i]); + } + + inputs = InputUtils.FullDeployInputs({ + accessManagerAdmin: vm.parseJsonAddress(json, '.accessManagerAdmin'), + proxyAdminOwner: vm.parseJsonAddress(json, '.proxyAdminOwner'), + hubAdmin: vm.parseJsonAddress(json, '.hubAdmin'), + hubConfiguratorAdmin: vm.parseJsonAddress(json, '.hubConfiguratorAdmin'), + treasurySpokeOwner: vm.parseJsonAddress(json, '.treasurySpokeOwner'), + spokeAdmin: vm.parseJsonAddress(json, '.spokeAdmin'), + spokeConfiguratorAdmin: vm.parseJsonAddress(json, '.spokeConfiguratorAdmin'), + gatewayOwner: vm.parseJsonAddress(json, '.gatewayOwner'), + positionManagerOwner: vm.parseJsonAddress(json, '.positionManagerOwner'), + nativeWrapper: vm.parseJsonAddress(json, '.nativeWrapper'), + deployNativeTokenGateway: vm.parseJsonBool(json, '.deployNativeTokenGateway'), + deploySignatureGateway: vm.parseJsonBool(json, '.deploySignatureGateway'), + deployPositionManagers: vm.parseJsonBool(json, '.deployPositionManagers'), + grantRoles: vm.parseJsonBool(json, '.grantRoles'), + hubLabels: vm.parseJsonStringArray(json, '.hubLabels'), + spokeLabels: vm.parseJsonStringArray(json, '.spokeLabels'), + spokeMaxReservesLimits: spokeMaxReservesLimits, + salt: vm.parseJsonBytes32(json, '.salt') + }); + + if (block.chainid == _expectedChainId()) { + _requireResolved(inputs.hubConfiguratorAdmin, 'hubConfiguratorAdmin'); + _requireResolved(inputs.spokeConfiguratorAdmin, 'spokeConfiguratorAdmin'); + _requireResolved(vm.parseJsonAddress(json, '.governanceExecutor'), 'governanceExecutor'); + } + } + + /// @dev The handover script reads these fields back and grants the market's standing permissions + /// from them, so none of them may still be the placeholder on Base. Local runs are exempt, which + /// is what lets the tests deploy from the unresolved config. + function _requireResolved(address target, string memory field) private pure { + require(target != PLACEHOLDER_ADDRESS, PlaceholderAddress(field)); + } +} diff --git a/scripts/deploy/AaveV4DeployBatchBase.s.sol b/scripts/deploy/AaveV4DeployBatchBase.s.sol index 2bf98e934..683ead63e 100644 --- a/scripts/deploy/AaveV4DeployBatchBase.s.sol +++ b/scripts/deploy/AaveV4DeployBatchBase.s.sol @@ -126,15 +126,18 @@ abstract contract AaveV4DeployBatchBaseScript is Script { sanitizedInputs.hubAdmin = deployer; } } else { - // when grantRoles is false, roles are deferred to a later governance action - // These three admin addresses are still required at deploy time so they default to the deployer + // when grantRoles is false, role grants are deferred to a later governance action, but the + // deploy-time ownership addresses are still applied, defaulting to the deployer when unset // ACCESS_MANAGER_ADMIN_ROLE is also retained by the deployer _logWarning('roles: deferred (not granted during deployment)'); - _logWarning(string.concat('treasury spoke owner', message, outcome)); - sanitizedInputs.treasurySpokeOwner = deployer; - - _logWarning(string.concat('proxy admin owner', message, outcome)); - sanitizedInputs.proxyAdminOwner = deployer; + if (inputs.treasurySpokeOwner == address(0)) { + _logWarning(string.concat('treasury spoke owner', message, outcome)); + sanitizedInputs.treasurySpokeOwner = deployer; + } + if (inputs.proxyAdminOwner == address(0)) { + _logWarning(string.concat('proxy admin owner', message, outcome)); + sanitizedInputs.proxyAdminOwner = deployer; + } } if (inputs.gatewayOwner == address(0)) { _logWarning(string.concat('gateway owner', message, outcome)); diff --git a/src/config-engine/AaveV4Payload.sol b/src/config-engine/AaveV4Payload.sol index 1f3e33e73..f54b249af 100644 --- a/src/config-engine/AaveV4Payload.sol +++ b/src/config-engine/AaveV4Payload.sol @@ -404,14 +404,9 @@ abstract contract AaveV4Payload { } /// @notice Executes all Position Manager configuration actions via delegatecall to the engine. + /// @dev PositionManager Role renouncements happen before Spoke registrations: renouncing requires the + /// Spoke to still be registered on the position manager itself. function _executePositionManagerActions() internal { - IAaveV4ConfigEngine.SpokeRegistration[] memory spokeRegs = positionManagerSpokeRegistrations(); - if (spokeRegs.length > 0) { - _delegateCallEngine( - abi.encodeCall(IAaveV4ConfigEngine.executePositionManagerSpokeRegistrations, (spokeRegs)) - ); - } - IAaveV4ConfigEngine.PositionManagerRoleRenouncement[] memory renouncements = positionManagerRoleRenouncements(); if (renouncements.length > 0) { @@ -419,6 +414,13 @@ abstract contract AaveV4Payload { abi.encodeCall(IAaveV4ConfigEngine.executePositionManagerRoleRenouncements, (renouncements)) ); } + + IAaveV4ConfigEngine.SpokeRegistration[] memory spokeRegs = positionManagerSpokeRegistrations(); + if (spokeRegs.length > 0) { + _delegateCallEngine( + abi.encodeCall(IAaveV4ConfigEngine.executePositionManagerSpokeRegistrations, (spokeRegs)) + ); + } } /// @notice Delegatecalls the config engine with the given calldata. diff --git a/src/config-engine/README.md b/src/config-engine/README.md index bc3b8a985..e3dcd5ddf 100644 --- a/src/config-engine/README.md +++ b/src/config-engine/README.md @@ -8,7 +8,7 @@ The `AaveV4ConfigEngine` is a helper smart contract to abstract best practices w Based on experience reviewing governance payloads for Aave V3, the config engine provides a type-safe, composable interface that covers the most common administrative operations: Hub configuration, Spoke configuration, AccessManager role management, and PositionManager administration. -The engine itself is **stateless** — it never stores data of its own. Payloads invoke it via `delegatecall`, so every external call the engine makes executes in the payload's (governance executor's) context and with the executor's permissions. +The engine itself is **stateless** — it never stores data of its own. Payloads invoke it via `delegatecall`, so every external call the engine makes executes in the governance executor's context and with the executor's permissions. See [Execution context](#execution-context) for the full topology. ## How to use the engine? @@ -22,17 +22,17 @@ The four groups, and the virtual functions in each, are listed below. #### Hub actions (`_executeHubActions`) -| Function | Struct | Purpose | -| ----------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------- | -| `hubAssetListings()` | `AssetListing` | List a new asset on a Hub. Optionally deploys a TokenizationSpoke if `symbol` `and name` are defined. | -| `hubAssetConfigUpdates()` | `AssetConfigUpdate` | Update fee config, IR strategy/data, reinvestment controller | -| `hubSpokeToAssetsAdditions()` | `SpokeToAssetsAddition` | Register a Spoke for multiple assets | -| `hubSpokeConfigUpdates()` | `SpokeConfigUpdate` | Update Spoke caps, risk premium threshold, active/halted | -| `hubAssetHalts()` | `AssetHalt` | Halt an asset | -| `hubAssetDeactivations()` | `AssetDeactivation` | Deactivate an asset | -| `hubAssetCapsResets()` | `AssetCapsReset` | Reset asset caps | -| `hubSpokeDeactivations()` | `SpokeDeactivation` | Deactivate a Spoke | -| `hubSpokeCapsResets()` | `SpokeCapsReset` | Reset Spoke caps | +| Function | Struct | Purpose | +| ----------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `hubAssetListings()` | `AssetListing` | List a new asset on a Hub. Optionally deploys a TokenizationSpoke if all tokenization params are set (`name`, `symbol` and `proxyAdminOwner`). | +| `hubAssetConfigUpdates()` | `AssetConfigUpdate` | Update fee config, IR strategy/data, reinvestment controller | +| `hubSpokeToAssetsAdditions()` | `SpokeToAssetsAddition` | Register a Spoke for multiple assets | +| `hubSpokeConfigUpdates()` | `SpokeConfigUpdate` | Update Spoke caps, risk premium threshold, active/halted | +| `hubAssetHalts()` | `AssetHalt` | Halt an asset | +| `hubAssetDeactivations()` | `AssetDeactivation` | Deactivate an asset | +| `hubAssetCapsResets()` | `AssetCapsReset` | Reset asset caps | +| `hubSpokeDeactivations()` | `SpokeDeactivation` | Deactivate a Spoke | +| `hubSpokeCapsResets()` | `SpokeCapsReset` | Reset Spoke caps | #### Spoke actions (`_executeSpokeActions`) @@ -100,8 +100,11 @@ When `execute()` is called, actions run in the following fixed order: 5. Dynamic reserve config updates 6. Position manager updates 5. **PositionManager actions** (in order): - 1. Spoke registrations - 2. Role renouncements + 1. Spoke PositionManager Role renouncements + 2. Spoke registrations + + Spoke PositionManager role renouncements run first because renouncing requires the Spoke to still be registered on the position manager — this allows renouncing and deregistering the same Spoke in one payload. + 6. `_postExecute()` ### The `KEEP_CURRENT` sentinel pattern @@ -129,7 +132,7 @@ Several engine functions inspect which fields differ from `KEEP_CURRENT` and cho - **Reserve config** (`SpokeEngine.executeSpokeReserveConfigUpdates`) — each flag (priceSource, collateralRisk, paused, frozen, borrowable, receiveSharesEnabled) is updated individually only when it differs from `KEEP_CURRENT` / `KEEP_CURRENT_ADDRESS`. - **Liquidation config** (`SpokeEngine.executeSpokeLiquidationConfigUpdates`) — calls `updateLiquidationConfig` when all three fields change, otherwise updates each field individually. - **Dynamic reserve config** (`SpokeEngine.executeSpokeDynamicReserveConfigUpdates`) — reads the current on-chain config, patches only the non-sentinel fields, and writes back the merged result. If nothing changed, the external call is skipped entirely. -- **Role update** (`AccessManagerEngine.executeRoleUpdates`) — a single `RoleUpdate` struct can update any combination of admin (`uint64`), guardian (`uint64`), grant delay (`uint32`), and label (`string`). Fields set to their type-max sentinel (`KEEP_CURRENT_UINT64` / `KEEP_CURRENT_UINT32`) or empty string are skipped. +- **Role update** (`AccessManagerEngine.executeRoleUpdates`) — a single `RoleUpdate` struct can update any combination of admin (`uint64`), guardian (`uint64`), grant delay (`uint32`), and label (`string`). Fields set to their type-max sentinel (`KEEP_CURRENT_UINT64` / `KEEP_CURRENT_UINT32`) or empty string are skipped. Set `labelUpdate` to `true` to relabel an already-labeled role — the existing label is cleared first (required by the AccessManagerEnumerable label tracking); with `labelUpdate` `false`, labeling an already-labeled role reverts. Clearing a label without setting a new one is not expressible through the engine and requires a direct `labelRole` call. ### Delegatecall architecture @@ -142,6 +145,15 @@ Several engine functions inspect which fields differ from `KEEP_CURRENT` and cho When a payload calls `execute()`, `AaveV4Payload` delegate-calls into `AaveV4ConfigEngine`, which in turn delegate-calls into the appropriate sub-engine. This two-level delegatecall chain means: -- All sub-engine code runs in the **payload's storage and `msg.sender` context** (i.e. the governance executor). - Neither the config engine nor the sub-engines hold any storage, permissions, or admin keys. - All HubConfigurator, SpokeConfigurator, AccessManager, and PositionManager calls originate from the governance executor's address. + +### Execution context + +In production the payload itself executes via delegatecall: the PayloadsController **calls** `Executor.executeTransaction`, which **delegatecalls** `payload.execute()`. Since `msg.sender` is preserved across delegatecall, for all engine code: + +- `address(this)` is the **Executor**. It is the identity holding permissions, and the address external calls originate from. +- `msg.sender` is the **PayloadsController**. It must never be used, for ownership, permissions, or anything else. Deriving an owner from `msg.sender` is bad practice: any address a deployment or configuration needs (e.g. `TokenizationSpokeConfig.proxyAdminOwner`) must be passed explicitly in the action structs. +- Payload storage is not readable during execution: action data must live in immutables, constants, or literals returned by the overridden virtual functions. + +Tests for engine actions must replicate this topology (see `tests/config-engine/GovernanceTopology.t.sol`); calling the engine directly from a test contract produces a different `msg.sender` and can hide context-dependent bugs. diff --git a/src/config-engine/interfaces/IAaveV4ConfigEngine.sol b/src/config-engine/interfaces/IAaveV4ConfigEngine.sol index 2dacaac8e..141fbabbb 100644 --- a/src/config-engine/interfaces/IAaveV4ConfigEngine.sol +++ b/src/config-engine/interfaces/IAaveV4ConfigEngine.sol @@ -15,11 +15,15 @@ import {IAssetInterestRateStrategy} from 'src/hub/interfaces/IAssetInterestRateS /// the universal KEEP_CURRENT sentinel. Boolean fields use uint256 (0=false, 1=true, KEEP_CURRENT=skip). interface IAaveV4ConfigEngine { /// @notice Parameters for tokenization of an asset on a Hub when listing the asset. - /// @dev addCap The add cap for the TokenizationSpoke (0 means no tokenization). - /// @dev name The name for the TokenizationSpoke. - /// @dev symbol The symbol for the TokenizationSpoke. + /// @dev Tokenization is skipped only when all fields are unset. Otherwise `name`, `symbol` and + /// `proxyAdminOwner` are all required; a partially set config reverts. + /// @dev addCap The add cap for the TokenizationSpoke. + /// @dev proxyAdminOwner The owner to set on the ProxyAdmin of the deployed TokenizationSpoke (address(0) when unset). + /// @dev name The name for the TokenizationSpoke ('' when unset). + /// @dev symbol The symbol for the TokenizationSpoke ('' when unset). struct TokenizationSpokeConfig { uint256 addCap; + address proxyAdminOwner; string name; string symbol; } @@ -305,6 +309,7 @@ interface IAaveV4ConfigEngine { /// @dev guardian The new guardian role identifier (KEEP_CURRENT_UINT64 to skip). /// @dev grantDelay The new grant delay (KEEP_CURRENT_UINT32 to skip). /// @dev label The label string (empty string to skip). + /// @dev labelUpdate Must be true to relabel an already-labeled role (clears the existing label first). struct RoleUpdate { address authority; uint64 roleId; @@ -312,6 +317,7 @@ interface IAaveV4ConfigEngine { uint64 guardian; uint32 grantDelay; string label; + bool labelUpdate; } /// @notice Parameters for setting target function roles via AccessManager. @@ -419,6 +425,9 @@ interface IAaveV4ConfigEngine { function executeRoleMemberships(RoleMembership[] calldata memberships) external; /// @notice Updates role configuration (admin, guardian, grant delay, label) via AccessManager. + /// @dev Set labelUpdate to true to relabel an already-labeled role (the existing label is cleared + /// first); labeling an already-labeled role with labelUpdate false reverts. + /// Clearing a label without setting a new one requires a direct `labelRole` call. /// @param updates The role updates to execute. function executeRoleUpdates(RoleUpdate[] calldata updates) external; diff --git a/src/config-engine/libraries/AccessManagerEngine.sol b/src/config-engine/libraries/AccessManagerEngine.sol index 9e483251f..f7591211e 100644 --- a/src/config-engine/libraries/AccessManagerEngine.sol +++ b/src/config-engine/libraries/AccessManagerEngine.sol @@ -32,6 +32,8 @@ library AccessManagerEngine { } /// @notice Updates role configuration (admin, guardian, grant delay, label) via AccessManager. + /// @dev When labelUpdate is true, the existing label is cleared before relabeling, as required by + /// the AccessManagerEnumerable label tracking. /// @param updates The role updates to execute. function executeRoleUpdates(IAaveV4ConfigEngine.RoleUpdate[] calldata updates) external { uint256 length = updates.length; @@ -47,6 +49,9 @@ library AccessManagerEngine { authority.setGrantDelay(updates[i].roleId, updates[i].grantDelay); } if (bytes(updates[i].label).length > 0) { + if (updates[i].labelUpdate) { + authority.labelRole(updates[i].roleId, ''); + } authority.labelRole(updates[i].roleId, updates[i].label); } } diff --git a/src/config-engine/libraries/HubEngine.sol b/src/config-engine/libraries/HubEngine.sol index 20b3d68c7..8cc74e8ad 100644 --- a/src/config-engine/libraries/HubEngine.sol +++ b/src/config-engine/libraries/HubEngine.sol @@ -19,8 +19,12 @@ library HubEngine { /// KEEP_CURRENT sentinel. All fields must be explicitly set when the strategy changes. error InvalidIrDataWithNewStrategy(); + /// @dev Thrown when a tokenization config is partially set. Either all fields are unset (no + /// TokenizationSpoke) or `name`, `symbol` and `proxyAdminOwner` must all be provided. + error InvalidTokenizationSpokeConfig(); + /// @notice Lists new assets on Hubs via the HubConfigurator. - /// @dev When `tokenization.name` & `tokenization.symbol` are defined, also deploys a TokenizationSpoke (impl + proxy) via + /// @dev When tokenization data is set, also deploys a TokenizationSpoke (impl + proxy) via /// CREATE2 and registers it on the Hub for the listed asset. /// @param listings The asset listings to execute. function executeHubAssetListings(IAaveV4ConfigEngine.AssetListing[] calldata listings) external { @@ -216,22 +220,29 @@ library HubEngine { } /// @dev Deploys a TokenizationSpoke (impl + proxy) via CREATE2 and registers it on the Hub. + /// Skipped only when the tokenization config is fully unset; a partially set config reverts + /// instead of being silently ignored. function _deployAndRegisterTokenizationSpoke( IAaveV4ConfigEngine.AssetListing calldata listing ) private { - // if not name and/or symbol given, we assume there is no intention to deploy a TokenizationSpoke, so we skip deployment and registration - if ( - bytes(listing.tokenization.name).length == 0 || bytes(listing.tokenization.symbol).length == 0 - ) { + IAaveV4ConfigEngine.TokenizationSpokeConfig calldata tokenization = listing.tokenization; + + bool hasName = bytes(tokenization.name).length > 0; + bool hasSymbol = bytes(tokenization.symbol).length > 0; + bool hasProxyAdminOwner = tokenization.proxyAdminOwner != address(0); + + if (!hasName && !hasSymbol && !hasProxyAdminOwner && tokenization.addCap == 0) { return; } + require(hasName && hasSymbol && hasProxyAdminOwner, InvalidTokenizationSpokeConfig()); - address proxy = TokenizationSpokeDeployer.deploy( - listing.hub, - listing.underlying, - listing.tokenization.name, - listing.tokenization.symbol - ); + address proxy = TokenizationSpokeDeployer.deploy({ + hub: listing.hub, + underlying: listing.underlying, + name: tokenization.name, + symbol: tokenization.symbol, + proxyAdminOwner: tokenization.proxyAdminOwner + }); uint256 assetId = IHubBase(listing.hub).getAssetId(listing.underlying); @@ -240,7 +251,7 @@ library HubEngine { proxy, assetId, IHub.SpokeConfig({ - addCap: listing.tokenization.addCap.toUint40(), + addCap: tokenization.addCap.toUint40(), drawCap: 0, riskPremiumThreshold: 0, active: true, diff --git a/src/config-engine/libraries/TokenizationSpokeDeployer.sol b/src/config-engine/libraries/TokenizationSpokeDeployer.sol index 193b5da57..b3cfe1d83 100644 --- a/src/config-engine/libraries/TokenizationSpokeDeployer.sol +++ b/src/config-engine/libraries/TokenizationSpokeDeployer.sol @@ -10,20 +10,27 @@ import {TokenizationSpokeInstance} from 'src/spoke/instances/TokenizationSpokeIn /// @notice Library for deterministic CREATE2 deployment and address pre-computation of TokenizationSpoke proxies /// using the Safe Singleton Factory. library TokenizationSpokeDeployer { + /// @dev Thrown when the proxy admin owner is the zero address. + error InvalidProxyAdminOwner(); + /// @notice Deploys a TokenizationSpokeInstance implementation and TransparentUpgradeableProxy via CREATE2 /// through the Safe Singleton Factory. - /// @dev The proxy admin owner is set to `msg.sender`. + /// @dev The proxy admin owner must be passed explicitly, never derived from execution context. /// @param hub The address of the Hub. /// @param underlying The address of the underlying asset. /// @param name The ERC20 name for the TokenizationSpoke share token. /// @param symbol The ERC20 symbol for the TokenizationSpoke share token. + /// @param proxyAdminOwner The initial owner of the ProxyAdmin. /// @return proxy The address of the deployed proxy. function deploy( address hub, address underlying, string calldata name, - string calldata symbol + string calldata symbol, + address proxyAdminOwner ) external returns (address proxy) { + require(proxyAdminOwner != address(0), InvalidProxyAdminOwner()); + bytes32 implSalt = _computeImplementationSalt(hub, underlying, name, symbol); bytes memory implCreationCode = abi.encodePacked( type(TokenizationSpokeInstance).creationCode, @@ -35,7 +42,7 @@ library TokenizationSpokeDeployer { bytes memory initData = abi.encodeCall(TokenizationSpokeInstance.initialize, (name, symbol)); bytes memory proxyCreationCode = abi.encodePacked( type(TransparentUpgradeableProxy).creationCode, - abi.encode(impl, msg.sender, initData) + abi.encode(impl, proxyAdminOwner, initData) ); proxy = Create2Utils.create2Deploy(proxySalt, proxyCreationCode); } @@ -60,7 +67,7 @@ library TokenizationSpokeDeployer { /// @param underlying The address of the underlying asset. /// @param name The ERC20 name for the TokenizationSpoke share token. /// @param symbol The ERC20 symbol for the TokenizationSpoke share token. - /// @param proxyAdminOwner The initial owner of the ProxyAdmin (msg.sender in `deploy`). + /// @param proxyAdminOwner The initial owner of the ProxyAdmin. /// @return The predicted proxy address. function computeProxyAddress( address hub, diff --git a/tests/config-engine/AaveV4Payload.t.sol b/tests/config-engine/AaveV4Payload.t.sol index 471319e41..fa71d3ac2 100644 --- a/tests/config-engine/AaveV4Payload.t.sol +++ b/tests/config-engine/AaveV4Payload.t.sol @@ -876,7 +876,8 @@ contract AaveV4PayloadTest is BaseConfigEngineTest { admin: Roles.HUB_CONFIGURATOR_ROLE, guardian: Roles.HUB_DEFICIT_ELIMINATOR_ROLE, grantDelay: 3600, - label: 'FEE_UPDATER' + label: 'FEE_UPDATER', + labelUpdate: false }); payload.setAccessManagerRoleUpdates(updates); @@ -1021,11 +1022,95 @@ contract AaveV4PayloadTest is BaseConfigEngineTest { }); payload.setPositionManagerRoleRenouncements(renouncements); + vm.expectEmit(address(spoke1())); + emit ISpoke.SetUserPositionManager(USER, address(freshPm), false); payload.execute(); + // the position manager is still active on the Spoke, so isPositionManager being false + // proves the approval itself was cleared + assertTrue(spoke1().isPositionManagerActive(address(freshPm))); assertFalse(spoke1().isPositionManager(USER, address(freshPm))); } + function test_execute_positionManagerDeregistrationWithRenouncement() public { + PositionManagerBaseWrapper freshPm = new PositionManagerBaseWrapper(address(payload)); + IAaveV4ConfigEngine.SpokeRegistration[] + memory regs = new IAaveV4ConfigEngine.SpokeRegistration[](1); + regs[0] = IAaveV4ConfigEngine.SpokeRegistration({ + positionManager: address(freshPm), + spoke: address(spoke1()), + registered: true + }); + payload.setPositionManagerSpokeRegistrations(regs); + + IAaveV4ConfigEngine.PositionManagerUpdate[] + memory pmUpdates = new IAaveV4ConfigEngine.PositionManagerUpdate[](1); + pmUpdates[0] = IAaveV4ConfigEngine.PositionManagerUpdate({ + spokeConfigurator: spokeConfigurator, + spoke: address(spoke1()), + positionManager: address(freshPm), + active: true + }); + payload.setSpokePositionManagerUpdates(pmUpdates); + payload.execute(); + + vm.prank(USER); + spoke1().setUserPositionManager(address(freshPm), true); + assertTrue(spoke1().isPositionManager(USER, address(freshPm))); + + // single payload winding down the position manager: renounce USER's role and deregister the Spoke + regs[0].registered = false; + payload.setPositionManagerSpokeRegistrations(regs); + payload.setSpokePositionManagerUpdates(new IAaveV4ConfigEngine.PositionManagerUpdate[](0)); + + IAaveV4ConfigEngine.PositionManagerRoleRenouncement[] + memory renouncements = new IAaveV4ConfigEngine.PositionManagerRoleRenouncement[](1); + renouncements[0] = IAaveV4ConfigEngine.PositionManagerRoleRenouncement({ + positionManager: address(freshPm), + spoke: address(spoke1()), + user: USER + }); + payload.setPositionManagerRoleRenouncements(renouncements); + + vm.expectEmit(address(spoke1())); + emit ISpoke.SetUserPositionManager(USER, address(freshPm), false); + payload.execute(); + + // the position manager is still active on the Spoke, so isPositionManager being false + // proves the approval itself was cleared + assertTrue(spoke1().isPositionManagerActive(address(freshPm))); + assertFalse(spoke1().isPositionManager(USER, address(freshPm))); + assertFalse(freshPm.isSpokeRegistered(address(spoke1()))); + } + + function test_execute_positionManagerRegistrationWithRenouncement_reverts() public { + PositionManagerBaseWrapper freshPm = new PositionManagerBaseWrapper(address(payload)); + + // single payload bundling the Spoke registration and the role renouncement. + // renouncements run before registrations, so the renounce executes while the + // Spoke is not yet registered and reverts. + IAaveV4ConfigEngine.SpokeRegistration[] + memory regs = new IAaveV4ConfigEngine.SpokeRegistration[](1); + regs[0] = IAaveV4ConfigEngine.SpokeRegistration({ + positionManager: address(freshPm), + spoke: address(spoke1()), + registered: true + }); + payload.setPositionManagerSpokeRegistrations(regs); + + IAaveV4ConfigEngine.PositionManagerRoleRenouncement[] + memory renouncements = new IAaveV4ConfigEngine.PositionManagerRoleRenouncement[](1); + renouncements[0] = IAaveV4ConfigEngine.PositionManagerRoleRenouncement({ + positionManager: address(freshPm), + spoke: address(spoke1()), + user: USER + }); + payload.setPositionManagerRoleRenouncements(renouncements); + + vm.expectRevert(IPositionManagerBase.SpokeNotRegistered.selector); + payload.execute(); + } + // --- Unauthorized execute tests --- function test_execute_reverts_hubAction_withoutHubConfiguratorRole() public { diff --git a/tests/config-engine/AccessManagerEngine.t.sol b/tests/config-engine/AccessManagerEngine.t.sol index 992ed9ea7..ba447d9e7 100644 --- a/tests/config-engine/AccessManagerEngine.t.sol +++ b/tests/config-engine/AccessManagerEngine.t.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.0; import 'tests/config-engine/BaseConfigEngine.t.sol'; +import {IAccessManagerEnumerable} from 'src/access/interfaces/IAccessManagerEnumerable.sol'; + contract AccessManagerEngineTest is BaseConfigEngineTest { // Default Roles : uint64 constant DEFAULT_ADMIN_ROLE = 0; @@ -218,7 +220,8 @@ contract AccessManagerEngineTest is BaseConfigEngineTest { admin: TEST_ADMIN_ROLE_ID, guardian: TEST_GUARDIAN_ROLE_ID, grantDelay: TEST_GRANT_DELAY, - label: 'FEE_UPDATER' + label: 'FEE_UPDATER', + labelUpdate: false }) ) ); @@ -244,7 +247,8 @@ contract AccessManagerEngineTest is BaseConfigEngineTest { admin: TEST_ADMIN_ROLE_ID, guardian: EngineFlags.KEEP_CURRENT_UINT64, grantDelay: EngineFlags.KEEP_CURRENT_UINT32, - label: '' + label: '', + labelUpdate: false }) ) ); @@ -268,7 +272,8 @@ contract AccessManagerEngineTest is BaseConfigEngineTest { admin: EngineFlags.KEEP_CURRENT_UINT64, guardian: TEST_GUARDIAN_ROLE_ID, grantDelay: EngineFlags.KEEP_CURRENT_UINT32, - label: '' + label: '', + labelUpdate: false }) ) ); @@ -293,7 +298,8 @@ contract AccessManagerEngineTest is BaseConfigEngineTest { admin: EngineFlags.KEEP_CURRENT_UINT64, guardian: EngineFlags.KEEP_CURRENT_UINT64, grantDelay: TEST_GRANT_DELAY, - label: '' + label: '', + labelUpdate: false }) ) ); @@ -315,12 +321,72 @@ contract AccessManagerEngineTest is BaseConfigEngineTest { admin: EngineFlags.KEEP_CURRENT_UINT64, guardian: EngineFlags.KEEP_CURRENT_UINT64, grantDelay: EngineFlags.KEEP_CURRENT_UINT32, - label: 'FEE_UPDATER' + label: 'FEE_UPDATER', + labelUpdate: false }) ) ); } + function test_executeRoleUpdates_relabel() public { + engine.executeRoleUpdates(_toRoleUpdateArray(_labelOnlyUpdate('FEE_UPDATER', false))); + + engine.executeRoleUpdates(_toRoleUpdateArray(_labelOnlyUpdate('RISK_UPDATER', true))); + + IAccessManagerEnumerable enumerable = IAccessManagerEnumerable(address(accessManager)); + assertEq(enumerable.getLabelOfRole(TEST_ROLE_ID), 'RISK_UPDATER'); + assertTrue(enumerable.isLabelAssigned('RISK_UPDATER')); + assertFalse(enumerable.isLabelAssigned('FEE_UPDATER')); + } + + function test_executeRoleUpdates_relabel_sameLabel() public { + engine.executeRoleUpdates(_toRoleUpdateArray(_labelOnlyUpdate('FEE_UPDATER', false))); + + engine.executeRoleUpdates(_toRoleUpdateArray(_labelOnlyUpdate('FEE_UPDATER', true))); + + IAccessManagerEnumerable enumerable = IAccessManagerEnumerable(address(accessManager)); + assertEq(enumerable.getLabelOfRole(TEST_ROLE_ID), 'FEE_UPDATER'); + assertTrue(enumerable.isLabelAssigned('FEE_UPDATER')); + } + + function test_executeRoleUpdates_relabel_withoutFlag_reverts() public { + engine.executeRoleUpdates(_toRoleUpdateArray(_labelOnlyUpdate('FEE_UPDATER', false))); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessManagerEnumerable.AccessManagerRoleAlreadyLabeled.selector, + TEST_ROLE_ID + ) + ); + engine.executeRoleUpdates(_toRoleUpdateArray(_labelOnlyUpdate('RISK_UPDATER', false))); + } + + function test_executeRoleUpdates_labelUpdate_onUnlabeledRole_reverts() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessManagerEnumerable.AccessManagerUnlabeledRole.selector, + TEST_ROLE_ID + ) + ); + engine.executeRoleUpdates(_toRoleUpdateArray(_labelOnlyUpdate('FEE_UPDATER', true))); + } + + function _labelOnlyUpdate( + string memory label, + bool labelUpdate + ) internal view returns (IAaveV4ConfigEngine.RoleUpdate memory) { + return + IAaveV4ConfigEngine.RoleUpdate({ + authority: address(accessManager), + roleId: TEST_ROLE_ID, + admin: EngineFlags.KEEP_CURRENT_UINT64, + guardian: EngineFlags.KEEP_CURRENT_UINT64, + grantDelay: EngineFlags.KEEP_CURRENT_UINT32, + label: label, + labelUpdate: labelUpdate + }); + } + function test_executeRoleUpdates_noneChanged() public { vm.recordLogs(); engine.executeRoleUpdates( @@ -331,7 +397,8 @@ contract AccessManagerEngineTest is BaseConfigEngineTest { admin: EngineFlags.KEEP_CURRENT_UINT64, guardian: EngineFlags.KEEP_CURRENT_UINT64, grantDelay: EngineFlags.KEEP_CURRENT_UINT32, - label: '' + label: '', + labelUpdate: false }) ) ); @@ -358,7 +425,8 @@ contract AccessManagerEngineTest is BaseConfigEngineTest { admin: admin, guardian: guardian, grantDelay: grantDelay, - label: 'FUZZ_LABEL' + label: 'FUZZ_LABEL', + labelUpdate: IAccessManagerEnumerable(address(accessManager)).isRoleLabeled(roleId) }) ) ); @@ -388,7 +456,8 @@ contract AccessManagerEngineTest is BaseConfigEngineTest { admin: TEST_ADMIN_ROLE_ID, guardian: EngineFlags.KEEP_CURRENT_UINT64, grantDelay: EngineFlags.KEEP_CURRENT_UINT32, - label: '' + label: '', + labelUpdate: false }) ) ); diff --git a/tests/config-engine/BaseConfigEngine.t.sol b/tests/config-engine/BaseConfigEngine.t.sol index 50c8e64c6..89b22cca1 100644 --- a/tests/config-engine/BaseConfigEngine.t.sol +++ b/tests/config-engine/BaseConfigEngine.t.sol @@ -38,6 +38,7 @@ import {TokenizationSpokeDeployer} from 'src/config-engine/libraries/Tokenizatio import {WETH9} from 'src/dependencies/weth/WETH9.sol'; import {TestnetERC20} from 'tests/helpers/mocks/TestnetERC20.sol'; import {AaveV4PayloadWrapper} from 'tests/helpers/mocks/config-engine/AaveV4PayloadWrapper.sol'; +import {MockGovernanceExecutor} from 'tests/helpers/mocks/config-engine/MockGovernanceExecutor.sol'; import {MockPriceFeed} from 'tests/helpers/mocks/MockPriceFeed.sol'; import {PositionManagerBaseWrapper} from 'tests/helpers/mocks/PositionManagerBaseWrapper.sol'; @@ -74,7 +75,10 @@ abstract contract BaseConfigEngineTest is Test, Create2TestHelper { address internal ACCOUNT = makeAddr('ACCOUNT'); address internal TARGET = makeAddr('TARGET'); address internal USER = makeAddr('USER'); + address internal PAYLOADS_CONTROLLER = makeAddr('PAYLOADS_CONTROLLER'); + address internal PROXY_ADMIN_OWNER = makeAddr('PROXY_ADMIN_OWNER'); + MockGovernanceExecutor internal executor; AaveV4ConfigEngine internal engine; IAccessManager internal accessManager; IHubConfigurator internal hubConfigurator; @@ -152,6 +156,7 @@ abstract contract BaseConfigEngineTest is Test, Create2TestHelper { oracles[i] = IAaveOracle(report.spokeReports[i].aaveOracle); } + executor = new MockGovernanceExecutor(PAYLOADS_CONTROLLER); engine = new AaveV4ConfigEngine(); positionManager = new PositionManagerBaseWrapper(address(engine)); @@ -332,7 +337,12 @@ abstract contract BaseConfigEngineTest is Test, Create2TestHelper { liquidityFee: LIQUIDITY_FEE, irStrategy: address(irStrategy1()), irData: IR_DATA, - tokenization: IAaveV4ConfigEngine.TokenizationSpokeConfig({addCap: 0, name: '', symbol: ''}) + tokenization: IAaveV4ConfigEngine.TokenizationSpokeConfig({ + addCap: 0, + proxyAdminOwner: address(0), + name: '', + symbol: '' + }) }); } diff --git a/tests/config-engine/GovernanceTopology.t.sol b/tests/config-engine/GovernanceTopology.t.sol new file mode 100644 index 000000000..5fb12064f --- /dev/null +++ b/tests/config-engine/GovernanceTopology.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/config-engine/BaseConfigEngine.t.sol'; + +import {ProxyHelper} from 'tests/utils/ProxyHelper.sol'; +import {MockGovernanceExecutor} from 'tests/helpers/mocks/config-engine/MockGovernanceExecutor.sol'; +import {MockTokenizationListingPayload} from 'tests/helpers/mocks/config-engine/MockTokenizationListingPayload.sol'; + +/// @dev Validates that payload execution flow through the correct governance topology, +/// respecting the correct order of calls and delegatecalls between each contract +/// (PayloadsController → Executor → delegatecall payload → delegatecall engine), where +/// `msg.sender` is the PayloadsController and `address(this)` is the Executor. +contract ConfigEngineGovernanceTopologyTest is BaseConfigEngineTest { + MockTokenizationListingPayload internal payload; + + function setUp() public override { + super.setUp(); + + payload = new MockTokenizationListingPayload({ + configEngine: IAaveV4ConfigEngine(address(engine)), + hubConfigurator: hubConfigurator, + hub: address(hub1()), + underlying: address(newToken), + feeReceiver: FEE_RECEIVER, + irStrategy: address(irStrategy1()), + proxyAdminOwner: PROXY_ADMIN_OWNER + }); + + // in production the Executor, not the payload or the engine, holds the configurator permissions + vm.prank(ADMIN); + accessManager.grantRole(Roles.HUB_CONFIGURATOR_DOMAIN_ADMIN_ROLE, address(executor), 0); + } + + function _executePayload(address target) internal { + vm.prank(PAYLOADS_CONTROLLER); + executor.executeTransaction(target, abi.encodeCall(AaveV4Payload.execute, ())); + } + + function test_hubAssetListing_tokenizationSpoke_proxyAdminOwner() public { + uint256 expectedAssetId = hub1().getAssetCount(); + + _executePayload(address(payload)); + + // spoke 0 is the fee receiver registered by addAsset, spoke 1 the deployed TokenizationSpoke + assertEq(hub1().getSpokeCount(expectedAssetId), 2); + address tokenizationSpoke = hub1().getSpokeAddress(expectedAssetId, 1); + assertNotEq(tokenizationSpoke, FEE_RECEIVER); + address proxyAdminOwner = Ownable(ProxyHelper.getProxyAdmin(tokenizationSpoke)).owner(); + + assertNotEq( + proxyAdminOwner, + PAYLOADS_CONTROLLER, + 'TokenizationSpoke ProxyAdmin owner must never be the PayloadsController' + ); + assertEq( + proxyAdminOwner, + PROXY_ADMIN_OWNER, + 'TokenizationSpoke ProxyAdmin owner should be the declared proxyAdminOwner' + ); + } + + function test_hubAssetListing_tokenizationSpoke_deterministicAddress() public { + uint256 expectedAssetId = hub1().getAssetCount(); + address predictedProxy = TokenizationSpokeDeployer.computeProxyAddress( + address(hub1()), + address(newToken), + 'Tokenized NEW', + 'tNEW', + PROXY_ADMIN_OWNER + ); + + _executePayload(address(payload)); + + assertTrue(hub1().isSpokeListed(expectedAssetId, predictedProxy)); + } + + function test_executeTransaction_withValue_reverts() public { + vm.deal(PAYLOADS_CONTROLLER, 1 ether); + vm.prank(PAYLOADS_CONTROLLER); + vm.expectRevert(MockGovernanceExecutor.FailedActionExecution.selector); + executor.executeTransaction{value: 1 ether}( + address(payload), + abi.encodeCall(AaveV4Payload.execute, ()) + ); + } +} diff --git a/tests/config-engine/HubEngine.t.sol b/tests/config-engine/HubEngine.t.sol index d3e2094f5..5d6194d96 100644 --- a/tests/config-engine/HubEngine.t.sol +++ b/tests/config-engine/HubEngine.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.0; import 'tests/config-engine/BaseConfigEngine.t.sol'; import {Create2Utils} from 'src/deployments/utils/libraries/Create2Utils.sol'; +import {ProxyHelper} from 'tests/utils/ProxyHelper.sol'; contract HubEngineTest is BaseConfigEngineTest { function setUp() public override { @@ -759,6 +760,7 @@ contract HubEngineTest is BaseConfigEngineTest { listing.underlying = address(newToken); listing.tokenization = IAaveV4ConfigEngine.TokenizationSpokeConfig({ addCap: 1000, + proxyAdminOwner: PROXY_ADMIN_OWNER, name: 'Tokenized NEW', symbol: 'tNEW' }); @@ -774,12 +776,18 @@ contract HubEngineTest is BaseConfigEngineTest { address(newToken), 'Tokenized NEW', 'tNEW', - address(this) + PROXY_ADMIN_OWNER ); IHub.SpokeConfig memory tsConfig = hub1().getSpokeConfig(assetCountBefore, predictedProxy); assertEq(tsConfig.addCap, 1000); assertTrue(tsConfig.active); + + assertEq( + Ownable(ProxyHelper.getProxyAdmin(predictedProxy)).owner(), + PROXY_ADMIN_OWNER, + 'TokenizationSpoke ProxyAdmin owner should be the declared proxyAdminOwner' + ); } function test_executeHubAssetListings_noTokenization() public { @@ -787,9 +795,23 @@ contract HubEngineTest is BaseConfigEngineTest { listing.underlying = address(newToken); uint256 assetCountBefore = hub1().getAssetCount(); + uint256 expectedAssetId = assetCountBefore; engine.executeHubAssetListings(_toAssetListingArray(listing)); assertEq(hub1().getAssetCount(), assetCountBefore + 1); + + // the skip path must not deploy or register a TokenizationSpoke: only the fee receiver spoke exists + assertEq(hub1().getSpokeCount(expectedAssetId), 1); + + address predictedProxy = TokenizationSpokeDeployer.computeProxyAddress( + address(hub1()), + address(newToken), + '', + '', + PROXY_ADMIN_OWNER + ); + assertFalse(hub1().isSpokeListed(expectedAssetId, predictedProxy)); + assertEq(predictedProxy.code.length, 0); } function test_executeHubAssetListings_tokenization_deterministicAddress() public { @@ -797,6 +819,7 @@ contract HubEngineTest is BaseConfigEngineTest { listing.underlying = address(newToken); listing.tokenization = IAaveV4ConfigEngine.TokenizationSpokeConfig({ addCap: 1000, + proxyAdminOwner: PROXY_ADMIN_OWNER, name: 'Tokenized NEW', symbol: 'tNEW' }); @@ -806,7 +829,7 @@ contract HubEngineTest is BaseConfigEngineTest { address(newToken), 'Tokenized NEW', 'tNEW', - address(this) + PROXY_ADMIN_OWNER ); uint256 assetCountBefore = hub1().getAssetCount(); @@ -816,60 +839,101 @@ contract HubEngineTest is BaseConfigEngineTest { assertEq(tsConfig.addCap, 1000); } - function test_executeHubAssetListings_tokenization_skipsOnEmptyName() public { + function test_executeHubAssetListings_tokenization_revertsOnEmptyName() public { IAaveV4ConfigEngine.AssetListing memory listing = _defaultAssetListing(); listing.underlying = address(newToken); listing.tokenization = IAaveV4ConfigEngine.TokenizationSpokeConfig({ addCap: 1000, + proxyAdminOwner: PROXY_ADMIN_OWNER, name: '', symbol: 'tNEW' }); - uint256 assetCountBefore = hub1().getAssetCount(); - uint256 expectedAssetId = assetCountBefore; + vm.expectRevert(HubEngine.InvalidTokenizationSpokeConfig.selector); + engine.executeHubAssetListings(_toAssetListingArray(listing)); + } + function test_executeHubAssetListings_tokenization_revertsOnEmptySymbol() public { + IAaveV4ConfigEngine.AssetListing memory listing = _defaultAssetListing(); + listing.underlying = address(newToken); + listing.tokenization = IAaveV4ConfigEngine.TokenizationSpokeConfig({ + addCap: 1000, + proxyAdminOwner: PROXY_ADMIN_OWNER, + name: 'Tokenized NEW', + symbol: '' + }); + + vm.expectRevert(HubEngine.InvalidTokenizationSpokeConfig.selector); engine.executeHubAssetListings(_toAssetListingArray(listing)); + } - assertEq(hub1().getAssetCount(), assetCountBefore + 1); - assertEq(hub1().getSpokeCount(expectedAssetId), 1); + function test_executeHubAssetListings_tokenization_revertsOnAddCapOnly() public { + IAaveV4ConfigEngine.AssetListing memory listing = _defaultAssetListing(); + listing.underlying = address(newToken); + listing.tokenization = IAaveV4ConfigEngine.TokenizationSpokeConfig({ + addCap: 1000, + proxyAdminOwner: address(0), + name: '', + symbol: '' + }); - address predictedProxy = TokenizationSpokeDeployer.computeProxyAddress( - address(hub1()), - address(newToken), - '', - 'tNEW', - address(this) - ); - assertFalse(hub1().isSpokeListed(expectedAssetId, predictedProxy)); - assertEq(predictedProxy.code.length, 0); + vm.expectRevert(HubEngine.InvalidTokenizationSpokeConfig.selector); + engine.executeHubAssetListings(_toAssetListingArray(listing)); } - function test_executeHubAssetListings_tokenization_skipsOnEmptySymbol() public { + function test_executeHubAssetListings_tokenization_revertsOnZeroProxyAdminOwner() public { IAaveV4ConfigEngine.AssetListing memory listing = _defaultAssetListing(); listing.underlying = address(newToken); listing.tokenization = IAaveV4ConfigEngine.TokenizationSpokeConfig({ addCap: 1000, + proxyAdminOwner: address(0), name: 'Tokenized NEW', - symbol: '' + symbol: 'tNEW' }); - uint256 assetCountBefore = hub1().getAssetCount(); - uint256 expectedAssetId = assetCountBefore; + vm.expectRevert(HubEngine.InvalidTokenizationSpokeConfig.selector); + engine.executeHubAssetListings(_toAssetListingArray(listing)); + } + function test_executeHubAssetListings_tokenization_revertsOnProxyAdminOwnerOnly() public { + IAaveV4ConfigEngine.AssetListing memory listing = _defaultAssetListing(); + listing.underlying = address(newToken); + listing.tokenization = IAaveV4ConfigEngine.TokenizationSpokeConfig({ + addCap: 0, + proxyAdminOwner: PROXY_ADMIN_OWNER, + name: '', + symbol: '' + }); + + vm.expectRevert(HubEngine.InvalidTokenizationSpokeConfig.selector); engine.executeHubAssetListings(_toAssetListingArray(listing)); + } - assertEq(hub1().getAssetCount(), assetCountBefore + 1); - assertEq(hub1().getSpokeCount(expectedAssetId), 1); + function test_executeHubAssetListings_tokenization_zeroAddCap_deploysInactiveCapped() public { + IAaveV4ConfigEngine.AssetListing memory listing = _defaultAssetListing(); + listing.underlying = address(newToken); + listing.tokenization = IAaveV4ConfigEngine.TokenizationSpokeConfig({ + addCap: 0, + proxyAdminOwner: PROXY_ADMIN_OWNER, + name: 'Tokenized NEW', + symbol: 'tNEW' + }); + + uint256 assetCountBefore = hub1().getAssetCount(); + engine.executeHubAssetListings(_toAssetListingArray(listing)); address predictedProxy = TokenizationSpokeDeployer.computeProxyAddress( address(hub1()), address(newToken), 'Tokenized NEW', - '', - address(this) + 'tNEW', + PROXY_ADMIN_OWNER ); - assertFalse(hub1().isSpokeListed(expectedAssetId, predictedProxy)); - assertEq(predictedProxy.code.length, 0); + + IHub.SpokeConfig memory tsConfig = hub1().getSpokeConfig(assetCountBefore, predictedProxy); + assertEq(tsConfig.addCap, 0); + assertTrue(tsConfig.active); + assertEq(Ownable(ProxyHelper.getProxyAdmin(predictedProxy)).owner(), PROXY_ADMIN_OWNER); } function test_executeHubAssetListings_multipleHubs() public { @@ -903,6 +967,17 @@ contract HubEngineTest is BaseConfigEngineTest { assertNotEq(predicted, address(0)); } + function test_tokenizationSpokeDeployer_deploy_revertsOnZeroProxyAdminOwner() public { + vm.expectRevert(TokenizationSpokeDeployer.InvalidProxyAdminOwner.selector); + TokenizationSpokeDeployer.deploy({ + hub: address(hub1()), + underlying: address(newToken), + name: 'Tokenized NEW', + symbol: 'tNEW', + proxyAdminOwner: address(0) + }); + } + function test_executeHubSpokeToAssetsAdditions_revert_spokeAlreadyListed() public { IAaveV4ConfigEngine.SpokeAssetConfig[] memory assets = new IAaveV4ConfigEngine.SpokeAssetConfig[](1); @@ -1088,6 +1163,7 @@ contract HubEngineTest is BaseConfigEngineTest { listing.underlying = address(newToken); listing.tokenization = IAaveV4ConfigEngine.TokenizationSpokeConfig({ addCap: 1000, + proxyAdminOwner: PROXY_ADMIN_OWNER, name: 'Tokenized NEW', symbol: 'tNEW' }); diff --git a/tests/deployments/AaveV4BaseConfigureAndRelinquish.t.sol b/tests/deployments/AaveV4BaseConfigureAndRelinquish.t.sol new file mode 100644 index 000000000..e4b1fb52f --- /dev/null +++ b/tests/deployments/AaveV4BaseConfigureAndRelinquish.t.sol @@ -0,0 +1,453 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {AaveV4DeployBase} from 'scripts/deploy/AaveV4DeployBase.s.sol'; +import {AaveV4BaseConfigInputs} from 'scripts/config/AaveV4BaseConfigInputs.sol'; +import {AaveV4BaseConfiguration} from 'scripts/config/AaveV4BaseConfiguration.sol'; +import {AaveV4BaseHandover} from 'scripts/config/AaveV4BaseHandover.sol'; +import {AaveV4BaseParameters} from 'scripts/config/AaveV4BaseParameters.sol'; + +import {AaveV4DeployOrchestration} from 'src/deployments/orchestration/AaveV4DeployOrchestration.sol'; +import {OrchestrationReports} from 'src/deployments/libraries/OrchestrationReports.sol'; +import {InputUtils} from 'src/deployments/utils/libraries/InputUtils.sol'; +import {BytecodeHelper} from 'src/deployments/utils/libraries/BytecodeHelper.sol'; +import {MetadataLogger} from 'src/deployments/utils/MetadataLogger.sol'; +import {Roles} from 'src/deployments/utils/libraries/Roles.sol'; +import {DeployConstants} from 'src/deployments/utils/libraries/DeployConstants.sol'; +import {IAccessManagerEnumerable} from 'src/access/interfaces/IAccessManagerEnumerable.sol'; +import {IAccessManager} from 'src/dependencies/openzeppelin/IAccessManager.sol'; +import {IERC20Metadata} from 'src/dependencies/openzeppelin/IERC20Metadata.sol'; +import {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol'; +import {Ownable2Step} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {IHubBase} from 'src/hub/interfaces/IHubBase.sol'; +import {IHubConfigurator} from 'src/hub/interfaces/IHubConfigurator.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +import {Create2TestHelper} from 'tests/utils/Create2TestHelper.sol'; + +import {Test} from 'forge-std/Test.sol'; + +/// @title AaveV4BaseConfigureAndRelinquishTest +/// @author Aave Labs +/// @notice Runs the whole Base operator path on a local deployment: deploy from config/base.json +/// with roles deferred, list a set of assets with the launch parameters, halt, then hand +/// over to the Security Council and the governance executor. +/// @dev config/base-config.json carries no assets yet, so the launch set is mocked here rather than +/// read from it: the point of these tests is the configuration and handover machinery, which +/// has to keep working for whatever set eventually lands there. +contract AaveV4BaseConfigureAndRelinquishTest is Test, Create2TestHelper, AaveV4DeployBase { + /// @dev Matches `DeployConstants.ORACLE_DECIMALS`, which `AaveOracle` enforces on price sources. + uint8 internal constant PRICE_FEED_DECIMALS = DeployConstants.ORACLE_DECIMALS; + uint8 internal constant MOCK_ASSET_DECIMALS = 18; + uint256 internal constant MOCK_PRICE = 1e8; + + address internal _deployer = makeAddr('deployer'); + + AaveV4BaseConfigInputs.Market internal _market; + AaveV4BaseConfigInputs.Handover internal _targets; + AaveV4BaseConfigInputs.Asset[] internal _assets; + + function setUp() public { + _etchCreate2Factory(); + + _assets.push(_mockAsset('WETH', true)); + _assets.push(_mockAsset('USDC', false)); + + InputUtils.FullDeployInputs memory inputs = _loadWarningsAndSanitizeInputs( + _getDeployInputs(), + _deployer + ); + + vm.startPrank(_deployer); + OrchestrationReports.FullDeploymentReport memory report = AaveV4DeployOrchestration + .deployAaveV4({ + logger: new MetadataLogger(''), + deployer: _deployer, + deployInputs: inputs, + hubBytecode: BytecodeHelper.getHubBytecode(), + spokeBytecode: BytecodeHelper.getSpokeBytecode() + }); + vm.stopPrank(); + + _market = _toMarket(report); + _targets = AaveV4BaseConfigInputs.readHandover(); + } + + /// @notice The deploy leaves the deployer as AccessManager admin and nothing else granted. + function test_deployDefersAllRoles() public view { + _assertHasRole(Roles.ACCESS_MANAGER_ADMIN_ROLE, _deployer, true); + + // the selectors are wired, but no address holds the roles that reach them yet + _assertHasRole(Roles.HUB_CONFIGURATOR_DOMAIN_ADMIN_ROLE, _deployer, false); + _assertHasRole(Roles.SPOKE_CONFIGURATOR_DOMAIN_ADMIN_ROLE, _deployer, false); + _assertHasRole(Roles.HUB_CONFIGURATOR_ROLE, _market.hubConfigurator, false); + _assertHasRole(Roles.SPOKE_CONFIGURATOR_ROLE, _market.spokeConfigurator, false); + _assertHasRole(Roles.HUB_CONFIGURATOR_DOMAIN_ADMIN_ROLE, _targets.councilExecutor, false); + } + + /// @notice Without the self-granted roles the deployer cannot configure anything. + function test_configureRevertsWithoutRoles() public { + vm.prank(_deployer); + vm.expectRevert(); + IHubConfigurator(_market.hubConfigurator).haltAsset(_market.hub, 0); + } + + /// @notice The AccessManager applies no delay, so the self-granted roles take effect at once. + function test_noAccessManagerDelays() public view { + AaveV4BaseConfiguration.requireNoDelays(_market); + } + + /// @notice Configuration lists every asset everywhere and leaves it halted on every Spoke. + function test_configureListsAndHalts() public { + uint256[] memory assetIds = _configure(); + + assertEq(assetIds.length, _assets.length, 'asset id count'); + assertEq(IHub(_market.hub).getAssetCount(), _assets.length, 'asset count'); + + for (uint256 i; i < _assets.length; ++i) { + assertEq(IHubBase(_market.hub).getAssetId(_assets[i].underlying), assetIds[i], 'asset id'); + + // the treasury spoke is registered as the fee receiver on top of the configured spokes, and + // the tokenized asset gets a tokenization spoke as well + uint256 expectedSpokes = _market.spokes.length + (_assets[i].tokenize ? 2 : 1); + uint256 spokeCount = IHub(_market.hub).getSpokeCount(assetIds[i]); + assertEq(spokeCount, expectedSpokes, 'spoke count'); + + for (uint256 j; j < spokeCount; ++j) { + address spoke = IHub(_market.hub).getSpokeAddress(assetIds[i], j); + assertTrue(IHub(_market.hub).getSpokeConfig(assetIds[i], spoke).halted, 'spoke halted'); + } + + for (uint256 j; j < _market.spokes.length; ++j) { + ISpoke.ReserveConfig memory config = ISpoke(_market.spokes[j]).getReserveConfig(i); + assertEq(config.collateralRisk, AaveV4BaseParameters.COLLATERAL_RISK, 'collateral risk'); + assertFalse(config.borrowable, 'borrowable'); + } + } + } + + /// @notice An empty launch set still configures the market, and lists nothing. + function test_configureWithNoAssets() public { + AaveV4BaseConfigInputs.Asset[] memory none = new AaveV4BaseConfigInputs.Asset[](0); + + vm.startPrank(_deployer); + AaveV4BaseConfiguration.configure(_market, _deployer, none, _targets.proxyAdminOwner); + vm.stopPrank(); + + assertEq(IHub(_market.hub).getAssetCount(), 0, 'asset count'); + _assertHasRole(Roles.HUB_CONFIGURATOR_ROLE, _market.hubConfigurator, true); + _assertHasRole(Roles.SPOKE_CONFIGURATOR_ROLE, _market.spokeConfigurator, true); + _assertManagersWired(); + } + + /// @notice Every position manager and gateway is wired to every Spoke, both halves. + function test_configureWiresPositionManagers() public { + _configure(); + _assertManagersWired(); + } + + /// @notice The tokenized asset's share token follows the live Ethereum and Avalanche naming. + function test_tokenizationSpokeNaming() public { + _configure(); + + address tokenizationSpoke = _tokenizationSpokeOf(0); + assertEq(IERC20Metadata(tokenizationSpoke).name(), 'Wrapped Aave Core WETH', 'share name'); + assertEq(IERC20Metadata(tokenizationSpoke).symbol(), 'waCoreWETH', 'share symbol'); + assertEq( + Ownable(AaveV4BaseConfigInputs.proxyAdmin(tokenizationSpoke)).owner(), + _targets.proxyAdminOwner, + 'tokenization spoke proxy admin' + ); + } + + /// @notice The handover reproduces the role map of the live Ethereum market. + function test_relinquishGrantsTheEthereumRoleMap() public { + _configure(); + _relinquish(); + + // reverts if anything is left behind + AaveV4BaseHandover.verify(_market, _targets, _deployer); + + _assertHasRole(Roles.ACCESS_MANAGER_ADMIN_ROLE, _targets.securityCouncil, true); + _assertHasRole(Roles.ACCESS_MANAGER_ADMIN_ROLE, _targets.governanceExecutor, true); + + // both configurator domain admin roles carry the same three holders + address[3] memory admins = [ + _targets.securityCouncil, + _targets.councilExecutor, + _targets.governanceExecutor + ]; + for (uint256 i; i < admins.length; ++i) { + _assertHasRole(Roles.HUB_CONFIGURATOR_DOMAIN_ADMIN_ROLE, admins[i], true); + _assertHasRole(Roles.SPOKE_CONFIGURATOR_DOMAIN_ADMIN_ROLE, admins[i], true); + } + _assertRoleMemberCount(Roles.HUB_CONFIGURATOR_DOMAIN_ADMIN_ROLE, admins.length); + _assertRoleMemberCount(Roles.SPOKE_CONFIGURATOR_DOMAIN_ADMIN_ROLE, admins.length); + + // the roles that reach the Hub and Spokes directly are left unheld + _assertRoleEmpty(Roles.HUB_DOMAIN_ADMIN_ROLE); + _assertRoleEmpty(Roles.HUB_FEE_MINTER_ROLE); + _assertRoleEmpty(Roles.HUB_DEFICIT_ELIMINATOR_ROLE); + _assertRoleEmpty(Roles.SPOKE_DOMAIN_ADMIN_ROLE); + _assertRoleEmpty(Roles.SPOKE_USER_POSITION_UPDATER_ROLE); + + // the configurators keep the roles they call the Hub and Spokes with + _assertHasRole(Roles.HUB_CONFIGURATOR_ROLE, _market.hubConfigurator, true); + _assertHasRole(Roles.SPOKE_CONFIGURATOR_ROLE, _market.spokeConfigurator, true); + } + + /// @notice After the handover the deployer can no longer configure or grant. + function test_relinquishRevokesDeployerPowers() public { + _configure(); + _relinquish(); + + vm.startPrank(_deployer); + vm.expectRevert(); + IHubConfigurator(_market.hubConfigurator).haltAsset(_market.hub, 0); + + vm.expectRevert(); + IAccessManager(_market.accessManager).grantRole( + Roles.HUB_CONFIGURATOR_DOMAIN_ADMIN_ROLE, + _deployer, + 0 + ); + vm.stopPrank(); + } + + /// @notice Verification fails if an asset is left live on any Spoke. + function test_verifyRejectsUnhaltedAsset() public { + uint256[] memory assetIds = _configure(); + + vm.prank(_deployer); + IHubConfigurator(_market.hubConfigurator).updateSpokeHalted({ + hub: _market.hub, + assetId: assetIds[0], + spoke: _market.spokes[0], + halted: false + }); + _relinquish(); + + vm.expectRevert( + abi.encodeWithSelector( + AaveV4BaseHandover.AssetNotHalted.selector, + assetIds[0], + _market.spokes[0] + ) + ); + this.verifyHandover(); + } + + /// @notice Verification fails if a role that must be left unheld has a member. + function test_verifyRejectsUnexpectedRoleHolder() public { + _configure(); + + vm.prank(_deployer); + IAccessManager(_market.accessManager).grantRole( + Roles.HUB_FEE_MINTER_ROLE, + makeAddr('intruder'), + 0 + ); + _relinquish(); + + vm.expectRevert( + abi.encodeWithSelector(AaveV4BaseHandover.RoleNotEmpty.selector, Roles.HUB_FEE_MINTER_ROLE) + ); + this.verifyHandover(); + } + + /// @notice A tokenization spoke whose ProxyAdmin went elsewhere fails verification. + function test_verifyRejectsForeignTokenizationSpokeProxyAdminOwner() public { + address foreignOwner = makeAddr('foreignOwner'); + + vm.startPrank(_deployer); + AaveV4BaseConfiguration.configure(_market, _deployer, _assets, foreignOwner); + vm.stopPrank(); + _relinquish(); + + vm.expectRevert( + abi.encodeWithSelector( + AaveV4BaseHandover.UnexpectedOwner.selector, + AaveV4BaseConfigInputs.proxyAdmin(_tokenizationSpokeOf(0)), + foreignOwner + ) + ); + this.verifyHandover(); + } + + /// @dev Exposes the verification externally, so that `vm.expectRevert` sees a nested call. + function verifyHandover() external view { + AaveV4BaseHandover.verify(_market, _targets, _deployer); + } + + /// @notice The Council owns the proxies and the treasury spoke throughout, and takes the managers + /// over with one `acceptOwnership` each. + function test_ownershipReachesTheCouncil() public { + _assertCouncilOwnsProxiesAndTreasury(); + _configure(); + _assertCouncilOwnsProxiesAndTreasury(); + + // the deployer owns the managers until the handover, which is what lets it wire them + assertEq(Ownable(_market.giverPositionManager).owner(), _deployer, 'giver owner'); + + _relinquish(); + _assertCouncilOwnsProxiesAndTreasury(); + + // no pending transfer left behind on the Ownable2Step treasury spoke + assertEq(Ownable2Step(_market.treasurySpoke).pendingOwner(), address(0), 'pending owner'); + + address[5] memory managers = [ + _market.giverPositionManager, + _market.takerPositionManager, + _market.configPositionManager, + _market.nativeTokenGateway, + _market.signatureGateway + ]; + + for (uint256 i; i < managers.length; ++i) { + assertEq(Ownable2Step(managers[i]).pendingOwner(), _targets.gatewayOwner, 'pending manager'); + + vm.prank(_targets.gatewayOwner); + Ownable2Step(managers[i]).acceptOwnership(); + assertEq(Ownable(managers[i]).owner(), _targets.gatewayOwner, 'manager owner'); + } + + // still verifies once the transfers have completed + AaveV4BaseHandover.verify(_market, _targets, _deployer); + } + + function _assertCouncilOwnsProxiesAndTreasury() internal view { + assertEq( + Ownable(AaveV4BaseConfigInputs.proxyAdmin(_market.hub)).owner(), + _targets.proxyAdminOwner, + 'hub proxy admin' + ); + for (uint256 i; i < _market.spokes.length; ++i) { + assertEq( + Ownable(AaveV4BaseConfigInputs.proxyAdmin(_market.spokes[i])).owner(), + _targets.proxyAdminOwner, + 'spoke proxy admin' + ); + } + assertEq( + Ownable(AaveV4BaseConfigInputs.proxyAdmin(_market.treasurySpoke)).owner(), + _targets.proxyAdminOwner, + 'treasury proxy admin' + ); + assertEq( + Ownable(_market.treasurySpoke).owner(), + _targets.treasurySpokeOwner, + 'treasury spoke owner' + ); + } + + function _assertManagersWired() internal view { + address[5] memory managers = [ + _market.giverPositionManager, + _market.takerPositionManager, + _market.configPositionManager, + _market.nativeTokenGateway, + _market.signatureGateway + ]; + + for (uint256 i; i < managers.length; ++i) { + for (uint256 j; j < _market.spokes.length; ++j) { + assertTrue( + ISpoke(_market.spokes[j]).isPositionManagerActive(managers[i]), + 'position manager active on spoke' + ); + } + } + } + + function _configure() internal returns (uint256[] memory assetIds) { + vm.startPrank(_deployer); + assetIds = AaveV4BaseConfiguration.configure( + _market, + _deployer, + _assets, + _targets.proxyAdminOwner + ); + vm.stopPrank(); + } + + function _relinquish() internal { + vm.startPrank(_deployer); + AaveV4BaseHandover.relinquish(_market, _targets, _deployer); + vm.stopPrank(); + } + + /// @dev The tokenization spoke is the last Spoke registered for the asset, since configuration + /// adds it after the treasury spoke and the market's own spokes. + function _tokenizationSpokeOf(uint256 assetId) internal view returns (address) { + uint256 spokeCount = IHub(_market.hub).getSpokeCount(assetId); + return IHub(_market.hub).getSpokeAddress(assetId, spokeCount - 1); + } + + function _mockAsset( + string memory symbol, + bool tokenize + ) internal returns (AaveV4BaseConfigInputs.Asset memory asset) { + asset = AaveV4BaseConfigInputs.Asset({ + symbol: symbol, + underlying: makeAddr(string.concat(symbol, '-underlying')), + priceSource: makeAddr(string.concat(symbol, '-priceSource')), + tokenize: tokenize + }); + + deployCodeTo( + 'TestnetERC20.sol:TestnetERC20', + abi.encode(symbol, symbol, MOCK_ASSET_DECIMALS), + asset.underlying + ); + deployCodeTo( + 'MockPriceFeed.sol:MockPriceFeed', + abi.encode(PRICE_FEED_DECIMALS, string.concat(symbol, ' / USD'), MOCK_PRICE), + asset.priceSource + ); + } + + function _toMarket( + OrchestrationReports.FullDeploymentReport memory report + ) internal pure returns (AaveV4BaseConfigInputs.Market memory market) { + market.accessManager = report.authorityBatchReport.accessManager; + market.hubConfigurator = report.configuratorBatchReport.hubConfigurator; + market.spokeConfigurator = report.configuratorBatchReport.spokeConfigurator; + market.treasurySpoke = report.treasurySpokeBatchReport.treasurySpoke; + market.hub = report.hubInstanceBatchReports[0].report.hubProxy; + market.irStrategy = report.hubInstanceBatchReports[0].report.irStrategy; + + market.spokes = new address[](report.spokeInstanceBatchReports.length); + for (uint256 i; i < report.spokeInstanceBatchReports.length; ++i) { + market.spokes[i] = report.spokeInstanceBatchReports[i].report.spokeProxy; + } + + market.nativeTokenGateway = report.gatewaysBatchReport.nativeGateway; + market.signatureGateway = report.gatewaysBatchReport.signatureGateway; + market.giverPositionManager = report.positionManagerBatchReport.giverPositionManager; + market.takerPositionManager = report.positionManagerBatchReport.takerPositionManager; + market.configPositionManager = report.positionManagerBatchReport.configPositionManager; + } + + function _assertHasRole(uint64 role, address account, bool expected) internal view { + (bool isMember, ) = IAccessManager(_market.accessManager).hasRole(role, account); + assertEq(isMember, expected, string.concat('role ', vm.toString(uint256(role)))); + } + + function _assertRoleEmpty(uint64 role) internal view { + _assertRoleMemberCount(role, 0); + } + + /// @dev Pins the exact member count, so a role gaining an unexpected holder fails even when every + /// expected holder is still in place. + function _assertRoleMemberCount(uint64 role, uint256 expected) internal view { + assertEq( + IAccessManagerEnumerable(_market.accessManager).getRoleMemberCount(role), + expected, + string.concat('role ', vm.toString(uint256(role)), ' members') + ); + } + + /// @dev Tests are non-interactive. + function _executeUserPrompt() internal override {} +} diff --git a/tests/deployments/AaveV4BaseDeployConfig.t.sol b/tests/deployments/AaveV4BaseDeployConfig.t.sol new file mode 100644 index 000000000..699d7d6d8 --- /dev/null +++ b/tests/deployments/AaveV4BaseDeployConfig.t.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {PostDeploymentVerificationBase} from 'tests/deployments/fork/PostDeploymentVerificationBase.t.sol'; +import {AaveV4DeployBase} from 'scripts/deploy/AaveV4DeployBase.s.sol'; +import {AaveV4BaseConfigEngine} from 'scripts/config/AaveV4BaseConfigEngine.sol'; +import {AaveV4BaseConfigInputs} from 'scripts/config/AaveV4BaseConfigInputs.sol'; +import {InputUtils} from 'src/deployments/utils/libraries/InputUtils.sol'; + +/// @title AaveV4BaseDeployConfigTest +/// @author Aave Labs +/// @notice Checks that config/base.json and config/base-config.json parse into the intended inputs, +/// and that a full deployment driven by those inputs sets every ownership as configured. +contract AaveV4BaseDeployConfigTest is PostDeploymentVerificationBase, AaveV4DeployBase { + /// @dev `MiscEthereum.V4_SECURITY_COUNCIL`, which is the same address on Ethereum, Avalanche and + /// Arc and is expected to be the same on Base. + address internal constant V4_SECURITY_COUNCIL = 0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9; + /// @dev `GovernanceV3Base.EXECUTOR_LVL_1`. + address internal constant GOVERNANCE_EXECUTOR = 0x9390B1735def18560c509E2d0bc090E9d6BA257a; + address internal constant WETH = 0x4200000000000000000000000000000000000006; + uint256 internal constant BASE_CHAIN_ID = 8453; + + function setUp() public override(PostDeploymentVerificationBase) { + _etchCreate2Factory(); + PostDeploymentVerificationBase.setUp(); + } + + function test_expectedChainId() public pure { + assertEq(_expectedChainId(), BASE_CHAIN_ID); + } + + /// @dev The Security Council owns the market outright. The V4 Security Council executor is not + /// deployed on Base yet, so both configurator domain admin fields are still the placeholder: + /// update these assertions together with config/base.json once it is. + function test_deployInputs() public view { + InputUtils.FullDeployInputs memory inputs = _getDeployInputs(); + + assertEq(inputs.accessManagerAdmin, V4_SECURITY_COUNCIL, 'accessManagerAdmin'); + assertEq(inputs.proxyAdminOwner, V4_SECURITY_COUNCIL, 'proxyAdminOwner'); + assertEq(inputs.treasurySpokeOwner, V4_SECURITY_COUNCIL, 'treasurySpokeOwner'); + assertEq(inputs.gatewayOwner, V4_SECURITY_COUNCIL, 'gatewayOwner'); + assertEq(inputs.positionManagerOwner, V4_SECURITY_COUNCIL, 'positionManagerOwner'); + + // the domain admin roles end up with whoever executes the Council's configuration payloads, + // which is not the Safe that owns the market + assertEq(inputs.hubConfiguratorAdmin, PLACEHOLDER_ADDRESS, 'hubConfiguratorAdmin'); + assertEq(inputs.spokeConfiguratorAdmin, PLACEHOLDER_ADDRESS, 'spokeConfiguratorAdmin'); + + // roles 100-103 and 300-302 are left unheld, as on the live Ethereum and Avalanche markets + assertEq(inputs.hubAdmin, address(0), 'hubAdmin'); + assertEq(inputs.spokeAdmin, address(0), 'spokeAdmin'); + + assertEq(inputs.nativeWrapper, WETH, 'nativeWrapper'); + assertTrue(inputs.deployNativeTokenGateway, 'deployNativeTokenGateway'); + assertTrue(inputs.deploySignatureGateway, 'deploySignatureGateway'); + assertTrue(inputs.deployPositionManagers, 'deployPositionManagers'); + // roles are granted by the configuration and handover scripts, not at deploy time + assertFalse(inputs.grantRoles, 'grantRoles'); + + assertEq(inputs.hubLabels.length, 1, 'hub count'); + assertEq(inputs.hubLabels[0], 'core', 'hub label'); + assertEq(inputs.spokeLabels.length, 1, 'spoke count'); + assertEq(inputs.spokeLabels[0], 'main', 'spoke label'); + assertEq(inputs.spokeMaxReservesLimits.length, 0, 'spoke max reserves limits'); + assertTrue(inputs.salt != bytes32(0), 'salt'); + } + + /// @notice The handover targets are read off the same file the deploy is driven by. + function test_handoverTargets() public view { + AaveV4BaseConfigInputs.Handover memory targets = AaveV4BaseConfigInputs.readHandover(); + + assertEq(targets.securityCouncil, V4_SECURITY_COUNCIL, 'securityCouncil'); + assertEq(targets.councilExecutor, PLACEHOLDER_ADDRESS, 'councilExecutor'); + assertEq(targets.governanceExecutor, GOVERNANCE_EXECUTOR, 'governanceExecutor'); + assertEq(targets.proxyAdminOwner, V4_SECURITY_COUNCIL, 'proxyAdminOwner'); + assertEq(targets.treasurySpokeOwner, V4_SECURITY_COUNCIL, 'treasurySpokeOwner'); + assertEq(targets.gatewayOwner, V4_SECURITY_COUNCIL, 'gatewayOwner'); + assertEq(targets.positionManagerOwner, V4_SECURITY_COUNCIL, 'positionManagerOwner'); + } + + /// @notice The launch set is empty until the assets and their risk parameters are decided, and + /// reading it is not an error. + function test_assetInputsAreEmpty() public view { + assertEq(AaveV4BaseConfigInputs.readAssets().length, 0, 'asset count'); + } + + /// @notice A deploy on Base itself refuses to read the placeholder address. + function test_deployRevertsOnBaseWithPlaceholders() public { + vm.chainId(BASE_CHAIN_ID); + vm.expectRevert(abi.encodeWithSelector(PlaceholderAddress.selector, 'hubConfiguratorAdmin')); + this.readDeployInputs(); + } + + /// @dev Exposes the deploy inputs externally, so that `vm.expectRevert` sees a nested call. + function readDeployInputs() external view returns (InputUtils.FullDeployInputs memory) { + return _getDeployInputs(); + } + + /// @notice The config engine lands on the address `predictedAddress` computes for it. + /// @dev That address is what governance payloads are built against, and nothing records it, so it + /// has to be recomputable rather than merely deterministic. + function test_configEngineDeploysAtPredictedAddress() public { + address predicted = AaveV4BaseConfigEngine.predictedAddress(); + assertEq(predicted.code.length, 0, 'already deployed'); + + assertEq(AaveV4BaseConfigEngine.deploy(), predicted, 'deployed address'); + assertGt(predicted.code.length, 0, 'engine code'); + } + + function test_deployWithBaseConfig() public { + InputUtils.FullDeployInputs memory sanitizedInputs = _loadWarningsAndSanitizeInputs( + _getDeployInputs(), + _deployer + ); + + // the Council owns the proxies and the treasury spoke from the deploy transaction onwards + assertEq(sanitizedInputs.proxyAdminOwner, V4_SECURITY_COUNCIL, 'proxyAdminOwner'); + assertEq(sanitizedInputs.treasurySpokeOwner, V4_SECURITY_COUNCIL, 'treasurySpokeOwner'); + + // the managers and gateways start on the deployer, which needs `onlyOwner` access to + // `registerSpoke` during configuration + assertEq(sanitizedInputs.gatewayOwner, _deployer, 'gatewayOwner'); + assertEq(sanitizedInputs.positionManagerOwner, _deployer, 'positionManagerOwner'); + + _deployWriteReportAndVerify(sanitizedInputs); + } + + /// @dev Tests are non-interactive. + function _executeUserPrompt() internal override {} +} diff --git a/tests/helpers/mocks/config-engine/MockGovernanceExecutor.sol b/tests/helpers/mocks/config-engine/MockGovernanceExecutor.sol new file mode 100644 index 000000000..dab9d108f --- /dev/null +++ b/tests/helpers/mocks/config-engine/MockGovernanceExecutor.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @dev Minimal replica of the aave-governance-v3 Executor. Owned by the PayloadsController and +/// executes payloads via delegatecall, so inside the payload `address(this)` is the Executor +/// while `msg.sender` remains the PayloadsController. +contract MockGovernanceExecutor { + address public immutable OWNER; + + error OnlyOwner(); + error FailedActionExecution(); + + constructor(address owner) { + OWNER = owner; + } + + function executeTransaction( + address target, + bytes memory data + ) external payable returns (bytes memory) { + require(msg.sender == OWNER, OnlyOwner()); + (bool success, bytes memory resultData) = target.delegatecall(data); + require(success, FailedActionExecution()); + return resultData; + } +} diff --git a/tests/helpers/mocks/config-engine/MockTokenizationListingPayload.sol b/tests/helpers/mocks/config-engine/MockTokenizationListingPayload.sol new file mode 100644 index 000000000..831c97303 --- /dev/null +++ b/tests/helpers/mocks/config-engine/MockTokenizationListingPayload.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {AaveV4Payload} from 'src/config-engine/AaveV4Payload.sol'; +import {IAaveV4ConfigEngine} from 'src/config-engine/interfaces/IAaveV4ConfigEngine.sol'; +import {IHubConfigurator} from 'src/hub/interfaces/IHubConfigurator.sol'; +import {IAssetInterestRateStrategy} from 'src/hub/interfaces/IAssetInterestRateStrategy.sol'; + +/// @dev Production-style payload: all action data lives in immutables or literals. `execute()` +/// runs via delegatecall inside the Executor, so payload storage is not readable at execution time. +contract MockTokenizationListingPayload is AaveV4Payload { + IHubConfigurator internal immutable HUB_CONFIGURATOR; + address internal immutable HUB; + address internal immutable UNDERLYING; + address internal immutable FEE_RECEIVER; + address internal immutable IR_STRATEGY; + address internal immutable PROXY_ADMIN_OWNER; + + constructor( + IAaveV4ConfigEngine configEngine, + IHubConfigurator hubConfigurator, + address hub, + address underlying, + address feeReceiver, + address irStrategy, + address proxyAdminOwner + ) AaveV4Payload(configEngine) { + HUB_CONFIGURATOR = hubConfigurator; + HUB = hub; + UNDERLYING = underlying; + FEE_RECEIVER = feeReceiver; + IR_STRATEGY = irStrategy; + PROXY_ADMIN_OWNER = proxyAdminOwner; + } + + function hubAssetListings() + public + view + override + returns (IAaveV4ConfigEngine.AssetListing[] memory) + { + IAaveV4ConfigEngine.AssetListing[] memory listings = new IAaveV4ConfigEngine.AssetListing[](1); + listings[0] = IAaveV4ConfigEngine.AssetListing({ + hubConfigurator: HUB_CONFIGURATOR, + hub: HUB, + underlying: UNDERLYING, + feeReceiver: FEE_RECEIVER, + liquidityFee: 5_00, + irStrategy: IR_STRATEGY, + irData: IAssetInterestRateStrategy.InterestRateData({ + optimalUsageRatio: 80_00, + baseDrawnRate: 1_00, + rateGrowthBeforeOptimal: 4_00, + rateGrowthAfterOptimal: 60_00 + }), + tokenization: IAaveV4ConfigEngine.TokenizationSpokeConfig({ + addCap: 1000, + proxyAdminOwner: PROXY_ADMIN_OWNER, + name: 'Tokenized NEW', + symbol: 'tNEW' + }) + }); + return listings; + } +}