diff --git a/.env.example b/.env.example index c84a7b782..70e1c3de7 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,7 @@ RPC_ZKEVM=https://zkevm-rpc.com RPC_GNOSIS=https://rpc.ankr.com/gnosis RPC_BNB=https://binance.llamarpc.com RPC_CELO=https://forno.celo.org +RPC_ARC= # Etherscan api keys for verification & download utils ETHERSCAN_API_KEY_MAINNET= @@ -31,4 +32,6 @@ ETHERSCAN_API_KEY_GNOSIS= ETHERSCAN_API_KEY_BNB= ETHERSCAN_API_KEY_CELO= - +# Arc's explorer sits behind Cloudflare Access, so forge-native --verify cannot reach it. +# Verification goes through the Blockscout API with this CF_Authorization cookie instead. +ARC_EXPLORER_TOKEN= diff --git a/Makefile b/Makefile index a93614417..9fd1e81aa 100644 --- a/Makefile +++ b/Makefile @@ -38,9 +38,12 @@ deploy-precompile :; --rpc-url ${chain} --account ${account} --ffi \ $(if ${dry},, --broadcast --verify) \ +# Deployment verification suites (scripts/verification), kept out of the protocol test suite +verify-arc :; FOUNDRY_PROFILE=verification forge test + # Step 2: Deploy contracts + grant roles to deployer -# `make deploy-contracts` +# `make deploy-contracts script=AaveV4DeployArc` 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) \ diff --git a/docs/arc-go-live.md b/docs/arc-go-live.md new file mode 100644 index 000000000..95f240fd8 --- /dev/null +++ b/docs/arc-go-live.md @@ -0,0 +1,184 @@ +# Aave V4 on Arc — going live + +Chain id 5042. Deployed, configured and handed over from `0x623f1C807fE1088439e129ebF3B9c92a63a0F5cD`. Four assets are listed on the `core` hub across the `main` and `forex` spokes, and **the whole market is halted**. + +Two Safe transactions remain, both from `0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9`, **in this order**: step 1, then step 2. Until step 1 lands the deployer EOA still holds `rescueToken` and `rescueNative` on contracts that will be handling user funds, so un-pausing first would open the market with that outstanding. + +Both should also wait on the Safe reaching 5-of-8. It is already the root authority and owns every ProxyAdmin. + +## Step 1 — accept ownership of the position managers + +`docs/arc-safe-1-accept-ownership.json` — 4 transactions. + +The three position managers and the signature gateway were deployed owned by the deployer, because configuration needs `onlyOwner` access to `registerSpoke` on them. The handover called `transferOwnership` to the Safe, and `PositionManagerBase` is `Ownable2Step`, so each one is waiting on `acceptOwnership()`. + +Until this runs, the deployer still owns those four contracts, which means `registerSpoke`, `renouncePositionManagerRole` and — since the rescue guardian is `owner()` — `rescueToken` and `rescueNative`. Everything else is already the Safe's. + +Worth understanding the shape of this rather than just doing it: **all fifteen relinquish transactions succeeded and ownership never moved.** `transferOwnership` on an `Ownable2Step` contract records a pending owner and returns successfully, so a check that the calls went through reports a clean handover while the deployer still owns everything. Only a check of `owner()` itself catches it. That is why `ArcVerification` asserts end state rather than transaction success, and it is the part worth carrying to the next chain. + +| Contract | Address | Call | +| --------------------- | -------------------------------------------- | ------------------- | +| GiverPositionManager | `0x01Da80Eef3004ebbF90b7637B1De7fF30fBc7cf1` | `acceptOwnership()` | +| TakerPositionManager | `0xe9fae1C386c6f45B1fb3C3Ef01aDE424DAd4bCcF` | `acceptOwnership()` | +| ConfigPositionManager | `0xa5Aa65Ae1c830d2ae10853CeEa42AE653adB3312` | `acceptOwnership()` | +| SignatureGateway | `0x0d36A4a21119BBBDe559d59002254171D976289f` | `acceptOwnership()` | + +## Step 2 — un-pause the market + +`docs/arc-safe-2-unpause.json` — 14 transactions. **This is the go-live switch. Do not run it until step 1 has landed, the Safe is 5-of-8, and the blocking items below are settled.** + +Every asset was halted at the end of configuration with `HubConfigurator.haltAsset`, which sets `halted = true` on every spoke registered for it. There is no `unhaltAsset`, so releasing means one `Hub.updateSpokeConfig` per asset-and-spoke pair with `halted` cleared and the caps passed back unchanged. + +The Safe holds `HUB_CONFIGURATOR_ROLE` (101), so it calls the Hub **directly** — no Executor hop. Confirmed on chain: `AccessManager.canCall(Safe, Hub, 0xa2763d29)` returns true with zero delay, and the same call from the Executor or the deployer returns false. + +Each transaction targets the hub, `0x17288dfc86205301064577b98B02b81017e6F79C`: + +| Asset | assetId | Spoke | Address | addCap | drawCap | +| ------ | ------- | ------------ | -------------------------------------------- | ----------------- | ---------- | +| USDC | 0 | treasury | `0xcbd466CB8709D9f6dd8312668B4dbef394cE0e15` | 1,099,511,627,775 | 0 | +| USDC | 0 | main | `0xB843bdC3a87A05E77E07Df9FE48928b3A34b134d` | 56,000,000 | 51,000,000 | +| USDC | 0 | forex | `0x4164EBCAF74670aa74C8D4F59de6157c0780F1bB` | 13,000,000 | 11,000,000 | +| USDC | 0 | tokenization | `0x42EAB64310E1D1c66b4d8aF7C9C4ce253885eB83` | 10,000,000 | 0 | +| EURC | 1 | treasury | `0xcbd466CB8709D9f6dd8312668B4dbef394cE0e15` | 1,099,511,627,775 | 0 | +| EURC | 1 | main | `0xB843bdC3a87A05E77E07Df9FE48928b3A34b134d` | 20,000,000 | 18,000,000 | +| EURC | 1 | forex | `0x4164EBCAF74670aa74C8D4F59de6157c0780F1bB` | 10,000,000 | 9,000,000 | +| EURC | 1 | tokenization | `0x5A10b1533C0f1f181DC8a428BF5Eb58B08fc8d2c` | 9,000,000 | 0 | +| cirBTC | 2 | treasury | `0xcbd466CB8709D9f6dd8312668B4dbef394cE0e15` | 1,099,511,627,775 | 0 | +| cirBTC | 2 | main | `0xB843bdC3a87A05E77E07Df9FE48928b3A34b134d` | 1,100 | 220 | +| cirBTC | 2 | tokenization | `0x83D364DbAf4e7018E0b87dB3FaB3d1d8535a6F13` | 160 | 0 | +| WETH | 3 | treasury | `0xcbd466CB8709D9f6dd8312668B4dbef394cE0e15` | 1,099,511,627,775 | 0 | +| WETH | 3 | main | `0xB843bdC3a87A05E77E07Df9FE48928b3A34b134d` | 24,000 | 4,800 | +| WETH | 3 | tokenization | `0xe8B890fea6e1E3915A337eD3136487F2f4f7e59D` | 6,000 | 0 | + +Caps are in whole token units; the Hub scales them by each token's decimals. The treasury spoke carries `MAX_ALLOWED_SPOKE_CAP` as its add cap, which is the fee-receiver default and means uncapped. + +The bundle can be split if you would rather release in stages — each transaction is independent. Releasing an asset only on `main` and not on `forex`, for instance, is just a matter of dropping the rows you do not want yet. + +After it lands, `forge script scripts/config/AaveV4VerifyArc.s.sol --rpc-url arc` will fail on the halt assertion, since it checks that everything **is** halted. That is expected once the market is open. + +## Deployed addresses + +### Core + +| Contract | Address | +| ------------------------- | -------------------------------------------- | +| AccessManagerEnumerable | `0x24761DB265998ba1D38E8a29031cF72C2CeF3A7D` | +| HubConfigurator | `0x419cF771E08d927b23F2F1691968C5135Ad8B659` | +| SpokeConfigurator | `0x102610d2A7Fd87A85ad8fdCfC78879be8Fd40576` | +| AssetInterestRateStrategy | `0xaa5b3bF9f16b634Eb1e0C1210bF8bB92b526e76D` | +| AaveV4ConfigEngine | `0x0A3af96f72b1B52c9BB9778FcD839154c2599371` | + +### Proxies + +Each row is a `TransparentUpgradeableProxy`. Every ProxyAdmin is owned by the Safe. + +| Contract | Proxy | Implementation | ProxyAdmin | +| -------------------------- | -------------------------------------------- | -------------------------------------------- | -------------------------------------------- | +| Hub (core) | `0x17288dfc86205301064577b98B02b81017e6F79C` | `0x3DcECcbD9b051638Bfa42200e7aAEC9Cc9621258` | `0x64F13f38798818D7AaC1bbD4dEfa163b90EA2fdD` | +| Spoke (main) | `0xB843bdC3a87A05E77E07Df9FE48928b3A34b134d` | `0xf76b49F5911Ca3838a469563c0ffB07c8f91ba79` | `0x6a1beE304Ce745Df8D3Ab5c18f16Ac0561BD2626` | +| Spoke (forex) | `0x4164EBCAF74670aa74C8D4F59de6157c0780F1bB` | `0xd4452Bd02C804245a41C75a5d0f6289C3ac787B6` | `0xE8d75bb46C15c70dB2A464886f51b8a46A871108` | +| TreasurySpoke | `0xcbd466CB8709D9f6dd8312668B4dbef394cE0e15` | `0x25AE500228A7307673BbD933F806ce7DC6555D66` | `0xfa12D100A649c8D4dCC0047f9618b2bB4939f6A0` | +| TokenizationSpoke (USDC) | `0x42EAB64310E1D1c66b4d8aF7C9C4ce253885eB83` | `0x7a2f8AFBF1F0Acaf5610d1D6860aFb5Ac931Ee43` | `0xFf7727ab55df7356F218c887C35a65c51768E374` | +| TokenizationSpoke (EURC) | `0x5A10b1533C0f1f181DC8a428BF5Eb58B08fc8d2c` | `0x9295E6e945e52DCD5AAE7943Fd9ACb62496A87ce` | `0x5b3d12e8c9168c6323D5158B8862F7dAA08605Eb` | +| TokenizationSpoke (cirBTC) | `0x83D364DbAf4e7018E0b87dB3FaB3d1d8535a6F13` | `0x4d2763ED7e162C1b7949176D73BEcEE940fDAF47` | `0x3414c0e09C804A63e5a2E003eA7226DEae975f7C` | +| TokenizationSpoke (WETH) | `0xe8B890fea6e1E3915A337eD3136487F2f4f7e59D` | `0x0215DF4A493A984997B1FB89660A3a1332fb5A4c` | `0xB05C54B974cE826e48AE7e3C2A72B2a8900E3B34` | + +### Position managers and gateways + +Owned by the deployer until step 1. + +| Contract | Address | +| --------------------- | -------------------------------------------- | +| GiverPositionManager | `0x01Da80Eef3004ebbF90b7637B1De7fF30fBc7cf1` | +| TakerPositionManager | `0xe9fae1C386c6f45B1fb3C3Ef01aDE424DAd4bCcF` | +| ConfigPositionManager | `0xa5Aa65Ae1c830d2ae10853CeEa42AE653adB3312` | +| SignatureGateway | `0x0d36A4a21119BBBDe559d59002254171D976289f` | + +### Oracles + +One `AaveOracle` per spoke, holding the per-reserve price sources. + +| Contract | Address | +| ------------------ | -------------------------------------------- | +| AaveOracle (main) | `0x6ffE98F3422041236c19923EDB949F18A69e8A09` | +| AaveOracle (forex) | `0x2abd2B5C30D649273B3b762b0E1758BaC8F87cFE` | + +### Libraries + +Deployed separately and linked into the contracts that call them. + +| Library | Address | Linked into | +| ------------------------- | -------------------------------------------- | ------------------ | +| LiquidationLogic | `0x818E84198224535FAeaEc1b583d3Ff6b812A5AF3` | SpokeInstance | +| AccessManagerEngine | `0x060b87b8481eEa9AeE246289AA774CE977445031` | AaveV4ConfigEngine | +| HubEngine | `0xc350fA6A315E783aB15D8F8bf6ECc49796587465` | AaveV4ConfigEngine | +| SpokeEngine | `0x8a121c22D558c91fc6819fEf1c738bb457Ad79F2` | AaveV4ConfigEngine | +| PositionManagerEngine | `0x9fF7CCe79F0599D6Dd2620bc28763F7cE287D88e` | AaveV4ConfigEngine | +| TokenizationSpokeDeployer | `0x38a979fa226e075f31C056CaE7C922Af782B1b66` | HubEngine | + +### Assets + +| Asset | assetId | Underlying | Dec | Price source | +| ------ | ------- | -------------------------------------------- | --- | -------------------------------------------- | +| USDC | 0 | `0x3600000000000000000000000000000000000000` | 6 | `0x729cFd10FC10A908aE9F9b35245cB6Ee14D44D6B` | +| EURC | 1 | `0xbEf5f6d51CB62b58e6A8f77868681825C6fe21c1` | 6 | `0x1aBa23B4733aa96919C4434c1b9AC25bE9550d58` | +| cirBTC | 2 | `0x171A4217b86A807A64eB94757Db6849fb4bDbAA0` | 8 | `0x7777547914e03BCbB04Ae034942765a0dbb26aE3` | +| WETH | 3 | `0x128cC466B61f542da60c70e3aA11c10e19B84EDB` | 18 | `0x2c7Dc3567b3490f53A8d32625d766834dd023F60` | + +USDC and EURC price through capped adapters (`PriceCapAdapterStable` and `EURPriceCapAdapterStable`); cirBTC and wETH through Chainlink SVR proxies for BTC/USD and ETH/USD. + +### Tokenization spokes + +Supply-only, one per asset, ERC-4626 share tokens. + +| Asset | Proxy | Share name | Symbol | Add cap | +| ------ | -------------------------------------------- | ------------------------ | -------------- | ---------- | +| USDC | `0x42EAB64310E1D1c66b4d8aF7C9C4ce253885eB83` | Wrapped Aave Core USDC | `waCoreUSDC` | 10,000,000 | +| EURC | `0x5A10b1533C0f1f181DC8a428BF5Eb58B08fc8d2c` | Wrapped Aave Core EURC | `waCoreEURC` | 9,000,000 | +| cirBTC | `0x83D364DbAf4e7018E0b87dB3FaB3d1d8535a6F13` | Wrapped Aave Core cirBTC | `waCorecirBTC` | 160 | +| WETH | `0xe8B890fea6e1E3915A337eD3136487F2f4f7e59D` | Wrapped Aave Core WETH | `waCoreWETH` | 6,000 | + +## Authority + +| Role | Id | Holder | +| ------------------------------- | --- | ------------------------------------------------------------- | +| ACCESS_MANAGER_ADMIN | 0 | Safe | +| HUB_CONFIGURATOR | 101 | HubConfigurator contract, Safe | +| HUB_FEE_MINTER | 102 | Safe | +| HUB_DEFICIT_ELIMINATOR | 103 | Safe | +| HUB_CONFIGURATOR_DOMAIN_ADMIN | 200 | Council Executor `0x8e79b0541122d3822eC93082cEB1ab03EDBc1Fd5` | +| SPOKE_CONFIGURATOR | 301 | SpokeConfigurator contract, Safe | +| SPOKE_USER_POSITION_UPDATER | 302 | Safe | +| SPOKE_CONFIGURATOR_DOMAIN_ADMIN | 400 | Council Executor | + +The deployer holds no role. Roles 100 and 300 are held by nobody, as on Ethereum and Avalanche. + +### Divergence from the other markets + +Arc grants the Safe raw hub and spoke roles that the other two V4 markets do not grant to anyone. Enumerated on all three chains: + +| Role | Ethereum | Avalanche | Arc | +| ------------------------- | ---------------------- | ---------------------- | ---------------------------- | +| 101 hub configurator | HubConfigurator only | HubConfigurator only | HubConfigurator **+ Safe** | +| 102 fee minter | **nobody** | **nobody** | **Safe** | +| 103 deficit eliminator | **nobody** | **nobody** | **Safe** | +| 301 spoke configurator | SpokeConfigurator only | SpokeConfigurator only | SpokeConfigurator **+ Safe** | +| 302 user position updater | **nobody** | **nobody** | **Safe** | + +These are not one decision, they are two: + +- **101 is load-bearing — leave it.** The un-pause in step 2 depends on the Safe calling the Hub directly, and the Executor cannot: `canCall(Executor, Hub, 0xa2763d29)` is false. Revoking 101 would break the release path. +- **102, 103 and 302 have no stated purpose on Arc and no holder on either other market.** Three roles that exist nowhere else are live here. Unless there is a reason for them, revoke — cheap now, awkward once the market is open. 301 is in the same category as 101 in principle, though nothing in the current release path needs it. + +## Known issues + +### Blocking un-pause + +- **The Safe is reportedly 1 owner at threshold 1.** It is the root authority and owns every ProxyAdmin. +- **cirBTC prices off a raw BTC/USD feed at a 78% collateral factor.** `PriceCapAdapterBase` clamps only the upper bound and has no floor, so a cirBTC discount to BTC is unpriced. This stopped being hypothetical when the asset was listed: the exposure is real from the moment step 2 runs. Every other wrapped BTC in the Aave price-feed set takes an asset-specific second input; cirBTC on a flat BTC/USD price is the first exception. A capped adapter is a one-field config change. +- **The 1.04 price cap** on the USDC and EURC adapters traces to no line of the ARFC — it is an inherited cross-network default, and it is load-bearing for both stablecoins. Confirm or set it deliberately. + +### Non-blocking + +- **Licence metadata is wrong on 21 of 24 verified contracts** — see `docs/arc-verif-license.md`. Cosmetic, and not fixable by resubmission. +- **wETH also sits on a raw Chainlink feed** rather than a capped adapter. Less acute than cirBTC — ETH/USD is a direct price for the asset, not a proxy for it — but the same asymmetry against USDC and EURC. diff --git a/docs/arc-safe-1-accept-ownership.json b/docs/arc-safe-1-accept-ownership.json new file mode 100644 index 000000000..2ca850991 --- /dev/null +++ b/docs/arc-safe-1-accept-ownership.json @@ -0,0 +1,43 @@ +{ + "version": "1.0", + "chainId": "5042", + "createdAt": 1788362599175, + "meta": { + "name": "Arc: accept ownership of position managers", + "description": "acceptOwnership() on the three position managers and the signature gateway, completing the Ownable2Step transfer from the deployer.", + "txBuilderVersion": "1.18.0", + "createdFromSafeAddress": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "createdFromOwnerAddress": "", + "checksum": "" + }, + "transactions": [ + { + "to": "0x01Da80Eef3004ebbF90b7637B1De7fF30fBc7cf1", + "value": "0", + "data": "0x79ba5097", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0xe9fae1C386c6f45B1fb3C3Ef01aDE424DAd4bCcF", + "value": "0", + "data": "0x79ba5097", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0xa5Aa65Ae1c830d2ae10853CeEa42AE653adB3312", + "value": "0", + "data": "0x79ba5097", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x0d36A4a21119BBBDe559d59002254171D976289f", + "value": "0", + "data": "0x79ba5097", + "contractMethod": null, + "contractInputsValues": null + } + ] +} diff --git a/docs/arc-safe-2-unpause.json b/docs/arc-safe-2-unpause.json new file mode 100644 index 000000000..97a11a8e7 --- /dev/null +++ b/docs/arc-safe-2-unpause.json @@ -0,0 +1,113 @@ +{ + "version": "1.0", + "chainId": "5042", + "createdAt": 1788362599175, + "meta": { + "name": "Arc: un-pause the market", + "description": "Hub.updateSpokeConfig for every registered asset/spoke pair, clearing halted while preserving caps. Called by the Safe under HUB_CONFIGURATOR_ROLE (101).", + "txBuilderVersion": "1.18.0", + "createdFromSafeAddress": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "createdFromOwnerAddress": "", + "checksum": "" + }, + "transactions": [ + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d290000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cbd466cb8709d9f6dd8312668b4dbef394ce0e15000000000000000000000000000000000000000000000000000000ffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d290000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b843bdc3a87a05e77e07df9fe48928b3a34b134d0000000000000000000000000000000000000000000000000000000003567e0000000000000000000000000000000000000000000000000000000000030a32c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d2900000000000000000000000000000000000000000000000000000000000000000000000000000000000000004164ebcaf74670aa74c8d4f59de6157c0780f1bb0000000000000000000000000000000000000000000000000000000000c65d400000000000000000000000000000000000000000000000000000000000a7d8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d29000000000000000000000000000000000000000000000000000000000000000000000000000000000000000042eab64310e1d1c66b4d8af7c9c4ce253885eb8300000000000000000000000000000000000000000000000000000000009896800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d290000000000000000000000000000000000000000000000000000000000000001000000000000000000000000cbd466cb8709d9f6dd8312668b4dbef394ce0e15000000000000000000000000000000000000000000000000000000ffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d290000000000000000000000000000000000000000000000000000000000000001000000000000000000000000b843bdc3a87a05e77e07df9fe48928b3a34b134d0000000000000000000000000000000000000000000000000000000001312d00000000000000000000000000000000000000000000000000000000000112a880000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d2900000000000000000000000000000000000000000000000000000000000000010000000000000000000000004164ebcaf74670aa74c8d4f59de6157c0780f1bb00000000000000000000000000000000000000000000000000000000009896800000000000000000000000000000000000000000000000000000000000895440000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d2900000000000000000000000000000000000000000000000000000000000000010000000000000000000000005a10b1533c0f1f181dc8a428bf5eb58b08fc8d2c00000000000000000000000000000000000000000000000000000000008954400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d290000000000000000000000000000000000000000000000000000000000000002000000000000000000000000cbd466cb8709d9f6dd8312668b4dbef394ce0e15000000000000000000000000000000000000000000000000000000ffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d290000000000000000000000000000000000000000000000000000000000000002000000000000000000000000b843bdc3a87a05e77e07df9fe48928b3a34b134d000000000000000000000000000000000000000000000000000000000000044c00000000000000000000000000000000000000000000000000000000000000dc000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d29000000000000000000000000000000000000000000000000000000000000000200000000000000000000000083d364dbaf4e7018e0b87db3fab3d1d8535a6f1300000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d290000000000000000000000000000000000000000000000000000000000000003000000000000000000000000cbd466cb8709d9f6dd8312668b4dbef394ce0e15000000000000000000000000000000000000000000000000000000ffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d290000000000000000000000000000000000000000000000000000000000000003000000000000000000000000b843bdc3a87a05e77e07df9fe48928b3a34b134d0000000000000000000000000000000000000000000000000000000000005dc000000000000000000000000000000000000000000000000000000000000012c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + }, + { + "to": "0x17288dfc86205301064577b98B02b81017e6F79C", + "value": "0", + "data": "0xa2763d290000000000000000000000000000000000000000000000000000000000000003000000000000000000000000e8b890fea6e1e3915a337ed3136487f2f4f7e59d00000000000000000000000000000000000000000000000000000000000017700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000", + "contractMethod": null, + "contractInputsValues": null + } + ] +} diff --git a/docs/arc-verif-license.md b/docs/arc-verif-license.md new file mode 100644 index 000000000..ac598979c --- /dev/null +++ b/docs/arc-verif-license.md @@ -0,0 +1,81 @@ +# Arc verification licences: outstanding issues + +Every contract of the Arc deployment is source-verified on `explorer.arc.io`, but **21 of 24 carry the wrong licence**. This lists them and what is and is not fixable. + +## The problem + +Aave V4 sources declare `LicenseRef-BUSL`, which maps to Blockscout's `bsl_1_1`. The OpenZeppelin `TransparentUpgradeableProxy` used for the hub, both spokes and the treasury spoke declares `MIT`, which maps to `mit`. Neither is what the explorer records. + +The cause is Blockscout's cross-chain matcher. Most of these contracts were never submitted: `eth_bytecode_db` recognised byte-identical verified code from the Ethereum and Avalanche V4 deployments and imported the record, and an imported record carries `license_type: none` regardless of what the source says. + +**A verified record cannot be corrected by resubmitting.** Confirmed on `AaveV4ConfigEngine`: a corrective POST with `license_type=bsl_1_1` returned `{"message":"Smart-contract verification started"}` and the record stayed at `none`. Blockscout keeps the existing record and drops the new source, and it reports the worker failure only over its websocket channel, so the acknowledgement is misleading. + +So the 21 below need explorer-side intervention from Circle, not another submission from us. + +## Should be `bsl_1_1` (17) + +| Contract | Address | On explorer | Should be | +| --------------------------- | -------------------------------------------- | ----------- | --------- | +| `AaveOracle` | `0x2abd2B5C30D649273B3b762b0E1758BaC8F87cFE` | `none` | `bsl_1_1` | +| `AaveOracle` | `0x6ffE98F3422041236c19923EDB949F18A69e8A09` | `none` | `bsl_1_1` | +| `AaveV4ConfigEngine` | `0x0A3af96f72b1B52c9BB9778FcD839154c2599371` | `none` | `bsl_1_1` | +| `AccessManagerEnumerable` | `0x24761DB265998ba1D38E8a29031cF72C2CeF3A7D` | `none` | `bsl_1_1` | +| `AssetInterestRateStrategy` | `0xaa5b3bF9f16b634Eb1e0C1210bF8bB92b526e76D` | `none` | `bsl_1_1` | +| `ConfigPositionManager` | `0xa5Aa65Ae1c830d2ae10853CeEa42AE653adB3312` | `none` | `bsl_1_1` | +| `GiverPositionManager` | `0x01Da80Eef3004ebbF90b7637B1De7fF30fBc7cf1` | `none` | `bsl_1_1` | +| `HubConfigurator` | `0x419cF771E08d927b23F2F1691968C5135Ad8B659` | `none` | `bsl_1_1` | +| `HubInstance` | `0x3DcECcbD9b051638Bfa42200e7aAEC9Cc9621258` | `none` | `bsl_1_1` | +| `LiquidationLogic` | `0x818E84198224535FAeaEc1b583d3Ff6b812A5AF3` | `none` | `bsl_1_1` | +| `PositionManagerEngine` | `0x9fF7CCe79F0599D6Dd2620bc28763F7cE287D88e` | `none` | `bsl_1_1` | +| `SignatureGateway` | `0x0d36A4a21119BBBDe559d59002254171D976289f` | `none` | `bsl_1_1` | +| `SpokeConfigurator` | `0x102610d2A7Fd87A85ad8fdCfC78879be8Fd40576` | `none` | `bsl_1_1` | +| `SpokeEngine` | `0x8a121c22D558c91fc6819fEf1c738bb457Ad79F2` | `none` | `bsl_1_1` | +| `SpokeInstance` | `0xd4452Bd02C804245a41C75a5d0f6289C3ac787B6` | `none` | `bsl_1_1` | +| `SpokeInstance` | `0xf76b49F5911Ca3838a469563c0ffB07c8f91ba79` | `none` | `bsl_1_1` | +| `TakerPositionManager` | `0xe9fae1C386c6f45B1fb3C3Ef01aDE424DAd4bCcF` | `none` | `bsl_1_1` | + +## Should be `mit` (4) + +| Contract | Address | On explorer | Should be | +| ----------------------------- | -------------------------------------------- | ----------- | --------- | +| `TransparentUpgradeableProxy` | `0x17288dfc86205301064577b98B02b81017e6F79C` | `none` | `mit` | +| `TransparentUpgradeableProxy` | `0x4164EBCAF74670aa74C8D4F59de6157c0780F1bB` | `none` | `mit` | +| `TransparentUpgradeableProxy` | `0xB843bdC3a87A05E77E07Df9FE48928b3A34b134d` | `none` | `mit` | +| `TransparentUpgradeableProxy` | `0xcbd466CB8709D9f6dd8312668B4dbef394cE0e15` | `none` | `mit` | + +## Correct (3) + +These three were unverified when the deployment was checked, so submitting them with an explicit `license_type=bsl_1_1` worked — there was no imported record to lose to. + +| Contract | Address | On explorer | Should be | +| --------------------------- | -------------------------------------------- | ----------- | --------- | +| `AccessManagerEngine` | `0x060b87b8481eEa9AeE246289AA774CE977445031` | `bsl_1_1` | `bsl_1_1` | +| `HubEngine` | `0xc350fA6A315E783aB15D8F8bf6ECc49796587465` | `bsl_1_1` | `bsl_1_1` | +| `TokenizationSpokeDeployer` | `0x38a979fa226e075f31C056CaE7C922Af782B1b66` | `bsl_1_1` | `bsl_1_1` | + +## What to ask Circle for + +1. **Correct the licence on the 21 records above**, or allow re-verification to replace an existing record rather than dropping it. +2. **A Cloudflare Access service token.** Verification currently depends on a `CF_Authorization` browser cookie valid for 24 hours, which makes any bulk correction a race against expiry. Requested 25 August, still outstanding. + +## How to avoid this next time + +Submit **before** the matcher runs. The window is short and it is the whole game: a first status sweep of the deployment showed five contracts unverified that a sweep minutes later showed verified and licence-less. Anything not submitted in that window inherits `none` permanently. + +There is no way to submit through forge. `--verifier-url` carries no headers and the explorer needs the Access cookie, so the shape is forge for the payload and curl for the POST: + +```bash +forge verify-contract --show-standard-json-input > input.json +curl -X POST --cookie "CF_Authorization=$ARC_EXPLORER_TOKEN" \ + -F compiler_version=v0.8.28+commit.7893614a \ + -F license_type=bsl_1_1 \ + -F autodetect_constructor_args=false -F constructor_args= \ + -F 'files[0]=@input.json;type=application/json' \ + https://explorer.arc.io/api/v2/smart-contracts//verification/via/standard-input +``` + +Three gotchas that cost time here: + +- **`forge verify-contract` cannot run at all in this repo** until the `[etherscan]` table is fixed. Several aliases (`bnb`, `fantom`, and others behind them) are unknown to forge 1.7.1 and specify `chainId` rather than `chain`, so the table fails to resolve and every invocation errors out before doing anything — including `--show-standard-json-input`, which needs no network. The payloads here were generated with that table commented out. +- **Contracts that link libraries need `FOUNDRY_LIBRARIES` exported when the payload is generated**, or `settings.libraries` comes out empty and the submitted source compiles to different bytecode. `HubEngine` needs `TokenizationSpokeDeployer`; `AaveV4ConfigEngine` needs all four engine libraries; `SpokeInstance` needs `LiquidationLogic`. +- **`/Users/koga/Work/misc-scripts/verify-arc.sh` re-sources `.env` internally**, so an exported `FOUNDRY_LIBRARIES` is overwritten by whatever `.env` holds. For library-linked contracts, generate the payload and POST manually instead. Its bytecode pre-check also masks only immutable spans, so it warns spuriously on libraries, which embed their own address after a leading `PUSH20`, and on the config engine, whose link slots it does not mask. diff --git a/foundry.toml b/foundry.toml index 1411ebe9a..17a487758 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,7 +5,7 @@ out = 'out' libs = ['lib'] fs_permissions = [ { access = "read", path = "tests/helpers/mocks/JsonBindings.sol" }, - { access = "read", path = "./config" }, + { access = "read", path = "./scripts/config" }, { access = "read", path = "./out" }, { access = "read-write", path = "./output" } ] @@ -47,6 +47,12 @@ runs = 5000 [profile.ci.fuzz] runs = 10000 +# Deployment verification suites under scripts/verification, deliberately outside the default +# `test` path so they are not part of the protocol test suite. Run with +# `FOUNDRY_PROFILE=verification forge test` or `make verify-arc`. +[profile.verification] +test = 'scripts/verification' + [profile.gas] gas_snapshot_check = true test = 'tests/gas' @@ -74,6 +80,7 @@ zkevm = "${RPC_ZKEVM}" gnosis = "${RPC_GNOSIS}" bnb = "${RPC_BNB}" celo = "${RPC_CELO}" +arc = "${RPC_ARC}" anvil = "http://127.0.0.1:8545" [etherscan] 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/AaveV4ConfigureArc.s.sol b/scripts/config/AaveV4ConfigureArc.s.sol new file mode 100644 index 000000000..3f82c0d88 --- /dev/null +++ b/scripts/config/AaveV4ConfigureArc.s.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {ArcConfigInputs} from 'scripts/config/ArcConfigInputs.sol'; +import {ArcConfiguration} from 'scripts/config/ArcConfiguration.sol'; +import {ArcParameters} from 'scripts/config/ArcParameters.sol'; + +import {Script} from 'forge-std/Script.sol'; +import {console2 as console} from 'forge-std/console2.sol'; + +/// @title AaveV4ConfigureArc +/// @author Aave Labs +/// @notice Configures the Arc market with the ARFC risk parameters, then halts every asset it +/// listed on the Hub. +/// @dev Run after the deploy script and before `AaveV4RelinquishArc`. The launch set is whichever +/// assets have both addresses filled in in config/arc-config.json; the rest are skipped. See +/// `ArcParameters` and docs/arc-deploy.md. +contract AaveV4ConfigureArc is Script { + /// @notice Reads the inputs and configures the market as the broadcasting deployer. + function run() external { + ArcConfigInputs.Market memory market = ArcConfigInputs.readMarket(); + ArcConfigInputs.AssetInput[] memory assets = ArcConfigInputs.readAssets(); + ArcConfigInputs.Handover memory targets = ArcConfigInputs.readHandover(); + + for (uint256 i; i < assets.length; ++i) { + console.log('listing', ArcParameters.symbol(assets[i].key), assets[i].underlying); + } + console.log('tokenization spoke proxy admin owner', targets.proxyAdminOwner); + + vm.startBroadcast(); + (, address deployer, ) = vm.readCallers(); + ArcConfiguration.configure(market, deployer, assets, targets.proxyAdminOwner); + vm.stopBroadcast(); + } +} diff --git a/scripts/config/AaveV4RelinquishArc.s.sol b/scripts/config/AaveV4RelinquishArc.s.sol new file mode 100644 index 000000000..a8f001cd7 --- /dev/null +++ b/scripts/config/AaveV4RelinquishArc.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {ArcConfigInputs} from 'scripts/config/ArcConfigInputs.sol'; +import {ArcHandover} from 'scripts/config/ArcHandover.sol'; + +import {Script} from 'forge-std/Script.sol'; + +/// @title AaveV4RelinquishArc +/// @author Aave Labs +/// @notice Hands the Arc market over to the Security Council and verifies the deployer holds +/// nothing afterwards. +/// @dev Run last, after `AaveV4ConfigureArc`. 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. +contract AaveV4RelinquishArc is Script { + /// @notice Reads the inputs, hands the market over as the broadcasting deployer, then verifies. + function run() external { + ArcConfigInputs.Market memory market = ArcConfigInputs.readMarket(); + ArcConfigInputs.Handover memory targets = ArcConfigInputs.readHandover(); + + vm.startBroadcast(); + (, address deployer, ) = vm.readCallers(); + ArcHandover.relinquish(market, targets, deployer); + ArcHandover.verify(market, targets, deployer); + vm.stopBroadcast(); + } +} diff --git a/scripts/config/AaveV4VerifyArc.s.sol b/scripts/config/AaveV4VerifyArc.s.sol new file mode 100644 index 000000000..c0853a312 --- /dev/null +++ b/scripts/config/AaveV4VerifyArc.s.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {ArcConfigEngine} from 'scripts/config/ArcConfigEngine.sol'; +import {ArcConfigInputs} from 'scripts/config/ArcConfigInputs.sol'; +import {ArcParameters} from 'scripts/config/ArcParameters.sol'; +import {ArcVerification} from 'scripts/config/ArcVerification.sol'; + +import {Script} from 'forge-std/Script.sol'; +import {console2 as console} from 'forge-std/console2.sol'; + +/// @title AaveV4VerifyArc +/// @author Aave Labs +/// @notice Asserts that the deployed Arc market matches the deploy inputs and the ARFC parameters. +/// @dev Run last, after the deploy, the configuration and the handover. `run()` is `view`, so it +/// cannot broadcast anything: it either returns having found no discrepancy, or reverts naming the +/// first one. Point it at a market with: +/// +/// forge script scripts/config/AaveV4VerifyArc.s.sol --rpc-url arc +/// +/// It reads the same inputs the other scripts do, so it verifies against intent rather than against +/// a recorded snapshot of the result. +contract AaveV4VerifyArc is Script { + /// @notice Reads the inputs and asserts the market matches them. + function run() external view { + ArcConfigInputs.Market memory market = ArcConfigInputs.readMarket(); + ArcConfigInputs.Handover memory targets = ArcConfigInputs.readHandover(); + ArcConfigInputs.AssetInput[] memory assets = ArcConfigInputs.readAssets(); + address deployer = ArcConfigInputs.readDeployer(); + + ArcVerification.verify(market, targets, assets, deployer); + + console.log('hub', market.hub); + console.log('configEngine', ArcConfigEngine.predictedAddress()); + for (uint256 i; i < assets.length; ++i) { + console.log('verified', ArcParameters.symbol(assets[i].key), assets[i].underlying); + } + console.log('Arc market verified: deployment, handover and configuration all match'); + } +} diff --git a/scripts/config/ArcConfigEngine.sol b/scripts/config/ArcConfigEngine.sol new file mode 100644 index 000000000..74e138d2b --- /dev/null +++ b/scripts/config/ArcConfigEngine.sol @@ -0,0 +1,41 @@ +// 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 ArcConfigEngine +/// @author Aave Labs +/// @notice Deploys and locates the `AaveV4ConfigEngine` for the Arc market. +/// @dev The engine is what governance payloads delegatecall into to maintain the market after +/// launch. 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 ArcConfigEngine { + /// @dev Fixed salt for the Arc 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_ARC_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/ArcConfigInputs.sol b/scripts/config/ArcConfigInputs.sol new file mode 100644 index 000000000..647b252e4 --- /dev/null +++ b/scripts/config/ArcConfigInputs.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {ArcParameters} from 'scripts/config/ArcParameters.sol'; + +import {Vm} from 'forge-std/Vm.sol'; + +/// @title ArcConfigInputs +/// @author Aave Labs +/// @notice Reads the inputs shared by the Arc configuration and handover scripts: the addresses of +/// a deployed Arc market, the handover targets, and the asset to configure. +library ArcConfigInputs { + 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 = 'scripts/config/arc.json'; + /// @dev Configuration inputs. + string internal constant CONFIG_PATH = 'scripts/config/arc-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 Arc 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 signatureGateway; + address giverPositionManager; + address takerPositionManager; + address configPositionManager; + } + + /// @notice The addresses the deployer hands the market over to. + /// @dev hubConfiguratorAdmin and spokeConfiguratorAdmin hold the domain admin roles, so they must + /// be the address that executes configuration payloads, not the Safe that owns it. + struct Handover { + address accessManagerAdmin; + address hubAdmin; + address hubConfiguratorAdmin; + address spokeAdmin; + address spokeConfiguratorAdmin; + address proxyAdminOwner; + address treasurySpokeOwner; + address gatewayOwner; + address positionManagerOwner; + } + + /// @notice An asset in the launch set, with the two addresses configuration needs. + /// @dev key Selects the asset's row in `ArcParameters`. + /// @dev underlying The underlying token. + /// @dev priceSource The price feed. + struct AssetInput { + ArcParameters.Asset key; + address underlying; + address priceSource; + } + + /// @notice Thrown when the deploy inputs declare anything other than a single Hub. + error SingleHubExpected(); + /// @notice Thrown when an address has no code, which every configuration call on it would + /// revert on. + error NotAContract(string field); + /// @notice Thrown when no asset has both addresses resolved, leaving nothing to configure. + error EmptyLaunchSet(); + /// @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 Arc 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.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 These fields are unused at deploy time while `grantRoles` is false, and are applied by + /// the handover script instead. + /// @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.accessManagerAdmin = vm.parseJsonAddress(json, '.accessManagerAdmin'); + handover.hubAdmin = vm.parseJsonAddress(json, '.hubAdmin'); + handover.hubConfiguratorAdmin = vm.parseJsonAddress(json, '.hubConfiguratorAdmin'); + handover.spokeAdmin = vm.parseJsonAddress(json, '.spokeAdmin'); + handover.spokeConfiguratorAdmin = vm.parseJsonAddress(json, '.spokeConfiguratorAdmin'); + 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 launch set: every asset in `ArcParameters` whose underlying and price source + /// are both filled in. + /// @dev An asset with either address left at zero is skipped, so the launch set is whatever the + /// operator has resolved rather than an assumption baked into the scripts. Both addresses must be + /// live contracts: `HubConfigurator.addAsset` reads `decimals()` off the underlying, and + /// `AaveOracle.setReserveSource` checks the price source decimals and reads a price from it. + /// @return assets The assets to configure. + function readAssets() internal view returns (AssetInput[] memory assets) { + string memory json = vm.readFile(CONFIG_PATH); + + uint256 count; + AssetInput[] memory resolved = new AssetInput[](ArcParameters.assetCount()); + + for (uint256 i; i < ArcParameters.assetCount(); ++i) { + ArcParameters.Asset key = ArcParameters.Asset(i); + string memory path = string.concat('.assets.', ArcParameters.symbol(key)); + + address underlying = vm.parseJsonAddress(json, string.concat(path, '.underlying')); + address priceSource = vm.parseJsonAddress(json, string.concat(path, '.priceSource')); + if (underlying == address(0) || priceSource == address(0)) continue; + + require( + underlying.code.length > 0, + NotAContract(string.concat(ArcParameters.symbol(key), ' underlying')) + ); + require( + priceSource.code.length > 0, + NotAContract(string.concat(ArcParameters.symbol(key), ' price source')) + ); + + resolved[count++] = AssetInput({key: key, underlying: underlying, priceSource: priceSource}); + } + + require(count > 0, EmptyLaunchSet()); + + assets = new AssetInput[](count); + for (uint256 i; i < count; ++i) { + assets[i] = resolved[i]; + } + } + + /// @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 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 _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/ArcConfiguration.sol b/scripts/config/ArcConfiguration.sol new file mode 100644 index 000000000..316966e2c --- /dev/null +++ b/scripts/config/ArcConfiguration.sol @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {ArcConfigInputs} from 'scripts/config/ArcConfigInputs.sol'; +import {ArcParameters} from 'scripts/config/ArcParameters.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 ArcConfiguration +/// @author Aave Labs +/// @notice Applies the `ArcParameters` risk parameters to an Arc market, then halts every asset it +/// listed 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. +/// +/// Only the assets in the launch set are configured, so an asset whose token or price feed does not +/// exist yet is simply left out: see `ArcConfigInputs.readAssets`. The parameters for every asset +/// and spoke pair are in `ArcParameters` regardless of whether it launches. +/// +/// Caps are passed in whole token units. The Hub scales them by the underlying's own `decimals()`, +/// so a cap never has to be pre-scaled here and cannot be scaled wrongly. +library ArcConfiguration { + /// @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 the deployed spoke count does not match the parameter tables. + error SpokeCountMismatch(uint256 deployed, uint256 expected); + /// @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 Arc deploy script is what arranges that ownership. + error ManagerNotOwnedByDeployer(address manager, address owner); + /// @notice Thrown when an underlying does not have the decimals its asset is expected to have. + error UnexpectedDecimals(string symbol, uint8 actual, uint8 expected); + + /// @notice Grants the roles configuration needs, applies the per-spoke liquidation configs, lists + /// every asset in the launch set 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 Arc market. + /// @param deployer The address holding the AccessManager admin role. + /// @param assets The launch set. + /// @param proxyAdminOwner The owner of each tokenization spoke's ProxyAdmin. + function configure( + ArcConfigInputs.Market memory market, + address deployer, + ArcConfigInputs.AssetInput[] memory assets, + address proxyAdminOwner + ) internal { + require( + market.spokes.length == ArcParameters.spokeCount(), + SpokeCountMismatch(market.spokes.length, ArcParameters.spokeCount()) + ); + require(proxyAdminOwner != address(0), InvalidProxyAdminOwner()); + + requireNoDelays(market); + grantConfigurationRoles(market, deployer); + setLiquidationConfigs(market); + wirePositionManagers(market, deployer); + + for (uint256 i; i < assets.length; ++i) { + requireListable(assets[i]); + uint256 assetId = listAssetOnHub(market, assets[i]); + listAssetOnSpokes(market, assets[i], assetId); + deployTokenizationSpoke(market, assets[i], assetId, proxyAdminOwner); + IHubConfigurator(market.hubConfigurator).haltAsset(market.hub, assetId); + } + } + + /// @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. The config engine's `TokenizationSpokeDeployer` + /// takes it explicitly too as of #1321, so either route is safe now, but this path uses + /// `AaveV4TokenizationSpokeBatch` because configuration here runs as direct calls from an EOA + /// rather than as a delegatecalled payload. + /// @param market The deployed Arc 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, or the zero address when the asset has no published cap. + function deployTokenizationSpoke( + ArcConfigInputs.Market memory market, + ArcConfigInputs.AssetInput memory asset, + uint256 assetId, + address proxyAdminOwner + ) internal returns (address) { + uint40 addCap = ArcParameters.assetParams(asset.key).tokenizationAddCap; + if (addCap == 0) return address(0); + + // the share token name follows the underlying's own symbol, as on Ethereum and Avalanche + string memory assetSymbol = IERC20Metadata(asset.underlying).symbol(); + + AaveV4TokenizationSpokeBatch batch = new AaveV4TokenizationSpokeBatch({ + hub_: market.hub, + underlying_: asset.underlying, + proxyAdminOwner_: proxyAdminOwner, + shareName_: ArcParameters.tokenizationShareName(assetSymbol), + shareSymbol_: ArcParameters.tokenizationShareSymbol(assetSymbol), + salt_: keccak256(abi.encode(market.hub, asset.underlying, 'tokenizationSpoke')) + }); + address proxy = batch.getReport().tokenizationSpokeProxy; + + IHubConfigurator(market.hubConfigurator).addSpoke({ + hub: market.hub, + spoke: proxy, + assetId: assetId, + config: IHub.SpokeConfig({ + addCap: addCap, + drawCap: 0, + riskPremiumThreshold: ArcParameters.RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }) + }); + + return proxy; + } + + /// @notice Reverts unless the underlying has the decimals the asset is expected to have. + /// @dev `ArcConfigInputs` already rejects an address with no code, which is what a testnet address + /// looks like here; this covers a live contract that is not the intended token. + /// + /// It does not validate the price source, and nothing here does. `AaveOracle.setReserveSource` + /// checks its decimals are 8 and that it returns a price, which is all a wrong-but-live feed has + /// to do to pass: a capped adapter built against the wrong cap or base feed reports 8 decimals + /// like any other. The price source is verified off-chain, before it reaches this config. + /// @param asset The asset to check. + function requireListable(ArcConfigInputs.AssetInput memory asset) internal view { + uint8 expected = ArcParameters.underlyingDecimals(asset.key); + uint8 actual = IERC20Metadata(asset.underlying).decimals(); + + require( + actual == expected, + UnexpectedDecimals(ArcParameters.symbol(asset.key), actual, expected) + ); + } + + /// @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 Arc market. + function requireNoDelays(ArcConfigInputs.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. + /// @param market The deployed Arc market. + /// @param deployer The address holding the AccessManager admin role. + function grantConfigurationRoles( + ArcConfigInputs.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 each spoke's dynamic liquidation bonus configuration. + /// @dev Per spoke, not per reserve, so this runs once rather than per listed asset. + /// @param market The deployed Arc market. + function setLiquidationConfigs(ArcConfigInputs.Market memory market) internal { + for (uint256 i; i < market.spokes.length; ++i) { + ISpokeConfigurator(market.spokeConfigurator).updateLiquidationConfig( + market.spokes[i], + ArcParameters.liquidationConfig(ArcParameters.Spoke(i)) + ); + } + } + + /// @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 the Arc deploy script 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 Arc market. + /// @param deployer The address that owns the managers during configuration. + function wirePositionManagers(ArcConfigInputs.Market memory market, address deployer) internal { + address[4] memory managers = [ + market.giverPositionManager, + market.takerPositionManager, + market.configPositionManager, + 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 its rate curve and liquidity fee. + /// @param market The deployed Arc market. + /// @param asset The asset to list. + /// @return The Hub asset id of the listed asset. + function listAssetOnHub( + ArcConfigInputs.Market memory market, + ArcConfigInputs.AssetInput memory asset + ) internal returns (uint256) { + ArcParameters.AssetParams memory params = ArcParameters.assetParams(asset.key); + + IAssetInterestRateStrategy.InterestRateData memory irData = IAssetInterestRateStrategy + .InterestRateData({ + optimalUsageRatio: params.optimalUsageRatio, + baseDrawnRate: params.baseDrawnRate, + rateGrowthBeforeOptimal: params.rateGrowthBeforeOptimal, + rateGrowthAfterOptimal: params.rateGrowthAfterOptimal + }); + + return + IHubConfigurator(market.hubConfigurator).addAsset({ + hub: market.hub, + underlying: asset.underlying, + feeReceiver: market.treasurySpoke, + liquidityFee: params.liquidityFee, + irStrategy: market.irStrategy, + irData: abi.encode(irData) + }); + } + + /// @notice Registers the asset on every spoke the parameters list it on, and lists the reserve. + /// @param market The deployed Arc market. + /// @param asset The asset being listed. + /// @param assetId The Hub asset id of that asset. + function listAssetOnSpokes( + ArcConfigInputs.Market memory market, + ArcConfigInputs.AssetInput memory asset, + uint256 assetId + ) internal { + for (uint256 i; i < market.spokes.length; ++i) { + ArcParameters.ReserveParams memory params = ArcParameters.reserveParams( + asset.key, + ArcParameters.Spoke(i) + ); + if (!params.listed) continue; + + _addSpokeToAsset(market, market.spokes[i], assetId, params); + _addReserve(market, market.spokes[i], asset, assetId, params); + } + } + + function _addSpokeToAsset( + ArcConfigInputs.Market memory market, + address spoke, + uint256 assetId, + ArcParameters.ReserveParams memory params + ) private { + uint256[] memory assetIds = new uint256[](1); + assetIds[0] = assetId; + + IHub.SpokeConfig[] memory configs = new IHub.SpokeConfig[](1); + configs[0] = IHub.SpokeConfig({ + addCap: params.addCap, + drawCap: params.drawCap, + riskPremiumThreshold: ArcParameters.RISK_PREMIUM_THRESHOLD, + active: true, + halted: false + }); + + IHubConfigurator(market.hubConfigurator).addSpokeToAssets({ + hub: market.hub, + spoke: spoke, + assetIds: assetIds, + configs: configs + }); + } + + function _addReserve( + ArcConfigInputs.Market memory market, + address spoke, + ArcConfigInputs.AssetInput memory asset, + uint256 assetId, + ArcParameters.ReserveParams memory params + ) private { + ISpokeConfigurator(market.spokeConfigurator).addReserve({ + spoke: spoke, + hub: market.hub, + assetId: assetId, + priceSource: asset.priceSource, + config: ISpoke.ReserveConfig({ + collateralRisk: ArcParameters.COLLATERAL_RISK, + paused: false, + frozen: false, + borrowable: params.borrowable, + receiveSharesEnabled: ArcParameters.RECEIVE_SHARES_ENABLED + }), + dynamicConfig: ISpoke.DynamicReserveConfig({ + collateralFactor: params.collateralFactor, + maxLiquidationBonus: params.maxLiquidationBonus, + liquidationFee: params.liquidationFee + }) + }); + } +} diff --git a/scripts/config/ArcHandover.sol b/scripts/config/ArcHandover.sol new file mode 100644 index 000000000..ed74d3e08 --- /dev/null +++ b/scripts/config/ArcHandover.sol @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {ArcConfigInputs} from 'scripts/config/ArcConfigInputs.sol'; +import {Roles} from 'src/deployments/utils/libraries/Roles.sol'; +import {AaveV4AccessManagerRolesProcedure} from 'src/deployments/procedures/roles/AaveV4AccessManagerRolesProcedure.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 {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol'; +import {Ownable2Step} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; + +/// @title ArcHandover +/// @author Aave Labs +/// @notice Hands an Arc market over from the deployer to the Security Council, and proves the +/// deployer holds nothing afterwards. +/// @dev Runs after `ArcConfiguration`. The deployer only ever holds the AccessManager admin role, +/// so the handover is roles only: every ownership was set to the Council at deploy time. Roles reach +/// their end-state holders before the deployer drops its own, because revoking the AccessManager +/// admin role first would strand the rest. +library ArcHandover { + /// @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 contract is not owned by its end-state holder. + error UnexpectedOwner(address target, address owner); + /// @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, then drops the deployer's roles. + /// @param market The deployed Arc market. + /// @param targets The addresses to hand the market over to. + /// @param deployer The address currently holding the AccessManager admin role. + function relinquish( + ArcConfigInputs.Market memory market, + ArcConfigInputs.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 Arc market. + /// @param targets The addresses the market was handed over to. + /// @param deployer The address that ran the deployment and configuration. + function verify( + ArcConfigInputs.Market memory market, + ArcConfigInputs.Handover memory targets, + address deployer + ) internal view { + verifyDeployerHoldsNoRole(market, deployer); + verifyRoleHolders(market, targets); + verifyOwnerships(market, targets); + verifyAssetsHalted(market); + } + + /// @notice Grants the Hub, Spoke and configurator domain admin roles to their end-state holders. + /// @param market The deployed Arc market. + /// @param targets The addresses to hand the market over to. + function grantHandoverRoles( + ArcConfigInputs.Market memory market, + ArcConfigInputs.Handover memory targets + ) internal { + AaveV4HubRolesProcedure.grantHubAllRoles({ + accessManager: market.accessManager, + admin: targets.hubAdmin + }); + AaveV4SpokeRolesProcedure.grantSpokeAllRoles({ + accessManager: market.accessManager, + admin: targets.spokeAdmin + }); + AaveV4HubConfiguratorRolesProcedure.grantHubConfiguratorAllRoles({ + accessManager: market.accessManager, + admin: targets.hubConfiguratorAdmin + }); + AaveV4SpokeConfiguratorRolesProcedure.grantSpokeConfiguratorAllRoles({ + accessManager: market.accessManager, + admin: targets.spokeConfiguratorAdmin + }); + } + + /// @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 Arc market. + /// @param targets The addresses to hand the market over to. + function transferManagerOwnership( + ArcConfigInputs.Market memory market, + ArcConfigInputs.Handover memory targets + ) internal { + 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 Council. + /// @dev The AccessManager admin role goes last: without it the deployer cannot revoke anything. + /// @param market The deployed Arc market. + /// @param targets The addresses to hand the market over to. + /// @param deployer The address currently holding the AccessManager admin role. + function dropDeployerRoles( + ArcConfigInputs.Market memory market, + ArcConfigInputs.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.accessManagerAdmin, + adminToRemove: deployer + }); + } + + /// @notice Reverts if the deployer still holds any role defined in `Roles`. + /// @param market The deployed Arc market. + /// @param deployer The address that ran the deployment and configuration. + function verifyDeployerHoldsNoRole( + ArcConfigInputs.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. + /// @param market The deployed Arc market. + /// @param targets The addresses the market was handed over to. + function verifyRoleHolders( + ArcConfigInputs.Market memory market, + ArcConfigInputs.Handover memory targets + ) internal view { + _requireRole(market, Roles.ACCESS_MANAGER_ADMIN_ROLE, targets.accessManagerAdmin); + + _requireRole(market, Roles.HUB_CONFIGURATOR_ROLE, targets.hubAdmin); + _requireRole(market, Roles.HUB_FEE_MINTER_ROLE, targets.hubAdmin); + _requireRole(market, Roles.HUB_DEFICIT_ELIMINATOR_ROLE, targets.hubAdmin); + _requireRole(market, Roles.SPOKE_CONFIGURATOR_ROLE, targets.spokeAdmin); + _requireRole(market, Roles.SPOKE_USER_POSITION_UPDATER_ROLE, targets.spokeAdmin); + + _requireRole(market, Roles.HUB_CONFIGURATOR_DOMAIN_ADMIN_ROLE, targets.hubConfiguratorAdmin); + _requireRole( + market, + Roles.SPOKE_CONFIGURATOR_DOMAIN_ADMIN_ROLE, + targets.spokeConfiguratorAdmin + ); + + // 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); + } + + /// @notice Reverts unless every ownership sits with its end-state holder. + /// @dev Nothing is transferred during the handover: every owner below was set at deploy time and + /// is checked here to prove the deployer was never one of them. + /// @param market The deployed Arc market. + /// @param targets The addresses the market was handed over to. + function verifyOwnerships( + ArcConfigInputs.Market memory market, + ArcConfigInputs.Handover memory targets + ) internal view { + _requireOwner(ArcConfigInputs.proxyAdmin(market.hub), targets.proxyAdminOwner); + for (uint256 i; i < market.spokes.length; ++i) { + _requireOwner(ArcConfigInputs.proxyAdmin(market.spokes[i]), targets.proxyAdminOwner); + } + _requireOwner(ArcConfigInputs.proxyAdmin(market.treasurySpoke), targets.proxyAdminOwner); + _requireOwner(market.treasurySpoke, targets.treasurySpokeOwner); + + // catches the tokenization spokes, which are deployed during configuration rather than at + // deploy time and so are not in the deployment report + _verifyRegisteredSpokeProxyAdmins(market, targets); + + // the managers are Ownable2Step and the deployer owns them until the Council accepts, so either + // state is valid here; `ArcVerification` is what insists the transfer has completed + 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 Arc market. + function verifyAssetsHalted(ArcConfigInputs.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)); + } + } + } + + /// @dev Every spoke the Hub knows about is behind a transparent proxy, so its ProxyAdmin owner is + /// readable from the ERC-1967 admin slot. + function _verifyRegisteredSpokeProxyAdmins( + ArcConfigInputs.Market memory market, + ArcConfigInputs.Handover memory targets + ) private 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); + _requireOwner(ArcConfigInputs.proxyAdmin(spoke), targets.proxyAdminOwner); + } + } + } + + function _requireRole( + ArcConfigInputs.Market memory market, + uint64 role, + address account + ) private view { + (bool isMember, ) = IAccessManager(market.accessManager).hasRole(role, account); + require(isMember, RoleNotGranted(role, account)); + } + + /// @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; + + address pendingOwner = Ownable2Step(target).pendingOwner(); + require(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/ArcParameters.sol b/scripts/config/ArcParameters.sol new file mode 100644 index 000000000..de01dd59a --- /dev/null +++ b/scripts/config/ArcParameters.sol @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +/// @title ArcParameters +/// @author Aave Labs +/// @notice The Arc risk parameters published by LlamaRisk, transcribed from the ARFC. +/// @dev Source: https://governance.aave.com/t/arfc-deploy-aave-v4-on-arc/25170, LlamaRisk post #3 +/// of 19 June 2026, as revised by the Changelog in that post (cirBTC collateral factor to 78%, +/// USDC slope 1 to 4.10%, wETH slope 1 to 2.20%, cirBTC base to 0.25% / slope 2 to 60% / +/// liquidity fee to 20%). LlamaRisk describes the set as preliminary and revisable, so re-read the +/// post's Changelog before a real run: `tests/deployments/ArcParameters.t.sol` pins every value +/// here so a revision shows up as a failing test rather than a silent drift. +/// +/// All percentages are in BPS, written as `_`: 4.10% is `4_10` and 90.00% is +/// `90_00`. Values below 1% are written as plain BPS, since Solidity rejects a separator in a +/// leading-zero decimal literal. Caps are in whole token units, which is what the Hub compares +/// against after scaling by the asset's decimals. +library ArcParameters { + /// @notice The assets in the ARFC parameter tables. + enum Asset { + USDC, + EURC, + CIRBTC, + WETH + } + + /// @notice The borrowing spokes, in the order `config/arc.json` declares `spokeLabels`. + enum Spoke { + MAIN, + FOREX + } + + /// @notice Hub-side parameters, which are per asset rather than per reserve. + /// @dev optimalUsageRatio The optimal usage ratio, in BPS. + /// @dev baseDrawnRate The base drawn rate, in BPS. + /// @dev rateGrowthBeforeOptimal Slope 1, in BPS. + /// @dev rateGrowthAfterOptimal Slope 2, in BPS. + /// @dev liquidityFee The liquidity fee, in BPS. + /// @dev tokenizationAddCap The add cap of the asset's tokenization spoke, in whole token units. + struct AssetParams { + uint16 optimalUsageRatio; + uint32 baseDrawnRate; + uint32 rateGrowthBeforeOptimal; + uint32 rateGrowthAfterOptimal; + uint256 liquidityFee; + uint40 tokenizationAddCap; + } + + /// @notice Spoke-side parameters, which are per asset and spoke pair. + /// @dev listed False for pairs the ARFC does not list, such as cirBTC on the Forex spoke. + /// @dev collateralFactor The collateral factor, in BPS. + /// @dev maxLiquidationBonus The max liquidation bonus, in BPS, where `100_00` is a 0.00% bonus. + /// @dev liquidationFee The liquidation fee, in BPS. + /// @dev borrowable Whether the reserve is borrowable. + /// @dev addCap The add cap, in whole token units. + /// @dev drawCap The draw cap, in whole token units. + struct ReserveParams { + bool listed; + uint16 collateralFactor; + uint32 maxLiquidationBonus; + uint16 liquidationFee; + bool borrowable; + uint40 addCap; + uint40 drawCap; + } + + /// @dev `PercentageMath.PERCENTAGE_FACTOR`, the `maxLiquidationBonus` value meaning a 0.00% bonus. + uint32 internal constant NO_LIQUIDATION_BONUS = 100_00; + + /// @dev The hub name as it appears in tokenization spoke share tokens. Arc runs a single hub, + /// labelled `core` in the deploy inputs. + string internal constant HUB_NAME = 'Core'; + + /// @dev Values the ARFC does not specify, taken from the live Ethereum and Avalanche V4 CORE + /// markets rather than defaulted. Both agree on all three, read on-chain from their `main` and + /// `forex` spokes: + /// - `receiveSharesEnabled` is true, so a liquidator may take collateral shares. + /// - `riskPremiumThreshold` is 0. This is the strictest value, not a neutral one: `Hub` requires + /// `premiumShares <= drawnShares.percentMulUp(threshold)` unless it equals + /// `MAX_RISK_PREMIUM_THRESHOLD`, so 0 forbids any risk premium. Both markets also leave + /// `SPOKE_USER_POSITION_UPDATER_ROLE` unheld, so neither uses risk premium at all. + /// - `collateralRisk` is 0. + bool internal constant RECEIVE_SHARES_ENABLED = true; + uint24 internal constant RISK_PREMIUM_THRESHOLD = 0; + uint24 internal constant COLLATERAL_RISK = 0; + + /// @notice The number of assets in the parameter tables. + function assetCount() internal pure returns (uint256) { + return 4; + } + + /// @notice The number of borrowing spokes in the parameter tables. + function spokeCount() internal pure returns (uint256) { + return 2; + } + + /// @notice The symbol of an asset, used as its key in config/arc-config.json. + /// @param asset The asset. + /// @return The asset symbol. + function symbol(Asset asset) internal pure returns (string memory) { + if (asset == Asset.USDC) return 'USDC'; + if (asset == Asset.EURC) return 'EURC'; + if (asset == Asset.CIRBTC) return 'cirBTC'; + return 'wETH'; + } + + /// @notice The decimals the asset's underlying token is expected to have on Arc. + /// @dev Checked against the token at configuration time, to catch an address that has code but is + /// not the intended asset. Circle's Arc documentation publishes a testnet-only EURC address, and + /// that address has been mistaken for the mainnet deployment more than once, so the underlying is + /// not taken on trust. + /// + /// wETH's 18 is the conventional value, not one read off the token supplied for Arc, which is + /// itself unverified. If that token turns out to carry different decimals this check fires, which + /// is the intent. + /// @param asset The asset. + /// @return The expected decimals. + function underlyingDecimals(Asset asset) internal pure returns (uint8) { + if (asset == Asset.USDC) return 6; + if (asset == Asset.EURC) return 6; + if (asset == Asset.CIRBTC) return 8; + return 18; + } + + /// @notice The ERC20 name of a tokenization spoke share token. + /// @dev Matches the convention deployed on Ethereum and Avalanche V4, read off-chain from their + /// CORE tokenization spokes: `Wrapped Aave Core USDC` with symbol `waCoreUSDC`. The hub name is + /// in the string and the chain is not — Avalanche's USDC share token carries the same name and + /// symbol as Ethereum's, so collisions across chains are part of the convention rather than + /// something to work around. + /// + /// The asset segment is the underlying's own `symbol()`, casing untouched, which is why Ethereum + /// has `waCorecbBTC` and `waCoreUSDt` and Avalanche has `waCoreWETHe` and `waCoreBTCb`. Taking it + /// from the token rather than from a table here keeps that property and avoids guessing whether + /// Arc's wrapped ether calls itself `wETH` or `WETH`. + /// @param assetSymbol The underlying token's ERC20 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 ERC20 symbol of a tokenization spoke share token. + /// @param assetSymbol The underlying token's ERC20 symbol. + /// @return The share token symbol. + function tokenizationShareSymbol( + string memory assetSymbol + ) internal pure returns (string memory) { + return string.concat('wa', HUB_NAME, assetSymbol); + } + + /// @notice The Hub-side parameters of an asset: its rate curve, liquidity fee and tokenization cap. + /// @param asset The asset. + /// @return The Hub-side parameters. + function assetParams(Asset asset) internal pure returns (AssetParams memory) { + if (asset == Asset.USDC) { + return + AssetParams({ + optimalUsageRatio: 90_00, + baseDrawnRate: 0, + rateGrowthBeforeOptimal: 4_10, + rateGrowthAfterOptimal: 10_00, + liquidityFee: 10_00, + tokenizationAddCap: 10_000_000 + }); + } + if (asset == Asset.EURC) { + return + AssetParams({ + optimalUsageRatio: 90_00, + baseDrawnRate: 0, + rateGrowthBeforeOptimal: 5_50, + rateGrowthAfterOptimal: 50_00, + liquidityFee: 10_00, + tokenizationAddCap: 9_000_000 + }); + } + if (asset == Asset.CIRBTC) { + return + AssetParams({ + optimalUsageRatio: 80_00, + // 0.25%, which the `_` form cannot spell: a leading zero decimal + // literal may not take a separator + baseDrawnRate: 25, + rateGrowthBeforeOptimal: 4_00, + rateGrowthAfterOptimal: 60_00, + liquidityFee: 20_00, + tokenizationAddCap: 160 + }); + } + return + AssetParams({ + optimalUsageRatio: 90_00, + baseDrawnRate: 0, + rateGrowthBeforeOptimal: 2_20, + rateGrowthAfterOptimal: 8_00, + liquidityFee: 15_00, + tokenizationAddCap: 6_000 + }); + } + + /// @notice The Spoke-side parameters of an asset on a spoke. + /// @dev EURC on the Main spoke is borrowable at a 0.00% collateral factor, so the ARFC leaves its + /// bonus and fee blank; they are set to the neutral values a 0.00% collateral factor makes + /// unreachable. Pairs the ARFC does not list come back with `listed` false. + /// @param asset The asset. + /// @param spoke The spoke. + /// @return The Spoke-side parameters. + function reserveParams(Asset asset, Spoke spoke) internal pure returns (ReserveParams memory) { + if (spoke == Spoke.MAIN) { + if (asset == Asset.CIRBTC) { + return + ReserveParams({ + listed: true, + collateralFactor: 78_00, + maxLiquidationBonus: 107_22, + liquidationFee: 10_00, + borrowable: true, + addCap: 1_100, + drawCap: 220 + }); + } + if (asset == Asset.USDC) { + return + ReserveParams({ + listed: true, + collateralFactor: 78_00, + maxLiquidationBonus: 105_55, + liquidationFee: 10_00, + borrowable: true, + addCap: 56_000_000, + drawCap: 51_000_000 + }); + } + if (asset == Asset.WETH) { + return + ReserveParams({ + listed: true, + collateralFactor: 83_00, + maxLiquidationBonus: 105_55, + liquidationFee: 10_00, + borrowable: true, + addCap: 24_000, + drawCap: 4_800 + }); + } + return + ReserveParams({ + listed: true, + collateralFactor: 0, + maxLiquidationBonus: NO_LIQUIDATION_BONUS, + liquidationFee: 0, + borrowable: true, + addCap: 20_000_000, + drawCap: 18_000_000 + }); + } + + if (asset == Asset.EURC) { + return + ReserveParams({ + listed: true, + collateralFactor: 90_00, + maxLiquidationBonus: 102_00, + liquidationFee: 10_00, + borrowable: true, + addCap: 10_000_000, + drawCap: 9_000_000 + }); + } + if (asset == Asset.USDC) { + return + ReserveParams({ + listed: true, + collateralFactor: 90_00, + maxLiquidationBonus: 102_00, + liquidationFee: 10_00, + borrowable: true, + addCap: 13_000_000, + drawCap: 11_000_000 + }); + } + + // cirBTC and wETH are not listed on the Forex spoke + return + ReserveParams({ + listed: false, + collateralFactor: 0, + maxLiquidationBonus: NO_LIQUIDATION_BONUS, + liquidationFee: 0, + borrowable: false, + addCap: 0, + drawCap: 0 + }); + } + + /// @notice The dynamic liquidation bonus configuration of a spoke. + /// @param spoke The spoke. + /// @return The liquidation configuration. + function liquidationConfig(Spoke spoke) internal pure returns (ISpoke.LiquidationConfig memory) { + if (spoke == Spoke.MAIN) { + return + ISpoke.LiquidationConfig({ + targetHealthFactor: 1.24e18, + healthFactorForMaxBonus: 0.90e18, + liquidationBonusFactor: 90_00 + }); + } + return + ISpoke.LiquidationConfig({ + targetHealthFactor: 1.0442e18, + healthFactorForMaxBonus: 0.99e18, + liquidationBonusFactor: 100_00 + }); + } +} diff --git a/scripts/config/ArcVerification.sol b/scripts/config/ArcVerification.sol new file mode 100644 index 000000000..c47902bb5 --- /dev/null +++ b/scripts/config/ArcVerification.sol @@ -0,0 +1,370 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {ArcConfigEngine} from 'scripts/config/ArcConfigEngine.sol'; +import {ArcConfigInputs} from 'scripts/config/ArcConfigInputs.sol'; +import {ArcHandover} from 'scripts/config/ArcHandover.sol'; +import {ArcParameters} from 'scripts/config/ArcParameters.sol'; + +import {IAccessManaged} from 'src/dependencies/openzeppelin/IAccessManaged.sol'; +import {IAssetInterestRateStrategy} from 'src/hub/interfaces/IAssetInterestRateStrategy.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {IHubBase} from 'src/hub/interfaces/IHubBase.sol'; +import {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol'; +import {Ownable2Step} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; +import {IPositionManagerBase} from 'src/position-manager/interfaces/IPositionManagerBase.sol'; +import {IAaveOracle} from 'src/spoke/interfaces/IAaveOracle.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {ITokenizationSpoke} from 'src/spoke/interfaces/ITokenizationSpoke.sol'; + +/// @title ArcVerification +/// @author Aave Labs +/// @notice Asserts that a deployed, configured and handed-over Arc market matches what the deploy +/// inputs and `ArcParameters` say it should be. +/// @dev Read-only, and reverts on the first discrepancy. Reconstructs the expected state from the +/// same sources the deploy and configuration scripts read, then compares it against on-chain reads, +/// so it catches a step that was skipped, half-applied, or applied with the wrong values. +/// +/// Roles, ownership and the halt are delegated to `ArcHandover.verify`, which enumerates them; this +/// library adds deployment integrity and the risk parameters of every listed asset. +library ArcVerification { + /// @notice Thrown when a contract that should exist has no code. + error MissingCode(string what, address target); + /// @notice Thrown when an on-chain address does not match the expected one. + error UnexpectedAddress(string what, address actual, address expected); + /// @notice Thrown when an on-chain value does not match the expected one. + error UnexpectedUint(string what, uint256 actual, uint256 expected); + /// @notice Thrown when an on-chain flag does not match the expected one. + error UnexpectedFlag(string what, bool actual, bool expected); + /// @notice Thrown when a Spoke is registered for an asset the parameters do not list it for. + error SpokeUnexpectedlyListed(string symbol, uint256 spokeIndex); + /// @notice Thrown when a listed asset has no tokenization spoke registered on the Hub. + error TokenizationSpokeNotFound(string symbol); + /// @notice Thrown when a position manager is not active on a spoke. The configuration script does + /// this half, so it means configuration did not run or did not complete. + error PositionManagerNotActive(address manager, address spoke); + /// @notice Thrown when a spoke is not registered on a position manager. + error SpokeNotRegisteredOnManager(address manager, address spoke); + /// @notice Thrown when a position manager's ownership transfer to the Council has not been + /// accepted yet. The Council completes it with `acceptOwnership`. + error ManagerOwnershipNotAccepted(address manager, address owner, address pendingOwner); + + /// @notice Asserts the whole market: deployment, handover, and the configuration of every asset in + /// the launch set. + /// @param market The deployed Arc market. + /// @param targets The addresses the market was handed over to. + /// @param assets The launch set that was configured. + /// @param deployer The address that ran the deployment and configuration. + function verify( + ArcConfigInputs.Market memory market, + ArcConfigInputs.Handover memory targets, + ArcConfigInputs.AssetInput[] memory assets, + address deployer + ) internal view { + verifyDeployment(market); + ArcHandover.verify(market, targets, deployer); + verifyLiquidationConfigs(market); + verifyPositionManagers(market, targets); + + for (uint256 i; i < assets.length; ++i) { + verifyAsset(market, assets[i]); + } + } + + /// @notice Asserts every contract in the deployment report exists and is wired to the others. + /// @param market The deployed Arc market. + function verifyDeployment(ArcConfigInputs.Market memory market) internal view { + _requireCode('accessManager', market.accessManager); + _requireCode('hubConfigurator', market.hubConfigurator); + _requireCode('spokeConfigurator', market.spokeConfigurator); + _requireCode('treasurySpoke', market.treasurySpoke); + _requireCode('configEngine', ArcConfigEngine.predictedAddress()); + _requireCode('hub', market.hub); + _requireCode('irStrategy', market.irStrategy); + + _requireAddress( + 'hubConfigurator authority', + IAccessManaged(market.hubConfigurator).authority(), + market.accessManager + ); + _requireAddress( + 'spokeConfigurator authority', + IAccessManaged(market.spokeConfigurator).authority(), + market.accessManager + ); + _requireAddress('hub authority', IAccessManaged(market.hub).authority(), market.accessManager); + + for (uint256 i; i < market.spokes.length; ++i) { + _requireCode('spoke', market.spokes[i]); + _requireAddress( + 'spoke authority', + IAccessManaged(market.spokes[i]).authority(), + market.accessManager + ); + _requireCode('spoke oracle', ISpoke(market.spokes[i]).ORACLE()); + } + + if (market.signatureGateway != address(0)) { + _requireCode('signatureGateway', market.signatureGateway); + } + if (market.giverPositionManager != address(0)) { + _requireCode('giverPositionManager', market.giverPositionManager); + _requireCode('takerPositionManager', market.takerPositionManager); + _requireCode('configPositionManager', market.configPositionManager); + } + } + + /// @notice Asserts each spoke carries the dynamic liquidation bonus configuration for its role. + /// @param market The deployed Arc market. + function verifyLiquidationConfigs(ArcConfigInputs.Market memory market) internal view { + for (uint256 i; i < market.spokes.length; ++i) { + ISpoke.LiquidationConfig memory expected = ArcParameters.liquidationConfig( + ArcParameters.Spoke(i) + ); + ISpoke.LiquidationConfig memory actual = ISpoke(market.spokes[i]).getLiquidationConfig(); + + _requireUint('targetHealthFactor', actual.targetHealthFactor, expected.targetHealthFactor); + _requireUint( + 'healthFactorForMaxBonus', + actual.healthFactorForMaxBonus, + expected.healthFactorForMaxBonus + ); + _requireUint( + 'liquidationBonusFactor', + actual.liquidationBonusFactor, + expected.liquidationBonusFactor + ); + } + } + + /// @notice Asserts both halves of position manager wiring on every spoke. + /// @dev Configuration does both halves of the wiring, so a failure there means configuration did + /// not run or did not complete. Ownership is separate: the handover starts an `Ownable2Step` + /// transfer and only the Council can accept it, so `ManagerOwnershipNotAccepted` means the + /// Council's `acceptOwnership` bundle is still outstanding. + /// @param market The deployed Arc market. + /// @param targets The addresses the market was handed over to. + function verifyPositionManagers( + ArcConfigInputs.Market memory market, + ArcConfigInputs.Handover memory targets + ) internal view { + address[4] memory managers = [ + market.giverPositionManager, + market.takerPositionManager, + market.configPositionManager, + market.signatureGateway + ]; + + for (uint256 i; i < managers.length; ++i) { + if (managers[i] == address(0)) continue; + address expectedOwner = managers[i] == market.signatureGateway + ? targets.gatewayOwner + : targets.positionManagerOwner; + address owner = Ownable(managers[i]).owner(); + require( + owner == expectedOwner, + ManagerOwnershipNotAccepted(managers[i], owner, Ownable2Step(managers[i]).pendingOwner()) + ); + + for (uint256 j; j < market.spokes.length; ++j) { + require( + ISpoke(market.spokes[j]).isPositionManagerActive(managers[i]), + PositionManagerNotActive(managers[i], market.spokes[j]) + ); + require( + IPositionManagerBase(managers[i]).isSpokeRegistered(market.spokes[j]), + SpokeNotRegisteredOnManager(managers[i], market.spokes[j]) + ); + } + } + } + + /// @notice Asserts one asset's Hub configuration, its reserve on every spoke, and its tokenization + /// spoke. + /// @param market The deployed Arc market. + /// @param asset The asset to check. + function verifyAsset( + ArcConfigInputs.Market memory market, + ArcConfigInputs.AssetInput memory asset + ) internal view { + // reverts if the asset was never listed on the Hub + uint256 assetId = IHubBase(market.hub).getAssetId(asset.underlying); + + _verifyHubAsset(market, asset, assetId); + + for (uint256 i; i < market.spokes.length; ++i) { + _verifySpokeReserve(market, asset, assetId, i); + } + + _verifyTokenizationSpoke(market, asset, assetId); + } + + function _verifyHubAsset( + ArcConfigInputs.Market memory market, + ArcConfigInputs.AssetInput memory asset, + uint256 assetId + ) private view { + string memory name = ArcParameters.symbol(asset.key); + ArcParameters.AssetParams memory expected = ArcParameters.assetParams(asset.key); + + IHub.AssetConfig memory config = IHub(market.hub).getAssetConfig(assetId); + _requireAddress(string.concat(name, ' feeReceiver'), config.feeReceiver, market.treasurySpoke); + _requireAddress(string.concat(name, ' irStrategy'), config.irStrategy, market.irStrategy); + _requireUint(string.concat(name, ' liquidityFee'), config.liquidityFee, expected.liquidityFee); + + IAssetInterestRateStrategy.InterestRateData memory rate = IAssetInterestRateStrategy( + market.irStrategy + ).getInterestRateData(assetId); + _requireUint( + string.concat(name, ' optimalUsageRatio'), + rate.optimalUsageRatio, + expected.optimalUsageRatio + ); + _requireUint(string.concat(name, ' baseDrawnRate'), rate.baseDrawnRate, expected.baseDrawnRate); + _requireUint( + string.concat(name, ' slope1'), + rate.rateGrowthBeforeOptimal, + expected.rateGrowthBeforeOptimal + ); + _requireUint( + string.concat(name, ' slope2'), + rate.rateGrowthAfterOptimal, + expected.rateGrowthAfterOptimal + ); + } + + function _verifySpokeReserve( + ArcConfigInputs.Market memory market, + ArcConfigInputs.AssetInput memory asset, + uint256 assetId, + uint256 spokeIndex + ) private view { + address spoke = market.spokes[spokeIndex]; + string memory name = ArcParameters.symbol(asset.key); + ArcParameters.ReserveParams memory expected = ArcParameters.reserveParams( + asset.key, + ArcParameters.Spoke(spokeIndex) + ); + + if (!expected.listed) { + require( + !IHub(market.hub).isSpokeListed(assetId, spoke), + SpokeUnexpectedlyListed(name, spokeIndex) + ); + return; + } + + IHub.SpokeConfig memory spokeConfig = IHub(market.hub).getSpokeConfig(assetId, spoke); + _requireUint(string.concat(name, ' addCap'), spokeConfig.addCap, expected.addCap); + _requireUint(string.concat(name, ' drawCap'), spokeConfig.drawCap, expected.drawCap); + + uint256 reserveId = ISpoke(spoke).getReserveId(market.hub, assetId); + + ISpoke.DynamicReserveConfig memory dynamicConfig = ISpoke(spoke).getDynamicReserveConfig( + reserveId, + 0 + ); + _requireUint( + string.concat(name, ' collateralFactor'), + dynamicConfig.collateralFactor, + expected.collateralFactor + ); + _requireUint( + string.concat(name, ' maxLiquidationBonus'), + dynamicConfig.maxLiquidationBonus, + expected.maxLiquidationBonus + ); + _requireUint( + string.concat(name, ' liquidationFee'), + dynamicConfig.liquidationFee, + expected.liquidationFee + ); + + ISpoke.ReserveConfig memory reserveConfig = ISpoke(spoke).getReserveConfig(reserveId); + _requireFlag(string.concat(name, ' borrowable'), reserveConfig.borrowable, expected.borrowable); + _requireFlag( + string.concat(name, ' receiveSharesEnabled'), + reserveConfig.receiveSharesEnabled, + ArcParameters.RECEIVE_SHARES_ENABLED + ); + _requireUint( + string.concat(name, ' collateralRisk'), + reserveConfig.collateralRisk, + ArcParameters.COLLATERAL_RISK + ); + _requireUint( + string.concat(name, ' riskPremiumThreshold'), + spokeConfig.riskPremiumThreshold, + ArcParameters.RISK_PREMIUM_THRESHOLD + ); + _requireAddress( + string.concat(name, ' priceSource'), + IAaveOracle(ISpoke(spoke).ORACLE()).getReserveSource(reserveId), + asset.priceSource + ); + } + + /// @dev The tokenization spoke is the Hub-registered spoke for this asset that is neither a + /// borrowing spoke nor the treasury spoke; it is then identified positively by the asset it + /// tokenizes. + function _verifyTokenizationSpoke( + ArcConfigInputs.Market memory market, + ArcConfigInputs.AssetInput memory asset, + uint256 assetId + ) private view { + string memory name = ArcParameters.symbol(asset.key); + uint40 expectedAddCap = ArcParameters.assetParams(asset.key).tokenizationAddCap; + if (expectedAddCap == 0) return; + + uint256 spokeCount = IHub(market.hub).getSpokeCount(assetId); + + for (uint256 i; i < spokeCount; ++i) { + address spoke = IHub(market.hub).getSpokeAddress(assetId, i); + if (spoke == market.treasurySpoke || _isBorrowingSpoke(market, spoke)) continue; + + _requireAddress( + string.concat(name, ' tokenization underlying'), + ITokenizationSpoke(spoke).asset(), + asset.underlying + ); + _requireAddress( + string.concat(name, ' tokenization hub'), + ITokenizationSpoke(spoke).hub(), + market.hub + ); + + IHub.SpokeConfig memory config = IHub(market.hub).getSpokeConfig(assetId, spoke); + _requireUint(string.concat(name, ' tokenization addCap'), config.addCap, expectedAddCap); + _requireUint(string.concat(name, ' tokenization drawCap'), config.drawCap, 0); + return; + } + + revert TokenizationSpokeNotFound(name); + } + + function _isBorrowingSpoke( + ArcConfigInputs.Market memory market, + address spoke + ) private pure returns (bool) { + for (uint256 i; i < market.spokes.length; ++i) { + if (market.spokes[i] == spoke) return true; + } + return false; + } + + function _requireCode(string memory what, address target) private view { + require(target.code.length > 0, MissingCode(what, target)); + } + + function _requireAddress(string memory what, address actual, address expected) private pure { + require(actual == expected, UnexpectedAddress(what, actual, expected)); + } + + function _requireUint(string memory what, uint256 actual, uint256 expected) private pure { + require(actual == expected, UnexpectedUint(what, actual, expected)); + } + + function _requireFlag(string memory what, bool actual, bool expected) private pure { + require(actual == expected, UnexpectedFlag(what, actual, expected)); + } +} diff --git a/scripts/config/DeployArcConfigEngine.s.sol b/scripts/config/DeployArcConfigEngine.s.sol new file mode 100644 index 000000000..25506e54e --- /dev/null +++ b/scripts/config/DeployArcConfigEngine.s.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {ArcConfigEngine} from 'scripts/config/ArcConfigEngine.sol'; + +import {Script} from 'forge-std/Script.sol'; +import {console2 as console} from 'forge-std/console2.sol'; + +/// @title DeployArcConfigEngine +/// @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 DeployArcConfigEngine 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 = ArcConfigEngine.predictedAddress(); + + if (engine.code.length > 0) { + console.log('AaveV4ConfigEngine already deployed at', engine); + return engine; + } + + vm.startBroadcast(); + engine = ArcConfigEngine.deploy(); + vm.stopBroadcast(); + + console.log('AaveV4ConfigEngine deployed at', engine); + } +} diff --git a/scripts/config/arc-config.json b/scripts/config/arc-config.json new file mode 100644 index 000000000..e3d63b2c2 --- /dev/null +++ b/scripts/config/arc-config.json @@ -0,0 +1,22 @@ +{ + "deployer": "0x623f1C807fE1088439e129ebF3B9c92a63a0F5cD", + "report": "output/reports/deployments/arc.json", + "assets": { + "USDC": { + "underlying": "0x3600000000000000000000000000000000000000", + "priceSource": "0x729cFd10FC10A908aE9F9b35245cB6Ee14D44D6B" + }, + "EURC": { + "underlying": "0xbEf5f6d51CB62b58e6A8f77868681825C6fe21c1", + "priceSource": "0x1aBa23B4733aa96919C4434c1b9AC25bE9550d58" + }, + "cirBTC": { + "underlying": "0x171A4217b86A807A64eB94757Db6849fb4bDbAA0", + "priceSource": "0x7777547914e03BCbB04Ae034942765a0dbb26aE3" + }, + "wETH": { + "underlying": "0x128cC466B61f542da60c70e3aA11c10e19B84EDB", + "priceSource": "0x2c7Dc3567b3490f53A8d32625d766834dd023F60" + } + } +} diff --git a/scripts/config/arc.json b/scripts/config/arc.json new file mode 100644 index 000000000..baf9a971a --- /dev/null +++ b/scripts/config/arc.json @@ -0,0 +1,20 @@ +{ + "accessManagerAdmin": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "proxyAdminOwner": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "hubAdmin": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "hubConfiguratorAdmin": "0x8e79b0541122d3822eC93082cEB1ab03EDBc1Fd5", + "treasurySpokeOwner": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "spokeAdmin": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "spokeConfiguratorAdmin": "0x8e79b0541122d3822eC93082cEB1ab03EDBc1Fd5", + "gatewayOwner": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "positionManagerOwner": "0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9", + "nativeWrapper": "0x0000000000000000000000000000000000000000", + "deployNativeTokenGateway": false, + "deploySignatureGateway": true, + "deployPositionManagers": true, + "grantRoles": false, + "hubLabels": ["core"], + "spokeLabels": ["main", "forex"], + "spokeMaxReservesLimits": [], + "salt": "0x6a0678a1d5c9463ae84593b24e9d375399648f16b8a7af3482e942502da606a9" +} diff --git a/scripts/deploy/AaveV4DeployArc.s.sol b/scripts/deploy/AaveV4DeployArc.s.sol new file mode 100644 index 000000000..2299339b9 --- /dev/null +++ b/scripts/deploy/AaveV4DeployArc.s.sol @@ -0,0 +1,99 @@ +// 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 AaveV4DeployArc +/// @author Aave Labs +/// @notice Arc deploy script (chain id 5042). Deploy inputs are read from scripts/config/arc.json. +contract AaveV4DeployArc is AaveV4DeployBatchBaseScript { + /// @dev Path to the Arc deploy inputs, relative to the project root. + string internal constant DEPLOY_CONFIG_PATH = 'scripts/config/arc.json'; + + /// @dev Constructor. + constructor() AaveV4DeployBatchBaseScript('arc') {} + + /// @dev Arc mainnet. The Arc testnet chain id is 5042002. + function _expectedChainId() internal pure virtual override returns (uint256) { + return 5042; + } + + /// @dev Sets deploy-time ownership so that the deployer holds exactly what configuration needs + /// and nothing else. + /// + /// `proxyAdminOwner` and `treasurySpokeOwner` are restored from the config, which the base script + /// otherwise overwrites with the deployer whenever `grantRoles` is false. Configuration never + /// touches either, so the Council owns them from the deploy transaction onwards and the + /// TreasurySpoke never needs an `Ownable2Step` acceptance. + /// + /// `gatewayOwner` and `positionManagerOwner` go the other way: they are forced to the deployer, + /// because `PositionManagerBase.registerSpoke` is `onlyOwner` and configuration has to call it. + /// The config's values are the end-state targets, applied by `AaveV4RelinquishArc`. + /// + /// Both values are read before delegating: the base assigns `sanitizedInputs = inputs`, which for + /// two memory structs is a reference rather than a copy, so it mutates `inputs` in place and the + /// configured values are gone by the time it returns. + function _loadWarningsAndSanitizeInputs( + InputUtils.FullDeployInputs memory inputs, + address deployer + ) internal virtual override returns (InputUtils.FullDeployInputs memory) { + address configuredProxyAdminOwner = inputs.proxyAdminOwner; + address configuredTreasurySpokeOwner = inputs.treasurySpokeOwner; + + InputUtils.FullDeployInputs memory sanitizedInputs = super._loadWarningsAndSanitizeInputs( + inputs, + deployer + ); + + if (configuredProxyAdminOwner != address(0)) { + sanitizedInputs.proxyAdminOwner = configuredProxyAdminOwner; + } + if (configuredTreasurySpokeOwner != address(0)) { + sanitizedInputs.treasurySpokeOwner = configuredTreasurySpokeOwner; + } + + sanitizedInputs.gatewayOwner = deployer; + sanitizedInputs.positionManagerOwner = deployer; + + return sanitizedInputs; + } + + /// @dev Reads the FullDeployInputs from scripts/config/arc.json. + 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') + }); + } +} diff --git a/scripts/verification/ArcConfigureAndRelinquish.t.sol b/scripts/verification/ArcConfigureAndRelinquish.t.sol new file mode 100644 index 000000000..f66dbcd30 --- /dev/null +++ b/scripts/verification/ArcConfigureAndRelinquish.t.sol @@ -0,0 +1,621 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {AaveV4DeployArc} from 'scripts/deploy/AaveV4DeployArc.s.sol'; +import {ArcConfigInputs} from 'scripts/config/ArcConfigInputs.sol'; +import {ArcConfiguration} from 'scripts/config/ArcConfiguration.sol'; +import {ArcParameters} from 'scripts/config/ArcParameters.sol'; +import {ArcConfigEngine} from 'scripts/config/ArcConfigEngine.sol'; +import {ArcHandover} from 'scripts/config/ArcHandover.sol'; +import {ArcVerification} from 'scripts/config/ArcVerification.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 {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'; +import {IHubBase} from 'src/hub/interfaces/IHubBase.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 {ITokenizationSpoke} from 'src/spoke/interfaces/ITokenizationSpoke.sol'; + +import {Create2TestHelper} from 'tests/utils/Create2TestHelper.sol'; +import {MockPriceFeed} from 'tests/helpers/mocks/MockPriceFeed.sol'; +import {TestnetERC20} from 'tests/helpers/mocks/TestnetERC20.sol'; + +import {Test} from 'forge-std/Test.sol'; + +/// @title ArcConfigureAndRelinquishTest +/// @author Aave Labs +/// @notice Runs the whole Arc operator path on a local deployment: deploy from scripts/config/arc.json with +/// roles deferred, configure with the placeholder parameters, halt, then hand over. +contract ArcConfigureAndRelinquishTest is Test, Create2TestHelper, AaveV4DeployArc { + /// @dev Matches `DeployConstants.ORACLE_DECIMALS`, which `AaveOracle` enforces on price sources. + uint8 internal constant PRICE_FEED_DECIMALS = DeployConstants.ORACLE_DECIMALS; + /// @dev USDC and EURC are both 6 decimals on Arc. + uint8 internal constant STABLE_DECIMALS = 6; + + address internal _deployer = makeAddr('deployer'); + + ArcConfigInputs.Market internal _market; + ArcConfigInputs.Handover internal _targets; + /// @dev The launch set: USDC and EURC, the two assets listed on both spokes. + ArcConfigInputs.AssetInput[] internal _assets; + + function setUp() public { + _etchCreate2Factory(); + + _pushAsset(ArcParameters.Asset.USDC); + _pushAsset(ArcParameters.Asset.EURC); + + 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(); + + // the config engine is deployed on its own, at a deterministic address + ArcConfigEngine.deploy(); + + _market = _toMarket(report); + _targets = ArcConfigInputs.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.hubConfiguratorAdmin, 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 { + ArcConfiguration.requireNoDelays(_market); + } + + /// @notice Configuration lists every asset in the launch set and leaves each halted on the Hub. + function test_configureListsAndHalts() public { + _configure(); + + assertEq(IHub(_market.hub).getAssetCount(), _assets.length, 'asset count'); + + for (uint256 i; i < _assets.length; ++i) { + uint256 assetId = IHubBase(_market.hub).getAssetId(_assets[i].underlying); + + // the two borrowing spokes, plus the treasury spoke registered as fee receiver by addAsset, + // plus the asset's tokenization spoke + uint256 spokeCount = IHub(_market.hub).getSpokeCount(assetId); + assertEq(spokeCount, _market.spokes.length + 2, 'spoke count'); + + for (uint256 j; j < spokeCount; ++j) { + address spoke = IHub(_market.hub).getSpokeAddress(assetId, j); + assertTrue(IHub(_market.hub).getSpokeConfig(assetId, spoke).halted, 'spoke halted'); + } + } + } + + /// @notice Each reserve lands with the parameters its own asset and spoke pair specifies, so the + /// same asset carries a different collateral factor on Main than on Forex. + function test_configureAppliesPerPairParameters() public { + _configure(); + + for (uint256 i; i < _assets.length; ++i) { + uint256 assetId = IHubBase(_market.hub).getAssetId(_assets[i].underlying); + + for (uint256 s; s < _market.spokes.length; ++s) { + ArcParameters.ReserveParams memory expected = ArcParameters.reserveParams( + _assets[i].key, + ArcParameters.Spoke(s) + ); + if (!expected.listed) continue; + + ISpoke spoke = ISpoke(_market.spokes[s]); + uint256 reserveId = spoke.getReserveId(_market.hub, assetId); + + ISpoke.DynamicReserveConfig memory dynamicConfig = spoke.getDynamicReserveConfig( + reserveId, + 0 + ); + assertEq(dynamicConfig.collateralFactor, expected.collateralFactor, 'collateral factor'); + assertEq(dynamicConfig.maxLiquidationBonus, expected.maxLiquidationBonus, 'max bonus'); + assertEq(dynamicConfig.liquidationFee, expected.liquidationFee, 'liquidation fee'); + assertEq(spoke.getReserveConfig(reserveId).borrowable, expected.borrowable, 'borrowable'); + + IHub.SpokeConfig memory spokeConfig = IHub(_market.hub).getSpokeConfig( + assetId, + _market.spokes[s] + ); + assertEq(spokeConfig.addCap, expected.addCap, 'add cap'); + assertEq(spokeConfig.drawCap, expected.drawCap, 'draw cap'); + } + } + } + + /// @notice Each spoke gets its own dynamic liquidation bonus configuration, set once. + function test_configureAppliesLiquidationConfigPerSpoke() public { + _configure(); + + for (uint256 s; s < _market.spokes.length; ++s) { + ISpoke.LiquidationConfig memory expected = ArcParameters.liquidationConfig( + ArcParameters.Spoke(s) + ); + ISpoke.LiquidationConfig memory actual = ISpoke(_market.spokes[s]).getLiquidationConfig(); + + assertEq(actual.targetHealthFactor, expected.targetHealthFactor, 'target health factor'); + assertEq( + actual.healthFactorForMaxBonus, + expected.healthFactorForMaxBonus, + 'health factor for max bonus' + ); + assertEq( + actual.liquidationBonusFactor, + expected.liquidationBonusFactor, + 'liquidation bonus factor' + ); + } + } + + /// @notice Each listed asset gets a tokenization spoke, registered supply-only at its published + /// add cap, with its ProxyAdmin owned by the Security Council rather than the deployer. + function test_configureDeploysTokenizationSpokes() public { + _configure(); + + for (uint256 i; i < _assets.length; ++i) { + uint256 assetId = IHubBase(_market.hub).getAssetId(_assets[i].underlying); + address tokenizationSpoke = _tokenizationSpoke(assetId); + assertTrue(tokenizationSpoke != address(0), 'tokenization spoke deployed'); + + assertEq( + Ownable(ArcConfigInputs.proxyAdmin(tokenizationSpoke)).owner(), + _targets.proxyAdminOwner, + 'tokenization spoke proxy admin owner' + ); + assertNotEq( + Ownable(ArcConfigInputs.proxyAdmin(tokenizationSpoke)).owner(), + _deployer, + 'proxy admin owner is not the deployer' + ); + + ArcParameters.AssetParams memory params = ArcParameters.assetParams(_assets[i].key); + IHub.SpokeConfig memory config = IHub(_market.hub).getSpokeConfig(assetId, tokenizationSpoke); + assertEq(config.addCap, params.tokenizationAddCap, 'tokenization add cap'); + assertEq(config.drawCap, 0, 'tokenization draw cap is supply-only'); + + // the spoke is wired to the asset it tokenizes + assertEq(ITokenizationSpoke(tokenizationSpoke).hub(), _market.hub, 'tokenization hub'); + assertEq( + ITokenizationSpoke(tokenizationSpoke).asset(), + _assets[i].underlying, + 'tokenization underlying' + ); + string memory assetSymbol = ITokenizationSpoke(_assets[i].underlying).symbol(); + assertEq( + ITokenizationSpoke(tokenizationSpoke).symbol(), + ArcParameters.tokenizationShareSymbol(assetSymbol), + 'share symbol' + ); + assertEq( + ITokenizationSpoke(tokenizationSpoke).name(), + ArcParameters.tokenizationShareName(assetSymbol), + 'share name' + ); + } + } + + /// @notice Configuration rejects an underlying whose decimals do not match the asset, which is + /// what pointing at the wrong live contract looks like. + function test_configureRejectsWrongDecimals() public { + uint8 wrongDecimals = STABLE_DECIMALS + 1; + + ArcConfigInputs.AssetInput[] memory assets = new ArcConfigInputs.AssetInput[](1); + assets[0] = ArcConfigInputs.AssetInput({ + key: ArcParameters.Asset.USDC, + underlying: address(new TestnetERC20('USDC', 'USDC', wrongDecimals)), + priceSource: address(new MockPriceFeed(PRICE_FEED_DECIMALS, 'USDC / USD', 1e8)) + }); + + vm.expectRevert( + abi.encodeWithSelector( + ArcConfiguration.UnexpectedDecimals.selector, + 'USDC', + wrongDecimals, + STABLE_DECIMALS + ) + ); + this.externalConfigure(assets); + } + + /// @dev External entry point so the test can expect a revert from configuration. + function externalConfigure(ArcConfigInputs.AssetInput[] memory assets) external { + vm.startPrank(_deployer); + ArcConfiguration.configure(_market, _deployer, assets, _targets.proxyAdminOwner); + vm.stopPrank(); + } + + /// @notice After the full flow the standalone verification passes: deployment, handover and every + /// configured parameter. + function test_verificationPassesAfterFullFlow() public { + _configure(); + + vm.startPrank(_deployer); + ArcHandover.relinquish(_market, _targets, _deployer); + vm.stopPrank(); + + _councilAcceptsOwnership(); + + ArcVerification.verify(_market, _targets, _assets, _deployer); + } + + /// @notice Configuration wires every manager and gateway to every spoke, both halves, leaving the + /// Council nothing to do but accept ownership. + function test_configureWiresPositionManagersBothHalves() public { + _configure(); + + address[4] memory managers = _managers(); + for (uint256 i; i < managers.length; ++i) { + assertTrue(managers[i] != address(0), 'manager deployed'); + for (uint256 j; j < _market.spokes.length; ++j) { + assertTrue( + ISpoke(_market.spokes[j]).isPositionManagerActive(managers[i]), + 'manager active on spoke' + ); + assertTrue( + IPositionManagerBase(managers[i]).isSpokeRegistered(_market.spokes[j]), + 'spoke registered on manager' + ); + } + } + } + + /// @notice The handover offers manager ownership to the Council and leaves it pending, so the + /// Council's only outstanding action is `acceptOwnership`. + function test_relinquishLeavesManagerOwnershipPending() public { + _configure(); + + vm.startPrank(_deployer); + ArcHandover.relinquish(_market, _targets, _deployer); + vm.stopPrank(); + + address[4] memory managers = _managers(); + for (uint256 i; i < managers.length; ++i) { + assertEq(Ownable(managers[i]).owner(), _deployer, 'still deployer until accepted'); + assertEq( + Ownable2Step(managers[i]).pendingOwner(), + _targets.positionManagerOwner, + 'Council is pending owner' + ); + } + + _councilAcceptsOwnership(); + + for (uint256 i; i < managers.length; ++i) { + assertEq(Ownable(managers[i]).owner(), _targets.positionManagerOwner, 'Council owns'); + assertEq(Ownable2Step(managers[i]).pendingOwner(), address(0), 'nothing left pending'); + } + } + + /// @notice Verification fails while the Council has not accepted, so the step cannot be skipped. + function test_verificationFailsUntilCouncilAcceptsOwnership() public { + _configure(); + + vm.startPrank(_deployer); + ArcHandover.relinquish(_market, _targets, _deployer); + vm.stopPrank(); + + vm.expectRevert( + abi.encodeWithSelector( + ArcVerification.ManagerOwnershipNotAccepted.selector, + _market.giverPositionManager, + _deployer, + _targets.positionManagerOwner + ) + ); + this.externalVerifyAll(); + } + + /// @notice The verification fails if configuration never ran, so it cannot pass vacuously on an + /// unconfigured market. + function test_verificationFailsWithoutConfiguration() public { + vm.startPrank(_deployer); + ArcHandover.relinquish(_market, _targets, _deployer); + vm.stopPrank(); + + vm.expectRevert(); + this.externalVerifyAll(); + } + + /// @notice The verification catches a parameter that drifts from `ArcParameters` after the fact. + function test_verificationCatchesDriftedParameter() public { + _configure(); + + uint256 assetId = IHubBase(_market.hub).getAssetId(_assets[0].underlying); + ArcParameters.ReserveParams memory expected = ArcParameters.reserveParams( + _assets[0].key, + ArcParameters.Spoke.MAIN + ); + + vm.startPrank(_deployer); + ArcHandover.relinquish(_market, _targets, _deployer); + vm.stopPrank(); + + _councilAcceptsOwnership(); + + // the hub admin holds role 101 once the handover has run, so it can move caps on the Hub + vm.prank(_targets.hubAdmin); + IHub(_market.hub).updateSpokeConfig( + assetId, + _market.spokes[0], + IHub.SpokeConfig({ + addCap: expected.addCap - 1, + drawCap: expected.drawCap, + riskPremiumThreshold: 0, + active: true, + halted: true + }) + ); + + vm.expectRevert( + abi.encodeWithSelector( + ArcVerification.UnexpectedUint.selector, + string.concat(ArcParameters.symbol(_assets[0].key), ' addCap'), + uint256(expected.addCap - 1), + uint256(expected.addCap) + ) + ); + this.externalVerifyAll(); + } + + /// @dev External entry point so the test can expect a revert from the full verification. + function externalVerifyAll() external view { + ArcVerification.verify(_market, _targets, _assets, _deployer); + } + + /// @notice The handover verification catches a tokenization spoke proxy left with the deployer, + /// whatever route deployed it. + function test_verifyCatchesDeployerOwnedTokenizationProxy() public { + vm.startPrank(_deployer); + ArcConfiguration.configure(_market, _deployer, _assets, _deployer); + ArcHandover.relinquish(_market, _targets, _deployer); + vm.stopPrank(); + + uint256 assetId = IHubBase(_market.hub).getAssetId(_assets[0].underlying); + address proxyAdmin = ArcConfigInputs.proxyAdmin(_tokenizationSpoke(assetId)); + + vm.expectRevert( + abi.encodeWithSelector(ArcHandover.UnexpectedOwner.selector, proxyAdmin, _deployer) + ); + this.externalVerify(); + } + + /// @dev External entry point so the test can expect a revert from the verification. + function externalVerify() external view { + ArcHandover.verify(_market, _targets, _deployer); + } + + /// @notice The halt reaches the tokenization spokes too, which only works because they are + /// registered on the Hub before `haltAsset` runs. + function test_tokenizationSpokesAreHalted() public { + _configure(); + + for (uint256 i; i < _assets.length; ++i) { + uint256 assetId = IHubBase(_market.hub).getAssetId(_assets[i].underlying); + address tokenizationSpoke = _tokenizationSpoke(assetId); + assertTrue( + IHub(_market.hub).getSpokeConfig(assetId, tokenizationSpoke).halted, + 'tokenization spoke halted' + ); + } + } + + /// @notice An asset left out of the launch set is not listed, so a market can go live without the + /// assets whose token or price feed does not exist yet. + function test_assetsOutsideLaunchSetAreNotListed() public { + _configure(); + + assertEq(IHub(_market.hub).getAssetCount(), 2, 'only the launch set is listed'); + assertTrue( + ArcParameters.reserveParams(ArcParameters.Asset.WETH, ArcParameters.Spoke.MAIN).listed, + 'wETH parameters are still written' + ); + } + + /// @notice The handover moves every role and ownership off the deployer. + function test_relinquishLeavesDeployerWithNothing() public { + _configure(); + + vm.startPrank(_deployer); + ArcHandover.relinquish(_market, _targets, _deployer); + vm.stopPrank(); + + // reverts if anything is left behind + ArcHandover.verify(_market, _targets, _deployer); + + _assertHasRole(Roles.ACCESS_MANAGER_ADMIN_ROLE, _targets.accessManagerAdmin, true); + _assertHasRole(Roles.HUB_CONFIGURATOR_DOMAIN_ADMIN_ROLE, _targets.hubConfiguratorAdmin, true); + _assertHasRole( + Roles.SPOKE_CONFIGURATOR_DOMAIN_ADMIN_ROLE, + _targets.spokeConfiguratorAdmin, + true + ); + } + + /// @notice After the handover the deployer can no longer configure or grant. + function test_relinquishRevokesDeployerPowers() public { + _configure(); + + vm.startPrank(_deployer); + ArcHandover.relinquish(_market, _targets, _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 The deployer owns nothing at any point: every ownership is the Council's from deploy. + function test_deployerNeverHoldsOwnership() public { + _assertCouncilOwnsEverything(); + _configure(); + _assertCouncilOwnsEverything(); + + vm.startPrank(_deployer); + ArcHandover.relinquish(_market, _targets, _deployer); + vm.stopPrank(); + + _assertCouncilOwnsEverything(); + // no pending transfer left behind on the Ownable2Step treasury spoke + assertEq(Ownable2Step(_market.treasurySpoke).pendingOwner(), address(0), 'pending owner'); + } + + function _assertCouncilOwnsEverything() internal view { + assertEq( + Ownable(ArcConfigInputs.proxyAdmin(_market.hub)).owner(), + _targets.proxyAdminOwner, + 'hub proxy admin' + ); + for (uint256 i; i < _market.spokes.length; ++i) { + assertEq( + Ownable(ArcConfigInputs.proxyAdmin(_market.spokes[i])).owner(), + _targets.proxyAdminOwner, + 'spoke proxy admin' + ); + } + assertEq( + Ownable(ArcConfigInputs.proxyAdmin(_market.treasurySpoke)).owner(), + _targets.proxyAdminOwner, + 'treasury proxy admin' + ); + assertEq( + Ownable(_market.treasurySpoke).owner(), + _targets.treasurySpokeOwner, + 'treasury spoke owner' + ); + // the managers are deliberately deployer-owned until the Council accepts, so they are checked + // by test_relinquishLeavesManagerOwnershipPending rather than here + } + + function _configure() internal { + vm.startPrank(_deployer); + ArcConfiguration.configure(_market, _deployer, _assets, _targets.proxyAdminOwner); + vm.stopPrank(); + } + + /// @dev The whole of what the Council has to do: accept the ownership the handover offered it. + function _councilAcceptsOwnership() internal { + vm.prank(_targets.gatewayOwner); + Ownable2Step(_market.signatureGateway).acceptOwnership(); + + address[3] memory managers = [ + _market.giverPositionManager, + _market.takerPositionManager, + _market.configPositionManager + ]; + for (uint256 i; i < managers.length; ++i) { + vm.prank(_targets.positionManagerOwner); + Ownable2Step(managers[i]).acceptOwnership(); + } + } + + function _managers() internal view returns (address[4] memory) { + return + [ + _market.giverPositionManager, + _market.takerPositionManager, + _market.configPositionManager, + _market.signatureGateway + ]; + } + + /// @dev The tokenization spoke of an asset is the spoke registered for it that is neither a + /// borrowing spoke nor the treasury spoke. + function _tokenizationSpoke(uint256 assetId) internal view returns (address) { + uint256 spokeCount = IHub(_market.hub).getSpokeCount(assetId); + + for (uint256 i; i < spokeCount; ++i) { + address spoke = IHub(_market.hub).getSpokeAddress(assetId, i); + if (spoke == _market.treasurySpoke) continue; + if (spoke == _market.spokes[0] || spoke == _market.spokes[1]) continue; + return spoke; + } + return address(0); + } + + /// @dev Stands in a real ERC20 and an 8-decimal feed for an asset, which the configuration path + /// requires: the Hub reads `decimals()` and the oracle reads a price. + function _pushAsset(ArcParameters.Asset key) internal { + string memory name = ArcParameters.symbol(key); + _assets.push( + ArcConfigInputs.AssetInput({ + key: key, + underlying: address(new TestnetERC20(name, name, STABLE_DECIMALS)), + priceSource: address( + new MockPriceFeed(PRICE_FEED_DECIMALS, string.concat(name, ' / USD'), 1e8) + ) + }) + ); + } + + function _toMarket( + OrchestrationReports.FullDeploymentReport memory report + ) internal pure returns (ArcConfigInputs.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.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)))); + } + + /// @dev Tests are non-interactive. + function _executeUserPrompt() internal override {} +} diff --git a/scripts/verification/ArcDeployConfig.t.sol b/scripts/verification/ArcDeployConfig.t.sol new file mode 100644 index 000000000..2fd8ca82c --- /dev/null +++ b/scripts/verification/ArcDeployConfig.t.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {PostDeploymentVerificationBase} from 'tests/deployments/fork/PostDeploymentVerificationBase.t.sol'; +import {AaveV4DeployArc} from 'scripts/deploy/AaveV4DeployArc.s.sol'; +import {InputUtils} from 'src/deployments/utils/libraries/InputUtils.sol'; + +/// @title ArcDeployConfigTest +/// @author Aave Labs +/// @notice Checks that scripts/config/arc.json parses into the intended deploy inputs, and that a full +/// deployment driven by those inputs grants every role and ownership as configured. +contract ArcDeployConfigTest is PostDeploymentVerificationBase, AaveV4DeployArc { + address internal constant SECURITY_COUNCIL = 0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9; + address internal constant SECURITY_COUNCIL_EXECUTOR = 0x8e79b0541122d3822eC93082cEB1ab03EDBc1Fd5; + uint256 internal constant ARC_CHAIN_ID = 5042; + + function setUp() public override(PostDeploymentVerificationBase) { + _etchCreate2Factory(); + PostDeploymentVerificationBase.setUp(); + } + + function test_expectedChainId() public pure { + assertEq(_expectedChainId(), ARC_CHAIN_ID); + } + + function test_deployInputs() public view { + InputUtils.FullDeployInputs memory inputs = _getDeployInputs(); + + assertEq(inputs.accessManagerAdmin, SECURITY_COUNCIL, 'accessManagerAdmin'); + assertEq(inputs.proxyAdminOwner, SECURITY_COUNCIL, 'proxyAdminOwner'); + assertEq(inputs.hubAdmin, SECURITY_COUNCIL, 'hubAdmin'); + assertEq(inputs.treasurySpokeOwner, SECURITY_COUNCIL, 'treasurySpokeOwner'); + assertEq(inputs.spokeAdmin, SECURITY_COUNCIL, 'spokeAdmin'); + assertEq(inputs.gatewayOwner, SECURITY_COUNCIL, 'gatewayOwner'); + assertEq(inputs.positionManagerOwner, SECURITY_COUNCIL, 'positionManagerOwner'); + + // the domain admin roles end up with the executor, which delegatecalls the config engine + assertEq(inputs.hubConfiguratorAdmin, SECURITY_COUNCIL_EXECUTOR, 'hubConfiguratorAdmin'); + assertEq(inputs.spokeConfiguratorAdmin, SECURITY_COUNCIL_EXECUTOR, 'spokeConfiguratorAdmin'); + + // Arc pays gas in USDC, so there is no native wrapper to gateway + assertEq(inputs.nativeWrapper, address(0), 'nativeWrapper'); + assertFalse(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, 2, 'spoke count'); + assertEq(inputs.spokeLabels[0], 'main', 'first spoke label'); + assertEq(inputs.spokeLabels[1], 'forex', 'second spoke label'); + assertEq(inputs.spokeMaxReservesLimits.length, 0, 'spoke max reserves limits'); + assertTrue(inputs.salt != bytes32(0), 'salt'); + } + + function test_deployWithArcConfig() public { + InputUtils.FullDeployInputs memory sanitizedInputs = _loadWarningsAndSanitizeInputs( + _getDeployInputs(), + _deployer + ); + + // the deployer holds no ownership: only the AccessManager admin role is deferred to it + assertEq(sanitizedInputs.proxyAdminOwner, SECURITY_COUNCIL, 'proxyAdminOwner'); + assertEq(sanitizedInputs.treasurySpokeOwner, SECURITY_COUNCIL, 'treasurySpokeOwner'); + // the managers are deployer-owned during configuration, which needs onlyOwner access to + // registerSpoke; the handover moves them to the Council + assertEq(sanitizedInputs.gatewayOwner, _deployer, 'gatewayOwner'); + assertEq(sanitizedInputs.positionManagerOwner, _deployer, 'positionManagerOwner'); + + _deployWriteReportAndVerify(sanitizedInputs); + } + + /// @dev Tests are non-interactive. + function _executeUserPrompt() internal override {} +} diff --git a/scripts/verification/ArcLaunchSet.t.sol b/scripts/verification/ArcLaunchSet.t.sol new file mode 100644 index 000000000..4a90f8fa8 --- /dev/null +++ b/scripts/verification/ArcLaunchSet.t.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {ArcConfigInputs} from 'scripts/config/ArcConfigInputs.sol'; +import {ArcParameters} from 'scripts/config/ArcParameters.sol'; + +import {Test} from 'forge-std/Test.sol'; + +/// @title ArcLaunchSetTest +/// @author Aave Labs +/// @notice Checks that scripts/config/arc-config.json resolves to the launch set we intend: the assets whose +/// underlying and price source are both filled in, and no others. +/// @dev The configured addresses have no code locally, so each one is etched before reading. That +/// only stands in for the code-presence check; on Arc the addresses have to be real, and an +/// adapter that is not deployed yet makes `readAssets` revert rather than list the asset. +contract ArcLaunchSetTest is Test { + /// @dev Minimal runtime code, enough to satisfy the code-presence check. + bytes internal constant STUB_CODE = hex'60006000f3'; + + function setUp() public { + for (uint256 i; i < ArcParameters.assetCount(); ++i) { + (address underlying, address priceSource) = _configuredAddresses(ArcParameters.Asset(i)); + if (underlying != address(0)) vm.etch(underlying, STUB_CODE); + if (priceSource != address(0)) vm.etch(priceSource, STUB_CODE); + } + } + + /// @notice All four ARFC assets now have a token and an oracle, so all four are in the launch set. + function test_launchSetIsAllFourAssets() public view { + ArcConfigInputs.AssetInput[] memory assets = ArcConfigInputs.readAssets(); + + assertEq(assets.length, ArcParameters.assetCount(), 'launch set size'); + for (uint256 i; i < assets.length; ++i) { + assertEq(uint256(assets[i].key), i, 'asset order follows the enum'); + } + } + + /// @notice Every asset in the launch set carries both addresses from the config file. + function test_launchSetAddressesMatchConfig() public view { + ArcConfigInputs.AssetInput[] memory assets = ArcConfigInputs.readAssets(); + + for (uint256 i; i < assets.length; ++i) { + (address underlying, address priceSource) = _configuredAddresses(assets[i].key); + string memory name = ArcParameters.symbol(assets[i].key); + + assertEq(assets[i].underlying, underlying, string.concat(name, ' underlying')); + assertEq(assets[i].priceSource, priceSource, string.concat(name, ' price source')); + } + } + + /// @notice cirBTC prices off Arc's BTC/USD SVR proxy. The address the ARFC lists has no code on + /// Arc, as do all four of its oracle entries, so they are not used. + function test_cirBtcUsesBtcUsdSvrProxy() public view { + (address underlying, address priceSource) = _configuredAddresses(ArcParameters.Asset.CIRBTC); + + assertTrue(underlying != address(0), 'cirBTC token'); + assertEq(priceSource, 0x7777547914e03BCbB04Ae034942765a0dbb26aE3, 'cirBTC price source'); + } + + /// @notice The deployer is recorded, which step 5 needs to assert it holds nothing. + function test_deployerIsSet() public view { + assertEq(ArcConfigInputs.readDeployer(), 0x623f1C807fE1088439e129ebF3B9c92a63a0F5cD); + } + + /// @notice wETH carries the token address supplied for Arc and Arc's ETH/USD SVR proxy. + function test_wethIsConfigured() public view { + (address underlying, address priceSource) = _configuredAddresses(ArcParameters.Asset.WETH); + + assertEq(underlying, 0x128cC466B61f542da60c70e3aA11c10e19B84EDB, 'wETH token'); + assertEq(priceSource, 0x2c7Dc3567b3490f53A8d32625d766834dd023F60, 'wETH price source'); + } + + /// @notice An asset whose price source is not deployed yet is refused, not listed, so the config + /// can carry the adapter addresses before the adapters exist. + function test_undeployedPriceSourceIsRefused() public { + (, address priceSource) = _configuredAddresses(ArcParameters.Asset.USDC); + vm.etch(priceSource, ''); + + vm.expectRevert( + abi.encodeWithSelector(ArcConfigInputs.NotAContract.selector, 'USDC price source') + ); + this.externalReadAssets(); + } + + /// @dev External entry point so the test can expect a revert from reading the config. + function externalReadAssets() external view { + ArcConfigInputs.readAssets(); + } + + function _configuredAddresses( + ArcParameters.Asset asset + ) internal view returns (address underlying, address priceSource) { + string memory json = vm.readFile('scripts/config/arc-config.json'); + string memory path = string.concat('.assets.', ArcParameters.symbol(asset)); + + underlying = vm.parseJsonAddress(json, string.concat(path, '.underlying')); + priceSource = vm.parseJsonAddress(json, string.concat(path, '.priceSource')); + } +} diff --git a/scripts/verification/ArcParameters.t.sol b/scripts/verification/ArcParameters.t.sol new file mode 100644 index 000000000..2131390dd --- /dev/null +++ b/scripts/verification/ArcParameters.t.sol @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {ArcParameters} from 'scripts/config/ArcParameters.sol'; + +import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +import {Test} from 'forge-std/Test.sol'; + +/// @title ArcParametersTest +/// @author Aave Labs +/// @notice Pins every value in `ArcParameters` to the ARFC tables, so a governance revision shows +/// up as a failing test rather than a silent drift, and checks each one against the +/// validation the Hub and Spoke apply. +contract ArcParametersTest is Test { + using PercentageMath for uint256; + + /// @dev `AssetInterestRateStrategy` bounds. + uint256 internal constant MIN_OPTIMAL_RATIO = 1_00; + uint256 internal constant MAX_OPTIMAL_RATIO = 99_00; + uint256 internal constant MAX_ALLOWED_DRAWN_RATE = 1000_00; + /// @dev `Spoke.HEALTH_FACTOR_LIQUIDATION_THRESHOLD`. + uint256 internal constant HEALTH_FACTOR_LIQUIDATION_THRESHOLD = 1e18; + + function test_interestRateCurves() public pure { + _assertAssetParams(ArcParameters.Asset.USDC, 90_00, 0, 4_10, 10_00, 10_00, 10_000_000); + _assertAssetParams(ArcParameters.Asset.EURC, 90_00, 0, 5_50, 50_00, 10_00, 9_000_000); + // base rate is 25 BPS, or 0.25% + _assertAssetParams(ArcParameters.Asset.CIRBTC, 80_00, 25, 4_00, 60_00, 20_00, 160); + _assertAssetParams(ArcParameters.Asset.WETH, 90_00, 0, 2_20, 8_00, 15_00, 6_000); + } + + function test_mainSpokeReserves() public pure { + ArcParameters.Spoke main = ArcParameters.Spoke.MAIN; + + _assertReserveParams(ArcParameters.Asset.CIRBTC, main, 78_00, 107_22, 10_00, 1_100, 220); + _assertReserveParams( + ArcParameters.Asset.USDC, + main, + 78_00, + 105_55, + 10_00, + 56_000_000, + 51_000_000 + ); + _assertReserveParams(ArcParameters.Asset.WETH, main, 83_00, 105_55, 10_00, 24_000, 4_800); + // EURC is borrowable on Main at a 0.00% collateral factor, so no bonus or fee is published + _assertReserveParams(ArcParameters.Asset.EURC, main, 0, 100_00, 0, 20_000_000, 18_000_000); + } + + function test_forexSpokeReserves() public pure { + ArcParameters.Spoke forex = ArcParameters.Spoke.FOREX; + + _assertReserveParams( + ArcParameters.Asset.EURC, + forex, + 90_00, + 102_00, + 10_00, + 10_000_000, + 9_000_000 + ); + _assertReserveParams( + ArcParameters.Asset.USDC, + forex, + 90_00, + 102_00, + 10_00, + 13_000_000, + 11_000_000 + ); + + // the ARFC lists neither on the Forex spoke + assertFalse(ArcParameters.reserveParams(ArcParameters.Asset.CIRBTC, forex).listed, 'cirBTC'); + assertFalse(ArcParameters.reserveParams(ArcParameters.Asset.WETH, forex).listed, 'wETH'); + } + + /// @notice Six of the eight asset and spoke pairs are listed, spread three on Main to two on Forex + /// plus EURC on Main. + function test_listedPairCount() public pure { + uint256 listed; + for (uint256 a; a < ArcParameters.assetCount(); ++a) { + for (uint256 s; s < ArcParameters.spokeCount(); ++s) { + if (ArcParameters.reserveParams(ArcParameters.Asset(a), ArcParameters.Spoke(s)).listed) { + ++listed; + } + } + } + assertEq(listed, 6, 'listed pairs'); + } + + function test_liquidationConfigs() public pure { + ISpoke.LiquidationConfig memory main = ArcParameters.liquidationConfig( + ArcParameters.Spoke.MAIN + ); + assertEq(main.targetHealthFactor, 1.24e18, 'main target health factor'); + assertEq(main.healthFactorForMaxBonus, 0.90e18, 'main health factor for max bonus'); + assertEq(main.liquidationBonusFactor, 90_00, 'main liquidation bonus factor'); + + ISpoke.LiquidationConfig memory forex = ArcParameters.liquidationConfig( + ArcParameters.Spoke.FOREX + ); + assertEq(forex.targetHealthFactor, 1.0442e18, 'forex target health factor'); + assertEq(forex.healthFactorForMaxBonus, 0.99e18, 'forex health factor for max bonus'); + assertEq(forex.liquidationBonusFactor, 100_00, 'forex liquidation bonus factor'); + } + + /// @notice Every rate curve passes the bounds `AssetInterestRateStrategy` enforces. + function test_rateCurvesAreValid() public pure { + for (uint256 a; a < ArcParameters.assetCount(); ++a) { + ArcParameters.AssetParams memory params = ArcParameters.assetParams(ArcParameters.Asset(a)); + + assertGe(params.optimalUsageRatio, MIN_OPTIMAL_RATIO, 'optimal usage ratio floor'); + assertLe(params.optimalUsageRatio, MAX_OPTIMAL_RATIO, 'optimal usage ratio ceiling'); + assertLe( + uint256(params.baseDrawnRate) + + params.rateGrowthBeforeOptimal + + params.rateGrowthAfterOptimal, + MAX_ALLOWED_DRAWN_RATE, + 'max drawn rate' + ); + assertLe(params.liquidityFee, PercentageMath.PERCENTAGE_FACTOR, 'liquidity fee'); + } + } + + /// @notice Every listed pair passes `Spoke._validateDynamicReserveConfig`, whose collateral factor + /// and max liquidation bonus check is the one a hand-entered parameter set can trip. + function test_reserveParamsAreValid() public pure { + for (uint256 a; a < ArcParameters.assetCount(); ++a) { + for (uint256 s; s < ArcParameters.spokeCount(); ++s) { + ArcParameters.ReserveParams memory params = ArcParameters.reserveParams( + ArcParameters.Asset(a), + ArcParameters.Spoke(s) + ); + if (!params.listed) continue; + + assertLt( + params.collateralFactor, + PercentageMath.PERCENTAGE_FACTOR, + 'collateral factor ceiling' + ); + assertGe( + params.maxLiquidationBonus, + PercentageMath.PERCENTAGE_FACTOR, + 'max liquidation bonus floor' + ); + assertLt( + uint256(params.maxLiquidationBonus).percentMulUp(params.collateralFactor), + PercentageMath.PERCENTAGE_FACTOR, + 'collateral factor against max liquidation bonus' + ); + assertLe(params.liquidationFee, PercentageMath.PERCENTAGE_FACTOR, 'liquidation fee'); + } + } + } + + /// @notice Every liquidation config passes `Spoke.updateLiquidationConfig`. + function test_liquidationConfigsAreValid() public pure { + for (uint256 s; s < ArcParameters.spokeCount(); ++s) { + ISpoke.LiquidationConfig memory config = ArcParameters.liquidationConfig( + ArcParameters.Spoke(s) + ); + + assertGe( + config.targetHealthFactor, + HEALTH_FACTOR_LIQUIDATION_THRESHOLD, + 'target health factor' + ); + assertLt( + config.healthFactorForMaxBonus, + HEALTH_FACTOR_LIQUIDATION_THRESHOLD, + 'health factor for max bonus' + ); + assertLe( + config.liquidationBonusFactor, + PercentageMath.PERCENTAGE_FACTOR, + 'liquidation bonus factor' + ); + } + } + + /// @notice Every draw cap is at or below its add cap. + function test_drawCapsWithinAddCaps() public pure { + for (uint256 a; a < ArcParameters.assetCount(); ++a) { + for (uint256 s; s < ArcParameters.spokeCount(); ++s) { + ArcParameters.ReserveParams memory params = ArcParameters.reserveParams( + ArcParameters.Asset(a), + ArcParameters.Spoke(s) + ); + if (!params.listed) continue; + assertLe(params.drawCap, params.addCap, 'draw cap within add cap'); + } + } + } + + /// @notice USDC, EURC and cirBTC decimals are the values observed on Arc mainnet. wETH's 18 is the + /// conventional value, since no wrapped ETH is deployed there. + function test_underlyingDecimals() public pure { + assertEq(ArcParameters.underlyingDecimals(ArcParameters.Asset.USDC), 6, 'USDC'); + assertEq(ArcParameters.underlyingDecimals(ArcParameters.Asset.EURC), 6, 'EURC'); + assertEq(ArcParameters.underlyingDecimals(ArcParameters.Asset.CIRBTC), 8, 'cirBTC'); + assertEq(ArcParameters.underlyingDecimals(ArcParameters.Asset.WETH), 18, 'wETH'); + } + + /// @notice Share token naming matches the convention read off the Ethereum and Avalanche V4 CORE + /// tokenization spokes: `Wrapped Aave Core USDC` / `waCoreUSDC`, no chain marker, and the + /// underlying's own symbol casing preserved. + function test_tokenizationShareNaming() public pure { + assertEq(ArcParameters.tokenizationShareName('USDC'), 'Wrapped Aave Core USDC'); + assertEq(ArcParameters.tokenizationShareSymbol('USDC'), 'waCoreUSDC'); + + assertEq(ArcParameters.tokenizationShareName('EURC'), 'Wrapped Aave Core EURC'); + assertEq(ArcParameters.tokenizationShareSymbol('EURC'), 'waCoreEURC'); + + // casing comes straight from the token, as in Ethereum's waCorecbBTC and Avalanche's waCoreBTCb + assertEq(ArcParameters.tokenizationShareName('cirBTC'), 'Wrapped Aave Core cirBTC'); + assertEq(ArcParameters.tokenizationShareSymbol('cirBTC'), 'waCorecirBTC'); + + assertEq(ArcParameters.tokenizationShareName('wETH'), 'Wrapped Aave Core wETH'); + assertEq(ArcParameters.tokenizationShareSymbol('wETH'), 'waCorewETH'); + } + + function test_symbolsMatchConfigKeys() public pure { + assertEq(ArcParameters.symbol(ArcParameters.Asset.USDC), 'USDC'); + assertEq(ArcParameters.symbol(ArcParameters.Asset.EURC), 'EURC'); + assertEq(ArcParameters.symbol(ArcParameters.Asset.CIRBTC), 'cirBTC'); + assertEq(ArcParameters.symbol(ArcParameters.Asset.WETH), 'wETH'); + } + + function _assertAssetParams( + ArcParameters.Asset asset, + uint16 optimalUsageRatio, + uint32 baseDrawnRate, + uint32 slope1, + uint32 slope2, + uint256 liquidityFee, + uint256 tokenizationAddCap + ) internal pure { + ArcParameters.AssetParams memory params = ArcParameters.assetParams(asset); + string memory name = ArcParameters.symbol(asset); + + assertEq(params.optimalUsageRatio, optimalUsageRatio, string.concat(name, ' Uoptimal')); + assertEq(params.baseDrawnRate, baseDrawnRate, string.concat(name, ' base')); + assertEq(params.rateGrowthBeforeOptimal, slope1, string.concat(name, ' slope 1')); + assertEq(params.rateGrowthAfterOptimal, slope2, string.concat(name, ' slope 2')); + assertEq(params.liquidityFee, liquidityFee, string.concat(name, ' liquidity fee')); + assertEq( + params.tokenizationAddCap, + tokenizationAddCap, + string.concat(name, ' tokenization add cap') + ); + } + + function _assertReserveParams( + ArcParameters.Asset asset, + ArcParameters.Spoke spoke, + uint16 collateralFactor, + uint32 maxLiquidationBonus, + uint16 liquidationFee, + uint40 addCap, + uint40 drawCap + ) internal pure { + ArcParameters.ReserveParams memory params = ArcParameters.reserveParams(asset, spoke); + string memory name = ArcParameters.symbol(asset); + + assertTrue(params.listed, string.concat(name, ' listed')); + assertTrue(params.borrowable, string.concat(name, ' borrowable')); + assertEq(params.collateralFactor, collateralFactor, string.concat(name, ' collateral factor')); + assertEq(params.maxLiquidationBonus, maxLiquidationBonus, string.concat(name, ' max bonus')); + assertEq(params.liquidationFee, liquidationFee, string.concat(name, ' liquidation fee')); + assertEq(params.addCap, addCap, string.concat(name, ' add cap')); + assertEq(params.drawCap, drawCap, string.concat(name, ' draw cap')); + } +} 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/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; + } +}