diff --git a/script/deprecation/README.md b/script/deprecation/README.md index e8b22721..d1f5d8f2 100644 --- a/script/deprecation/README.md +++ b/script/deprecation/README.md @@ -34,7 +34,14 @@ The admin role revocation must come after all Folio calls that require admin per bash script/deprecation/generate-deprecation-proposals.sh ``` -This generates one Safe Transaction Builder JSON per DTF in `script/deprecation/proposals/`. +This generates one Safe Transaction Builder JSON per DTF in `script/deprecation/proposals/`. Each file is a +single `propose()` call carrying every action for that DTF — one proposal per DTF, no follow-up round. + +### Folio Version Caveats + +The action set above targets Folio `4.0.0`+ (verified against `4.0.0` and `5.0.0` deployments). Folio `1.0.0` +has no `deprecateFolio()`; it exposes `killFolio()` (`0x60913997`) / `isKilled()` instead, so a generated +proposal would revert on execution. Check `folio.version()` before generating. ### Adding a New DTF @@ -121,33 +128,71 @@ cast call "state(uint256)(uint8)" --rpc-url ## Fork Tests -Two test suites validate the deprecation flow against live onchain state: +Each DTF is covered twice, once on either side of submission. Both suites run the generated JSON, so the +file you upload to the Safe is the file under test. Shared setup and post-conditions live in +`test/base/BaseDeprecationForkTest.sol`; the suites differ only in where the actions come from and who +executes them. -### Direct Simulation (`test/DeprecationFork.t.sol`) +### 1. Before Submission (`test/DeprecationJsonFork.t.sol`) -Pranks as the owner timelock to execute all deprecation steps directly on a fork. +Decodes the `propose()` calldata out of the generated JSON, checks the action set against live onchain +state, then executes those exact actions as the owner timelock. Run this before uploading to the Safe. ```bash -FORK_RPC_MAINNET="" FORK_RPC_BASE="" \ - forge test --match-contract DeprecationForkTest --evm-version cancun -vv +FORK_RPC_MAINNET="" \ + forge test --match-contract DeprecationJsonFork --evm-version cancun -vv ``` -### Onchain Proposal Execution (`test/DeprecationProposalFork.t.sol`) +Beyond the shared post-conditions it asserts, against the JSON itself: the transaction targets the owner +governor on the right chain, every `revokeRole` names a role the account currently holds, the +`DEFAULT_ADMIN_ROLE` revoke is the last Folio action, and the ProxyAdmin `renounceOwnership()` closes the +proposal. Pin `createSelectFork` to a recent block, since the DTF is still live. + +### 2. Once Submitted (`test/DeprecationProposalFork.t.sol`) -Retrieves an actual queued proposal onchain, warps past the timelock ETA, and executes it through the Governor. +Both entrypoints derive the proposal id from the actions via `hashProposal`, so nothing is hardcoded — if +the id resolves to a real proposal, the onchain payload matches the JSON byte for byte. ```bash FORK_RPC_MAINNET="" \ - forge test --match-contract DeprecationProposalFork --evm-version cancun -vv + forge test --match-contract "DeprecationProposalFork|DeprecationLifecycleFork" --evm-version cancun -vv +``` + +**While still `Pending`** — `DeprecationLifecycleForkTest` drives the whole lifecycle: acquires voting power, +votes, waits out the voting period, queues, waits out the timelock, and executes. Extend it with `cfg`, +`governor`, `jsonPath`, `votingToken`, `renouncesProxyAdmin = true`, and pin the fork to a block **before the +voting snapshot** — voting power is obtained by dealing vault shares and delegating, which only counts if it +happens before the snapshot. Covers `DeprecationLifecycleFork_BED` and `_SMEL`. The vote is simulated; +the proposal, its actions, quorum and the timelock delay are real. + +**Once `Queued`** — `DeprecationQueuedForkTest` asserts the state, warps past the ETA and executes. Mix it +with `DeprecationProposalForkFromJson` to source the actions from the JSON, and pin the fork to a block where +the proposal is queued but not yet executed. `DeprecationProposalFork_mvRWA` instead declares its actions +inline: it predates the single-proposal flow, so its onchain payload has no ProxyAdmin renounce. + +Per-DTF addresses for pending deprecations live in `test/base/PendingDeprecations.sol`, shared by this suite +and the JSON one. + +### Regression (`test/DeprecationFork.t.sol`) + +Assembles the steps inline and pranks the owner timelock. Retained for the DTFs already deprecated onchain, +pinned to blocks before their proposals executed. New DTFs belong in the two suites above. + +```bash +FORK_RPC_MAINNET="" FORK_RPC_BASE="" \ + forge test --match-contract DeprecationForkTest --evm-version cancun -vv ``` ### What the Tests Verify - `isDeprecated` set to `true` - All role counts drop to zero (`DEFAULT_ADMIN_ROLE`, `REBALANCE_MANAGER`, `AUCTION_LAUNCHER`) -- **Redeem works** — 1 share redeemed, assets received > 0 +- **Redeem works** — 1 share redeemed; every token `toAssets` quotes a nonzero amount for must pay out + (tokens quoted at zero are dust weights that floor away for a single share) - **Mint blocked** — reverts with `Folio__FolioDeprecated` -- **Unstake/withdraw works** — StakingVault shares redeemed, lock claimed after delay, underlying tokens received +- **Unstake/withdraw works** — StakingVault shares redeemed, lock claimed after the delay, underlying + received. The lock id is read off the `LockCreated` event, since a vault shared by several DTFs holds + too many locks to scan by index - ProxyAdmin ownership renounced to `address(0)` ### Requirements diff --git a/script/deprecation/generate-deprecation-proposals.sh b/script/deprecation/generate-deprecation-proposals.sh index 272d55bc..9ab2edc8 100755 --- a/script/deprecation/generate-deprecation-proposals.sh +++ b/script/deprecation/generate-deprecation-proposals.sh @@ -140,6 +140,26 @@ generate_proposal "mvDEFI" "1" \ "0x6f1d6b86d4ad705385e751e6e88b0fdfdbadf298" \ "0x7daaf7bc2ee8bf4c0ac7f37e6b6cfaeb3ed9a868" +generate_proposal "BED" "1" \ + "0x4e3b170dcbe704b248df5f56d488114ace01b1c5" \ + "0xfad4823ae478637fd8ffdafb6c912f63c8cd1dd7" \ + "0x6b45fe3f4464477f657702b8e942b71c3ba83944" \ + "0x8e530cd0c47d515558229aae193dd119cc791a40" \ + "0xeaa356f6cd6b3fd15b47838d03cf34fa79f7c712" \ + "0x280730d9277ef586d58db74c277aa710ca8f87c9" \ + "0xc6625129c9df3314a4dd604845488f4ba62f9db8" \ + "0x7daaf7bc2ee8bf4c0ac7f37e6b6cfaeb3ed9a868" + +generate_proposal "SMEL" "1" \ + "0xf91384484f4717314798e8975bcd904a35fc2bf1" \ + "0x622c0b5ad82a2a47f330d4a2061a0e3562f583b0" \ + "0x476ae35b6deccda7969f105e983a916bcc12c31a" \ + "0x395417220ae7447d19752f38327b96faf52e1911" \ + "0xdd885b0f2f97703b94d2790320b30017a17768bf" \ + "0x280730d9277ef586d58db74c277aa710ca8f87c9" \ + "0xc6625129c9df3314a4dd604845488f4ba62f9db8" \ + "0x7daaf7bc2ee8bf4c0ac7f37e6b6cfaeb3ed9a868" + # ────────────────────────────────────────────── # Base (chainId: 8453) # ────────────────────────────────────────────── diff --git a/script/deprecation/proposals/deprecate-BED.json b/script/deprecation/proposals/deprecate-BED.json new file mode 100644 index 00000000..fbb558ac --- /dev/null +++ b/script/deprecation/proposals/deprecate-BED.json @@ -0,0 +1,16 @@ +{ + "version": "1.0", + "chainId": "1", + "createdAt": 1786449741, + "meta": { + "name": "Deprecate BED", + "description": "Governance proposal to deprecate BED Index DTF" + }, + "transactions": [ + { + "to": "0xfad4823ae478637fd8ffdafb6c912f63c8cd1dd7", + "value": "0", + "data": "0x7d5e81e2000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000068000000000000000000000000000000000000000000000000000000000000000070000000000000000000000004e3b170dcbe704b248df5f56d488114ace01b1c50000000000000000000000004e3b170dcbe704b248df5f56d488114ace01b1c50000000000000000000000004e3b170dcbe704b248df5f56d488114ace01b1c50000000000000000000000004e3b170dcbe704b248df5f56d488114ace01b1c50000000000000000000000004e3b170dcbe704b248df5f56d488114ace01b1c50000000000000000000000004e3b170dcbe704b248df5f56d488114ace01b1c5000000000000000000000000eaa356f6cd6b3fd15b47838d03cf34fa79f7c71200000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000002a0000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000003a000000000000000000000000000000000000000000000000000000000000000047aeaafb3000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044d547741f4ff6ae4d6a29e79ca45c6441bdc89b93878ac6118485b33c8baa3749fc3cb1300000000000000000000000008e530cd0c47d515558229aae193dd119cc791a40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044d547741f13ff1b2625181b311f257c723b5e6d366eb318b212d9dd694c48fcf227659df5000000000000000000000000280730d9277ef586d58db74c277aa710ca8f87c9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044d547741f13ff1b2625181b311f257c723b5e6d366eb318b212d9dd694c48fcf227659df5000000000000000000000000c6625129c9df3314a4dd604845488f4ba62f9db8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044d547741f13ff1b2625181b311f257c723b5e6d366eb318b212d9dd694c48fcf227659df50000000000000000000000007daaf7bc2ee8bf4c0ac7f37e6b6cfaeb3ed9a868000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044d547741f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000006b45fe3f4464477f657702b8e942b71c3ba83944000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004715018a60000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000174465707265636174652042454420496e64657820445446000000000000000000" + } + ] +} diff --git a/script/deprecation/proposals/deprecate-SMEL.json b/script/deprecation/proposals/deprecate-SMEL.json new file mode 100644 index 00000000..0dce9dc2 --- /dev/null +++ b/script/deprecation/proposals/deprecate-SMEL.json @@ -0,0 +1,16 @@ +{ + "version": "1.0", + "chainId": "1", + "createdAt": 1786449741, + "meta": { + "name": "Deprecate SMEL", + "description": "Governance proposal to deprecate SMEL Index DTF" + }, + "transactions": [ + { + "to": "0x622c0b5ad82a2a47f330d4a2061a0e3562f583b0", + "value": "0", + "data": "0x7d5e81e200000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000028000000000000000000000000000000000000000000000000000000000000006800000000000000000000000000000000000000000000000000000000000000007000000000000000000000000f91384484f4717314798e8975bcd904a35fc2bf1000000000000000000000000f91384484f4717314798e8975bcd904a35fc2bf1000000000000000000000000f91384484f4717314798e8975bcd904a35fc2bf1000000000000000000000000f91384484f4717314798e8975bcd904a35fc2bf1000000000000000000000000f91384484f4717314798e8975bcd904a35fc2bf1000000000000000000000000f91384484f4717314798e8975bcd904a35fc2bf1000000000000000000000000dd885b0f2f97703b94d2790320b30017a17768bf00000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000022000000000000000000000000000000000000000000000000000000000000002a0000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000003a000000000000000000000000000000000000000000000000000000000000000047aeaafb3000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044d547741f4ff6ae4d6a29e79ca45c6441bdc89b93878ac6118485b33c8baa3749fc3cb130000000000000000000000000395417220ae7447d19752f38327b96faf52e1911000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044d547741f13ff1b2625181b311f257c723b5e6d366eb318b212d9dd694c48fcf227659df5000000000000000000000000280730d9277ef586d58db74c277aa710ca8f87c9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044d547741f13ff1b2625181b311f257c723b5e6d366eb318b212d9dd694c48fcf227659df5000000000000000000000000c6625129c9df3314a4dd604845488f4ba62f9db8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044d547741f13ff1b2625181b311f257c723b5e6d366eb318b212d9dd694c48fcf227659df50000000000000000000000007daaf7bc2ee8bf4c0ac7f37e6b6cfaeb3ed9a868000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044d547741f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476ae35b6deccda7969f105e983a916bcc12c31a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004715018a600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001844657072656361746520534d454c20496e646578204454460000000000000000" + } + ] +} diff --git a/test/DeprecationFork.t.sol b/test/DeprecationFork.t.sol index 5d77ccaf..744b3116 100644 --- a/test/DeprecationFork.t.sol +++ b/test/DeprecationFork.t.sol @@ -1,43 +1,19 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; -import "forge-std/Test.sol"; - import { IAccessControlEnumerable } from "@openzeppelin/contracts/access/extensions/IAccessControlEnumerable.sol"; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; -import { IFolio } from "contracts/interfaces/IFolio.sol"; import { Folio } from "@src/Folio.sol"; -bytes32 constant DEFAULT_ADMIN_ROLE = 0x00; -bytes32 constant REBALANCE_MANAGER = keccak256("REBALANCE_MANAGER"); -bytes32 constant AUCTION_LAUNCHER = keccak256("AUCTION_LAUNCHER"); - -interface IStakingVault is IERC4626 { - function unstakingDelay() external view returns (uint256); - function unstakingManager() external view returns (address); -} - -interface IUnstakingManager { - function locks( - uint256 lockId - ) external view returns (address user, uint256 amount, uint256 unlockTime, uint256 claimedAt); - function claimLock(uint256 lockId) external; -} - -abstract contract DeprecationForkTest is Test { - struct DTFConfig { - string symbol; - address folio; - address ownerTimelock; - address tradingTimelock; // address(0) if none - address[] auctionLaunchers; - address proxyAdmin; - address stakingVault; - } +import { BaseDeprecationForkTest, DEFAULT_ADMIN_ROLE, REBALANCE_MANAGER, AUCTION_LAUNCHER } from "./base/BaseDeprecationForkTest.sol"; +/** + * @notice Direct simulation of the deprecation steps, pranking as the owner timelock. + * @dev Regression coverage for the DTFs already deprecated onchain, pinned to blocks before their + * proposals executed. New DTFs should be covered by DeprecationJsonFork (before submission) and + * DeprecationProposalFork (once queued) instead, since those run the real proposal payload. + */ +abstract contract DeprecationForkTest is BaseDeprecationForkTest { DTFConfig[] internal configs; function _addConfig( @@ -62,187 +38,62 @@ abstract contract DeprecationForkTest is Test { ); } - /// @dev Simulate Round 1: deprecate folio + revoke all roles + /// @dev Deprecate folio + revoke all roles function test_deprecation_fork() public { for (uint256 i; i < configs.length; i++) { - _testDeprecation(configs[i]); + _deprecateAsTimelock(configs[i]); } } - /// @dev Simulate Round 2: renounce ProxyAdmin ownership + /// @dev Renounce ProxyAdmin ownership function test_renounceProxyAdmin_fork() public { for (uint256 i; i < configs.length; i++) { - _testRenounceProxyAdmin(configs[i]); + _renounceProxyAdminAsTimelock(configs[i]); } } /// @dev Full flow: deprecate, verify redeem/mint/unstake, renounce ProxyAdmin function test_fullDeprecation_fork() public { for (uint256 i; i < configs.length; i++) { - _testDeprecation(configs[i]); - _testRedeemStillWorks(configs[i]); - _testMintBlocked(configs[i]); - _testUnstakeWithdraw(configs[i]); - _testRenounceProxyAdmin(configs[i]); + _deprecateAsTimelock(configs[i]); + _assertRedemptionOnlyMode(configs[i]); + _renounceProxyAdminAsTimelock(configs[i]); } } - function _testDeprecation(DTFConfig memory cfg) internal { - Folio folio = Folio(cfg.folio); + function _deprecateAsTimelock(DTFConfig memory cfg) internal { + _assertLiveAndGoverned(cfg); - // Pre-checks - assertFalse(folio.isDeprecated(), string.concat(cfg.symbol, ": already deprecated")); - assertTrue( - IAccessControlEnumerable(cfg.folio).hasRole(DEFAULT_ADMIN_ROLE, cfg.ownerTimelock), - string.concat(cfg.symbol, ": timelock missing admin role") - ); - - // Execute as owner timelock vm.startPrank(cfg.ownerTimelock); // 1. deprecateFolio() - folio.deprecateFolio(); - assertTrue(folio.isDeprecated(), string.concat(cfg.symbol, ": not deprecated")); + Folio(cfg.folio).deprecateFolio(); // 2. Revoke REBALANCE_MANAGER if (cfg.tradingTimelock != address(0)) { IAccessControlEnumerable(cfg.folio).revokeRole(REBALANCE_MANAGER, cfg.tradingTimelock); - assertFalse( - IAccessControlEnumerable(cfg.folio).hasRole(REBALANCE_MANAGER, cfg.tradingTimelock), - string.concat(cfg.symbol, ": trading timelock still has REBALANCE_MANAGER") - ); } // 3. Revoke AUCTION_LAUNCHER(s) - for (uint256 j; j < cfg.auctionLaunchers.length; j++) { - IAccessControlEnumerable(cfg.folio).revokeRole(AUCTION_LAUNCHER, cfg.auctionLaunchers[j]); - assertFalse( - IAccessControlEnumerable(cfg.folio).hasRole(AUCTION_LAUNCHER, cfg.auctionLaunchers[j]), - string.concat(cfg.symbol, ": launcher still has AUCTION_LAUNCHER") - ); + for (uint256 i; i < cfg.auctionLaunchers.length; i++) { + IAccessControlEnumerable(cfg.folio).revokeRole(AUCTION_LAUNCHER, cfg.auctionLaunchers[i]); } // 4. Revoke DEFAULT_ADMIN_ROLE (last) IAccessControlEnumerable(cfg.folio).revokeRole(DEFAULT_ADMIN_ROLE, cfg.ownerTimelock); - assertFalse( - IAccessControlEnumerable(cfg.folio).hasRole(DEFAULT_ADMIN_ROLE, cfg.ownerTimelock), - string.concat(cfg.symbol, ": timelock still has admin role") - ); vm.stopPrank(); - // Post-checks: no roles left - assertEq( - IAccessControlEnumerable(cfg.folio).getRoleMemberCount(DEFAULT_ADMIN_ROLE), - 0, - string.concat(cfg.symbol, ": admin role count != 0") - ); - assertEq( - IAccessControlEnumerable(cfg.folio).getRoleMemberCount(REBALANCE_MANAGER), - 0, - string.concat(cfg.symbol, ": rebalance manager count != 0") - ); - assertEq( - IAccessControlEnumerable(cfg.folio).getRoleMemberCount(AUCTION_LAUNCHER), - 0, - string.concat(cfg.symbol, ": auction launcher count != 0") - ); + _assertDeprecated(cfg); } - function _testRedeemStillWorks(DTFConfig memory cfg) internal { - Folio folio = Folio(cfg.folio); - assertTrue(folio.isDeprecated(), string.concat(cfg.symbol, ": should be deprecated for redeem test")); - - uint256 redeemShares = 1e18; - - try folio.toAssets(redeemShares, Math.Rounding.Floor) returns (address[] memory assets, uint256[] memory) { - address redeemer = makeAddr(string.concat("redeemer-", cfg.symbol)); - deal(cfg.folio, redeemer, redeemShares); - assertEq(folio.balanceOf(redeemer), redeemShares, string.concat(cfg.symbol, ": deal failed")); - - uint256[] memory minAmountsOut = new uint256[](assets.length); - - uint256[] memory balancesBefore = new uint256[](assets.length); - for (uint256 j; j < assets.length; j++) { - balancesBefore[j] = IERC20(assets[j]).balanceOf(redeemer); - } - - vm.prank(redeemer); - folio.redeem(redeemShares, redeemer, assets, minAmountsOut); - - assertEq(folio.balanceOf(redeemer), 0, string.concat(cfg.symbol, ": shares not burned")); - - uint256 totalReceived; - for (uint256 j; j < assets.length; j++) { - totalReceived += IERC20(assets[j]).balanceOf(redeemer) - balancesBefore[j]; - } - assertGt(totalReceived, 0, string.concat(cfg.symbol, ": received nothing from redeem")); - } catch { - emit log_string(string.concat(cfg.symbol, ": SKIPPED redeem test (basket token incompatible with fork)")); - } - } - - function _testMintBlocked(DTFConfig memory cfg) internal { - Folio folio = Folio(cfg.folio); - address minter = makeAddr(string.concat("minter-", cfg.symbol)); - - vm.prank(minter); - vm.expectRevert(IFolio.Folio__FolioDeprecated.selector); - folio.mint(1e18, minter, 0); - } - - function _testUnstakeWithdraw(DTFConfig memory cfg) internal { - _unstakeAndClaim(cfg.symbol, cfg.stakingVault); - } - - function _unstakeAndClaim(string memory symbol, address stakingVaultAddr) internal { - IStakingVault vault = IStakingVault(stakingVaultAddr); - address underlying = vault.asset(); - address staker = makeAddr(string.concat("staker-", symbol)); - - // Deal staking vault shares - deal(stakingVaultAddr, staker, 1e18); - - uint256 underlyingBefore = IERC20(underlying).balanceOf(staker); - - // Redeem shares — creates a lock in UnstakingManager - vm.prank(staker); - vault.redeem(1e18, staker, staker); - assertEq(IERC20(stakingVaultAddr).balanceOf(staker), 0, string.concat(symbol, ": vault shares not burned")); - - // Warp past unstaking delay - vm.warp(block.timestamp + vault.unstakingDelay() + 1); - - // Find and claim lock - IUnstakingManager umgr = IUnstakingManager(vault.unstakingManager()); - bool found; - for (uint256 lockId; lockId < 100; lockId++) { - (address lockUser, , , uint256 claimedAt) = umgr.locks(lockId); - if (lockUser == staker && claimedAt == 0) { - umgr.claimLock(lockId); - assertGt( - IERC20(underlying).balanceOf(staker), - underlyingBefore, - string.concat(symbol, ": no underlying after unstake") - ); - found = true; - break; - } - } - require(found, string.concat(symbol, ": could not find unstaking lock")); - } - - function _testRenounceProxyAdmin(DTFConfig memory cfg) internal { - assertEq( - Ownable(cfg.proxyAdmin).owner(), - cfg.ownerTimelock, - string.concat(cfg.symbol, ": proxyAdmin not owned by timelock") - ); + function _renounceProxyAdminAsTimelock(DTFConfig memory cfg) internal { + _assertProxyAdminOwned(cfg); vm.prank(cfg.ownerTimelock); Ownable(cfg.proxyAdmin).renounceOwnership(); - assertEq(Ownable(cfg.proxyAdmin).owner(), address(0), string.concat(cfg.symbol, ": proxyAdmin owner not zero")); + _assertProxyAdminRenounced(cfg); } } diff --git a/test/DeprecationJsonFork.t.sol b/test/DeprecationJsonFork.t.sol new file mode 100644 index 00000000..645a7409 --- /dev/null +++ b/test/DeprecationJsonFork.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import { PendingDeprecations } from "./base/PendingDeprecations.sol"; + +/** + * @notice Validates a generated proposal JSON before it is submitted to the Safe. + * @dev Decodes the propose() calldata out of the committed JSON, checks the action set against live + * onchain state, then executes those exact actions as the owner timelock. Once a proposal is + * onchain, DeprecationProposalFork takes over and runs it through the Governor instead. + */ +abstract contract DeprecationJsonForkTest is PendingDeprecations { + DTFConfig internal cfg; + + address internal ownerGovernor; + string internal jsonPath; + + function test_deprecationFromJson_fork() public { + ( + address to, + uint256 chainId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) = _loadProposalJson(jsonPath); + + assertEq(chainId, block.chainid, string.concat(cfg.symbol, ": JSON chainId mismatch")); + assertEq(to, ownerGovernor, string.concat(cfg.symbol, ": JSON does not target the owner governor")); + assertEq(description, string.concat("Deprecate ", cfg.symbol, " Index DTF")); + + _assertLiveAndGoverned(cfg); + _assertProxyAdminOwned(cfg); + _assertDeprecationActions(cfg, targets, values, calldatas); + + _executeAsTimelock(cfg, targets, values, calldatas); + + _assertRedemptionOnlyMode(cfg); + _assertProxyAdminRenounced(cfg); + } +} + +contract DeprecationJsonFork_BED is DeprecationJsonForkTest { + function setUp() public { + vm.createSelectFork(vm.envOr("FORK_RPC_MAINNET", string("mainnet")), 25739000); + + PendingDTF memory dtf = _bed(); + cfg = dtf.cfg; + ownerGovernor = dtf.governor; + jsonPath = dtf.jsonPath; + } +} + +contract DeprecationJsonFork_SMEL is DeprecationJsonForkTest { + function setUp() public { + vm.createSelectFork(vm.envOr("FORK_RPC_MAINNET", string("mainnet")), 25739000); + + PendingDTF memory dtf = _smel(); + cfg = dtf.cfg; + ownerGovernor = dtf.governor; + jsonPath = dtf.jsonPath; + } +} diff --git a/test/DeprecationProposalFork.t.sol b/test/DeprecationProposalFork.t.sol index 85a7fe6f..ec0ef81e 100644 --- a/test/DeprecationProposalFork.t.sol +++ b/test/DeprecationProposalFork.t.sol @@ -1,296 +1,293 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.28; -import "forge-std/Test.sol"; - -import { IAccessControlEnumerable } from "@openzeppelin/contracts/access/extensions/IAccessControlEnumerable.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; -import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; -import { IFolio } from "contracts/interfaces/IFolio.sol"; -import { Folio } from "@src/Folio.sol"; - -bytes32 constant DEFAULT_ADMIN_ROLE = 0x00; -bytes32 constant REBALANCE_MANAGER = keccak256("REBALANCE_MANAGER"); -bytes32 constant AUCTION_LAUNCHER = keccak256("AUCTION_LAUNCHER"); - -interface IGovernor { - enum ProposalState { - Pending, - Active, - Canceled, - Defeated, - Succeeded, - Queued, - Expired, - Executed - } - - function state(uint256 proposalId) external view returns (ProposalState); - function proposalEta(uint256 proposalId) external view returns (uint256); - function token() external view returns (address); - function timelock() external view returns (address); - - function execute( - address[] calldata targets, - uint256[] calldata values, - bytes[] calldata calldatas, - bytes32 descriptionHash - ) external returns (uint256); -} - -interface IStakingVault is IERC4626 { - function unstakingDelay() external view returns (uint256); - function unstakingManager() external view returns (address); -} - -interface IUnstakingManager { - function locks( - uint256 lockId - ) external view returns (address user, uint256 amount, uint256 unlockTime, uint256 claimedAt); - function claimLock(uint256 lockId) external; +import { IVotes } from "@openzeppelin/contracts/governance/utils/IVotes.sol"; + +import { IGovernor, DEFAULT_ADMIN_ROLE, REBALANCE_MANAGER, AUCTION_LAUNCHER } from "./base/BaseDeprecationForkTest.sol"; +import { PendingDeprecations } from "./base/PendingDeprecations.sol"; + +uint8 constant VOTE_FOR = 1; + +/** + * @notice Shared state for the suites that run a live onchain deprecation proposal. + * @dev Subclasses supply the proposal actions, either from the generated JSON (DeprecationProposalForkFromJson) + * or inline for proposals that predate the current generator, and mix in one of the two test entrypoints: + * DeprecationQueuedForkTest (proposal already queued) or DeprecationLifecycleForkTest (still Pending). + */ +abstract contract DeprecationProposalForkTest is PendingDeprecations { + DTFConfig internal cfg; + + address internal governor; + + /// @dev Whether the proposal also renounces ProxyAdmin ownership; early proposals did this in a second round + bool internal renouncesProxyAdmin; + + function _proposalActions() + internal + view + virtual + returns ( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ); } -abstract contract DeprecationProposalForkTest is Test { - struct ProposalConfig { - string symbol; - address folio; - address governor; - address proxyAdmin; - address stakingVault; - uint256 deprecationProposalId; - address[] targets; - uint256[] values; - bytes[] calldatas; - string description; - } +/// @notice Executes an already-queued proposal through the Governor. +/// @dev Pin the fork to a block where the proposal is queued but not yet executed. +abstract contract DeprecationQueuedForkTest is DeprecationProposalForkTest { + function test_executeQueuedProposal_fork() public { + ( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) = _proposalActions(); + + _assertLiveAndGoverned(cfg); + + if (renouncesProxyAdmin) { + _assertProxyAdminOwned(cfg); + _assertDeprecationActions(cfg, targets, values, calldatas); + } - ProposalConfig[] internal proposals; + _executeViaGovernor(cfg, governor, targets, values, calldatas, description); - function _addProposal( - string memory symbol, - address folio, - address governor, - address proxyAdmin, - address stakingVault, - uint256 proposalId, - address[] memory targets, - uint256[] memory values, - bytes[] memory calldatas, - string memory description - ) internal { - proposals.push( - ProposalConfig({ - symbol: symbol, - folio: folio, - governor: governor, - proxyAdmin: proxyAdmin, - stakingVault: stakingVault, - deprecationProposalId: proposalId, - targets: targets, - values: values, - calldatas: calldatas, - description: description - }) - ); - } + _assertRedemptionOnlyMode(cfg); - /// @dev Execute a queued proposal on fork and verify deprecation - function test_executeQueuedProposal_fork() public { - for (uint256 i; i < proposals.length; i++) { - _testExecuteProposal(proposals[i]); + if (renouncesProxyAdmin) { + _assertProxyAdminRenounced(cfg); } } +} - function _testExecuteProposal(ProposalConfig memory cfg) internal { - IGovernor gov = IGovernor(cfg.governor); - Folio folio = Folio(cfg.folio); +/// @dev Runs the proposal exactly as generated, so the JSON is the single source of truth end to end: +/// DeprecationJsonFork validates it before submission, this validates it after it is queued +abstract contract DeprecationProposalForkFromJson is DeprecationProposalForkTest { + string internal jsonPath; + + function _proposalActions() + internal + view + override + returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, string memory description) + { + address to; + uint256 chainId; + (to, chainId, targets, values, calldatas, description) = _loadProposalJson(jsonPath); + + assertEq(chainId, block.chainid, string.concat(cfg.symbol, ": JSON chainId mismatch")); + assertEq(to, governor, string.concat(cfg.symbol, ": JSON does not target the governor")); + } +} - // Verify proposal is queued - IGovernor.ProposalState currentState = gov.state(cfg.deprecationProposalId); +/** + * @notice Drives a submitted proposal through its whole lifecycle: vote, queue, execute. + * @dev Lets a proposal be validated end to end while it is still Pending, rather than waiting days for it to + * reach Queued. Voting power is acquired before the snapshot, so the fork MUST be pinned to a block + * before voting opens. The vote is simulated; everything else — proposal id, actions, timelock delay — + * is the real onchain proposal. + */ +abstract contract DeprecationLifecycleForkTest is DeprecationProposalForkFromJson { + address internal votingToken; + + function test_proposalLifecycle_fork() public { + ( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) = _proposalActions(); + + IGovernor gov = IGovernor(governor); + uint256 proposalId = gov.hashProposal(targets, values, calldatas, keccak256(bytes(description))); + + // the proposal exists onchain and voting has not opened yet assertEq( - uint256(currentState), - uint256(IGovernor.ProposalState.Queued), - string.concat(cfg.symbol, ": proposal not queued") + uint256(gov.state(proposalId)), + uint256(IGovernor.ProposalState.Pending), + string.concat(cfg.symbol, ": proposal not Pending - pin the fork before the voting snapshot") ); - // Pre-checks - assertFalse(folio.isDeprecated(), string.concat(cfg.symbol, ": already deprecated")); + _assertLiveAndGoverned(cfg); + _assertProxyAdminOwned(cfg); + _assertDeprecationActions(cfg, targets, values, calldatas); - // Warp past the ETA - uint256 eta = gov.proposalEta(cfg.deprecationProposalId); - vm.warp(eta + 1); + _voteFor(gov, proposalId); + _queue(gov, proposalId, targets, values, calldatas, description); - // Execute the proposal - bytes32 descriptionHash = keccak256(bytes(cfg.description)); - gov.execute(cfg.targets, cfg.values, cfg.calldatas, descriptionHash); + _executeViaGovernor(cfg, governor, targets, values, calldatas, description); - // Verify executed - assertEq( - uint256(gov.state(cfg.deprecationProposalId)), - uint256(IGovernor.ProposalState.Executed), - string.concat(cfg.symbol, ": proposal not executed") - ); + _assertRedemptionOnlyMode(cfg); + _assertProxyAdminRenounced(cfg); + } - // Verify deprecation effects - assertTrue(folio.isDeprecated(), string.concat(cfg.symbol, ": not deprecated after execution")); + /// @dev Acquires voting power before the snapshot, then votes once voting opens + function _voteFor(IGovernor gov, uint256 proposalId) internal { + uint256 snapshot = gov.proposalSnapshot(proposalId); + assertLt(block.timestamp, snapshot, string.concat(cfg.symbol, ": fork pinned past the voting snapshot")); - // Verify all roles revoked - assertEq( - IAccessControlEnumerable(cfg.folio).getRoleMemberCount(DEFAULT_ADMIN_ROLE), - 0, - string.concat(cfg.symbol, ": admin role count != 0") - ); + // dealing shares does not move voting units, delegating afterwards does — and neither raises the + // total-supply checkpoint that quorum is measured against + address voter = makeAddr(string.concat("voter-", cfg.symbol)); + uint256 stake = IERC20(votingToken).totalSupply() / 2; + deal(votingToken, voter, stake); + + vm.prank(voter); + IVotes(votingToken).delegate(voter); + + vm.warp(snapshot + 1); assertEq( - IAccessControlEnumerable(cfg.folio).getRoleMemberCount(REBALANCE_MANAGER), - 0, - string.concat(cfg.symbol, ": rebalance manager count != 0") + uint256(gov.state(proposalId)), + uint256(IGovernor.ProposalState.Active), + string.concat(cfg.symbol, ": proposal not Active after the snapshot") ); - assertEq( - IAccessControlEnumerable(cfg.folio).getRoleMemberCount(AUCTION_LAUNCHER), - 0, - string.concat(cfg.symbol, ": auction launcher count != 0") + assertGe( + IVotes(votingToken).getPastVotes(voter, snapshot), + gov.quorum(snapshot), + string.concat(cfg.symbol, ": test voter below quorum") ); - // Verify mint is blocked - address minter = makeAddr(string.concat("minter-", cfg.symbol)); - vm.prank(minter); - vm.expectRevert(IFolio.Folio__FolioDeprecated.selector); - folio.mint(1e18, minter, 0); - - // Verify redeem still works - _testRedeemStillWorks(cfg.symbol, folio); + vm.prank(voter); + gov.castVote(proposalId, VOTE_FOR); - // Verify unstake/withdraw still works - _testUnstakeWithdraw(cfg.symbol, cfg.stakingVault); + vm.warp(gov.proposalDeadline(proposalId) + 1); + assertEq( + uint256(gov.state(proposalId)), + uint256(IGovernor.ProposalState.Succeeded), + string.concat(cfg.symbol, ": proposal did not succeed") + ); } - function _testRedeemStillWorks(string memory symbol, Folio folio) internal { - uint256 redeemShares = 1e18; - - try folio.toAssets(redeemShares, Math.Rounding.Floor) returns (address[] memory assets, uint256[] memory) { - address redeemer = makeAddr(string.concat("redeemer-", symbol)); - deal(address(folio), redeemer, redeemShares); + function _queue( + IGovernor gov, + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) internal { + gov.queue(targets, values, calldatas, keccak256(bytes(description))); - uint256[] memory minAmountsOut = new uint256[](assets.length); + assertEq( + uint256(gov.state(proposalId)), + uint256(IGovernor.ProposalState.Queued), + string.concat(cfg.symbol, ": proposal not queued") + ); + assertGt(gov.proposalEta(proposalId), block.timestamp, string.concat(cfg.symbol, ": timelock delay skipped")); + } +} - uint256[] memory balancesBefore = new uint256[](assets.length); - for (uint256 j; j < assets.length; j++) { - balancesBefore[j] = IERC20(assets[j]).balanceOf(redeemer); - } +contract DeprecationLifecycleFork_BED is DeprecationLifecycleForkTest { + function setUp() public { + vm.createSelectFork(vm.envOr("FORK_RPC_MAINNET", string("mainnet")), 25739000); // before voting opens + + PendingDTF memory dtf = _bed(); + cfg = dtf.cfg; + governor = dtf.governor; + jsonPath = dtf.jsonPath; + votingToken = VLRSR_STEAKHOUSE; + renouncesProxyAdmin = true; + } +} - vm.prank(redeemer); - folio.redeem(redeemShares, redeemer, assets, minAmountsOut); +contract DeprecationLifecycleFork_SMEL is DeprecationLifecycleForkTest { + function setUp() public { + vm.createSelectFork(vm.envOr("FORK_RPC_MAINNET", string("mainnet")), 25739000); // before voting opens + + PendingDTF memory dtf = _smel(); + cfg = dtf.cfg; + governor = dtf.governor; + jsonPath = dtf.jsonPath; + votingToken = VLRSR_STEAKHOUSE; + renouncesProxyAdmin = true; + } +} - assertEq(folio.balanceOf(redeemer), 0, string.concat(symbol, ": shares not burned")); +contract DeprecationProposalFork_BED is DeprecationProposalForkFromJson, DeprecationQueuedForkTest { + function setUp() public { + vm.createSelectFork(vm.envOr("FORK_RPC_MAINNET", string("mainnet")), 25782402); // proposal queued - uint256 totalReceived; - for (uint256 j; j < assets.length; j++) { - totalReceived += IERC20(assets[j]).balanceOf(redeemer) - balancesBefore[j]; - } - assertGt(totalReceived, 0, string.concat(symbol, ": received nothing from redeem")); - } catch { - emit log_string(string.concat(symbol, ": SKIPPED redeem test (basket token incompatible with fork)")); - } + PendingDTF memory dtf = _bed(); + cfg = dtf.cfg; + governor = dtf.governor; + jsonPath = dtf.jsonPath; + renouncesProxyAdmin = true; } +} - function _testUnstakeWithdraw(string memory symbol, address stakingVaultAddr) internal { - IStakingVault vault = IStakingVault(stakingVaultAddr); - address underlying = vault.asset(); - address staker = makeAddr(string.concat("staker-", symbol)); - - // Deal staking vault shares - deal(stakingVaultAddr, staker, 1e18); - - uint256 underlyingBefore = IERC20(underlying).balanceOf(staker); - - // Redeem shares — creates a lock in UnstakingManager - vm.prank(staker); - vault.redeem(1e18, staker, staker); - assertEq(IERC20(stakingVaultAddr).balanceOf(staker), 0, string.concat(symbol, ": vault shares not burned")); - - // Warp past unstaking delay - vm.warp(block.timestamp + vault.unstakingDelay() + 1); - - // Find and claim lock - IUnstakingManager umgr = IUnstakingManager(vault.unstakingManager()); - bool found; - for (uint256 lockId; lockId < 100; lockId++) { - (address lockUser, , , uint256 claimedAt) = umgr.locks(lockId); - if (lockUser == staker && claimedAt == 0) { - umgr.claimLock(lockId); - assertGt( - IERC20(underlying).balanceOf(staker), - underlyingBefore, - string.concat(symbol, ": no underlying after unstake") - ); - found = true; - break; - } - } - require(found, string.concat(symbol, ": could not find unstaking lock")); +contract DeprecationProposalFork_SMEL is DeprecationProposalForkFromJson, DeprecationQueuedForkTest { + function setUp() public { + vm.createSelectFork(vm.envOr("FORK_RPC_MAINNET", string("mainnet")), 25782402); // proposal queued + + PendingDTF memory dtf = _smel(); + cfg = dtf.cfg; + governor = dtf.governor; + jsonPath = dtf.jsonPath; + renouncesProxyAdmin = true; } } -contract DeprecationProposalFork_mvRWA is DeprecationProposalForkTest { +/// @dev Proposal id 77830145447331487048806002448004034037637077883085812792021902429453348063407. +/// Predates the single-proposal flow: the ProxyAdmin renounce was a separate second proposal. +contract DeprecationProposalFork_mvRWA is DeprecationQueuedForkTest { function setUp() public { vm.createSelectFork(vm.envOr("FORK_RPC_MAINNET", string("mainnet")), 24677500); // proposal queued - address folio = 0xA5cdea03B11042fc10B52aF9eCa48bb17A2107d2; - - // Build proposal actions (same as the deprecation JSON) - address[] memory targets = new address[](6); - uint256[] memory values = new uint256[](6); - bytes[] memory calldatas = new bytes[](6); + address[] memory launchers = new address[](3); + launchers[0] = 0x6293e97900aA987Cf3Cbd419e0D5Ba43ebfA91c1; + launchers[1] = 0xC6625129C9df3314a4dd604845488f4bA62F9dB8; + launchers[2] = 0x7DaAf7Bc2eE8bf4C0ac7f37E6b6cfaEB3ed9a868; + + cfg = DTFConfig({ + symbol: "mvRWA", + folio: 0xA5cdea03B11042fc10B52aF9eCa48bb17A2107d2, + ownerTimelock: 0x02188526Dd0021F8032868552d2Ea8529d3A4E53, + tradingTimelock: 0xF156F05d8eB854926f08983F98bD8Ac27c2f18c4, + auctionLaunchers: launchers, + proxyAdmin: 0x019318674560C233893aA31Bc0A380dc71dc2dDf, + stakingVault: 0xa2DeA781F351C9Cb831CB1E6c1A687994E04e8aF + }); + + governor = 0x58e72A9a9E9Dc5209D02335d5Ac67eD28a86EAe9; + renouncesProxyAdmin = false; + } - for (uint256 i; i < 6; i++) { - targets[i] = folio; - values[i] = 0; + function _proposalActions() + internal + view + override + returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, string memory description) + { + targets = new address[](6); + values = new uint256[](6); + calldatas = new bytes[](6); + + for (uint256 i; i < targets.length; i++) { + targets[i] = cfg.folio; } - // 1. deprecateFolio() calldatas[0] = abi.encodeWithSignature("deprecateFolio()"); - // 2. revokeRole(REBALANCE_MANAGER, tradingTimelock) - calldatas[1] = abi.encodeWithSignature( - "revokeRole(bytes32,address)", - REBALANCE_MANAGER, - 0xF156F05d8eB854926f08983F98bD8Ac27c2f18c4 - ); - // 3-5. revokeRole(AUCTION_LAUNCHER, launcher) + calldatas[1] = abi.encodeWithSignature("revokeRole(bytes32,address)", REBALANCE_MANAGER, cfg.tradingTimelock); calldatas[2] = abi.encodeWithSignature( "revokeRole(bytes32,address)", AUCTION_LAUNCHER, - 0x6293e97900aA987Cf3Cbd419e0D5Ba43ebfA91c1 + cfg.auctionLaunchers[0] ); calldatas[3] = abi.encodeWithSignature( "revokeRole(bytes32,address)", AUCTION_LAUNCHER, - 0xC6625129C9df3314a4dd604845488f4bA62F9dB8 + cfg.auctionLaunchers[1] ); calldatas[4] = abi.encodeWithSignature( "revokeRole(bytes32,address)", AUCTION_LAUNCHER, - 0x7DaAf7Bc2eE8bf4C0ac7f37E6b6cfaEB3ed9a868 - ); - // 6. revokeRole(DEFAULT_ADMIN_ROLE, ownerTimelock) - calldatas[5] = abi.encodeWithSignature( - "revokeRole(bytes32,address)", - DEFAULT_ADMIN_ROLE, - 0x02188526Dd0021F8032868552d2Ea8529d3A4E53 + cfg.auctionLaunchers[2] ); + calldatas[5] = abi.encodeWithSignature("revokeRole(bytes32,address)", DEFAULT_ADMIN_ROLE, cfg.ownerTimelock); - _addProposal( - "mvRWA", - folio, - 0x58e72A9a9E9Dc5209D02335d5Ac67eD28a86EAe9, // governor - 0x019318674560C233893aA31Bc0A380dc71dc2dDf, // proxyAdmin - 0xa2DeA781F351C9Cb831CB1E6c1A687994E04e8aF, // stakingVault - 77830145447331487048806002448004034037637077883085812792021902429453348063407, - targets, - values, - calldatas, - "Deprecate mvRWA Index DTF" - ); + description = "Deprecate mvRWA Index DTF"; } } diff --git a/test/base/BaseDeprecationForkTest.sol b/test/base/BaseDeprecationForkTest.sol new file mode 100644 index 00000000..f8519657 --- /dev/null +++ b/test/base/BaseDeprecationForkTest.sol @@ -0,0 +1,401 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import "forge-std/Test.sol"; + +import { IAccessControlEnumerable } from "@openzeppelin/contracts/access/extensions/IAccessControlEnumerable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { IFolio } from "contracts/interfaces/IFolio.sol"; +import { Folio } from "@src/Folio.sol"; + +bytes32 constant DEFAULT_ADMIN_ROLE = 0x00; +bytes32 constant REBALANCE_MANAGER = keccak256("REBALANCE_MANAGER"); +bytes32 constant AUCTION_LAUNCHER = keccak256("AUCTION_LAUNCHER"); + +bytes4 constant PROPOSE_SELECTOR = bytes4(keccak256("propose(address[],uint256[],bytes[],string)")); +bytes4 constant DEPRECATE_FOLIO_SELECTOR = bytes4(keccak256("deprecateFolio()")); +bytes4 constant REVOKE_ROLE_SELECTOR = bytes4(keccak256("revokeRole(bytes32,address)")); +bytes4 constant RENOUNCE_OWNERSHIP_SELECTOR = bytes4(keccak256("renounceOwnership()")); + +/// @dev UnstakingManager.LockCreated(uint256 lockId, address user, uint256 amount, uint256 unlockTime) +bytes32 constant LOCK_CREATED_TOPIC = keccak256("LockCreated(uint256,address,uint256,uint256)"); + +interface IGovernor { + enum ProposalState { + Pending, + Active, + Canceled, + Defeated, + Succeeded, + Queued, + Expired, + Executed + } + + function state(uint256 proposalId) external view returns (ProposalState); + function proposalEta(uint256 proposalId) external view returns (uint256); + function proposalSnapshot(uint256 proposalId) external view returns (uint256); + function proposalDeadline(uint256 proposalId) external view returns (uint256); + function quorum(uint256 timepoint) external view returns (uint256); + function castVote(uint256 proposalId, uint8 support) external returns (uint256); + + function queue( + address[] calldata targets, + uint256[] calldata values, + bytes[] calldata calldatas, + bytes32 descriptionHash + ) external returns (uint256); + + function hashProposal( + address[] calldata targets, + uint256[] calldata values, + bytes[] calldata calldatas, + bytes32 descriptionHash + ) external pure returns (uint256); + + function execute( + address[] calldata targets, + uint256[] calldata values, + bytes[] calldata calldatas, + bytes32 descriptionHash + ) external returns (uint256); +} + +interface IStakingVault is IERC4626 { + function unstakingDelay() external view returns (uint256); + function unstakingManager() external view returns (address); +} + +interface IUnstakingManager { + function claimLock(uint256 lockId) external; +} + +/** + * @title BaseDeprecationForkTest + * @notice Shared setup and post-conditions for the deprecation fork suites. + * @dev Subclasses differ only in where the deprecation actions come from and who executes them: + * - DeprecationJsonFork: decoded from the generated proposal JSON, executed as the owner timelock + * - DeprecationProposalFork: a live onchain proposal, executed through the Governor + * - DeprecationFork: assembled inline, executed as the owner timelock + */ +abstract contract BaseDeprecationForkTest is Test { + struct DTFConfig { + string symbol; + address folio; + address ownerTimelock; + address tradingTimelock; // address(0) if none + address[] auctionLaunchers; + address proxyAdmin; + address stakingVault; + } + + /// @dev A DTF awaiting deprecation, with everything the proposal suites need to drive it + struct PendingDTF { + DTFConfig cfg; + address governor; // owner governor, the propose()/execute() target + string jsonPath; + } + + // ==== Pre-conditions ==== + + /// @dev Roles other than the admin are not asserted here: a DTF can reach deprecation having already + /// given some of them up. _assertDeprecationActions checks the ones a proposal actually revokes. + function _assertLiveAndGoverned(DTFConfig memory cfg) internal view { + assertFalse(Folio(cfg.folio).isDeprecated(), string.concat(cfg.symbol, ": already deprecated")); + assertTrue( + IAccessControlEnumerable(cfg.folio).hasRole(DEFAULT_ADMIN_ROLE, cfg.ownerTimelock), + string.concat(cfg.symbol, ": timelock missing admin role") + ); + } + + function _assertProxyAdminOwned(DTFConfig memory cfg) internal view { + assertEq( + Ownable(cfg.proxyAdmin).owner(), + cfg.ownerTimelock, + string.concat(cfg.symbol, ": proxyAdmin not owned by timelock") + ); + } + + // ==== Post-conditions ==== + + /// @dev Deprecated, with no address left holding any operational role + function _assertDeprecated(DTFConfig memory cfg) internal view { + assertTrue(Folio(cfg.folio).isDeprecated(), string.concat(cfg.symbol, ": not deprecated")); + + assertEq( + IAccessControlEnumerable(cfg.folio).getRoleMemberCount(DEFAULT_ADMIN_ROLE), + 0, + string.concat(cfg.symbol, ": admin role count != 0") + ); + assertEq( + IAccessControlEnumerable(cfg.folio).getRoleMemberCount(REBALANCE_MANAGER), + 0, + string.concat(cfg.symbol, ": rebalance manager count != 0") + ); + assertEq( + IAccessControlEnumerable(cfg.folio).getRoleMemberCount(AUCTION_LAUNCHER), + 0, + string.concat(cfg.symbol, ": auction launcher count != 0") + ); + } + + function _assertProxyAdminRenounced(DTFConfig memory cfg) internal view { + assertEq(Ownable(cfg.proxyAdmin).owner(), address(0), string.concat(cfg.symbol, ": proxyAdmin owner not zero")); + } + + /// @dev Redemption is the one Folio entrypoint that must survive deprecation. Every basket token quoted + /// by toAssets has to actually pay out; tokens quoted at zero are dust weights that floor away for + /// a single share. + function _assertRedeemStillWorks(DTFConfig memory cfg) internal { + Folio folio = Folio(cfg.folio); + assertTrue(folio.isDeprecated(), string.concat(cfg.symbol, ": should be deprecated for redeem test")); + + uint256 redeemShares = 1e18; + (address[] memory assets, uint256[] memory quoted) = folio.toAssets(redeemShares, Math.Rounding.Floor); + assertGt(assets.length, 0, string.concat(cfg.symbol, ": empty basket")); + + address redeemer = makeAddr(string.concat("redeemer-", cfg.symbol)); + deal(cfg.folio, redeemer, redeemShares); + assertEq(folio.balanceOf(redeemer), redeemShares, string.concat(cfg.symbol, ": deal failed")); + + uint256[] memory balancesBefore = new uint256[](assets.length); + for (uint256 i; i < assets.length; i++) { + balancesBefore[i] = IERC20(assets[i]).balanceOf(redeemer); + } + + vm.prank(redeemer); + folio.redeem(redeemShares, redeemer, assets, new uint256[](assets.length)); + + assertEq(folio.balanceOf(redeemer), 0, string.concat(cfg.symbol, ": shares not burned")); + + uint256 totalReceived; + for (uint256 i; i < assets.length; i++) { + uint256 received = IERC20(assets[i]).balanceOf(redeemer) - balancesBefore[i]; + totalReceived += received; + + if (quoted[i] != 0) { + assertGt(received, 0, string.concat(cfg.symbol, ": received nothing for a quoted basket token")); + } + } + assertGt(totalReceived, 0, string.concat(cfg.symbol, ": received nothing from redeem")); + } + + function _assertMintBlocked(DTFConfig memory cfg) internal { + address minter = makeAddr(string.concat("minter-", cfg.symbol)); + + vm.prank(minter); + vm.expectRevert(IFolio.Folio__FolioDeprecated.selector); + Folio(cfg.folio).mint(1e18, minter, 0); + } + + /// @dev Stakers must still be able to exit the governance staking vault + function _assertUnstakeStillWorks(DTFConfig memory cfg) internal { + IStakingVault vault = IStakingVault(cfg.stakingVault); + address underlying = vault.asset(); + address unstakingManager = vault.unstakingManager(); + address staker = makeAddr(string.concat("staker-", cfg.symbol)); + + deal(cfg.stakingVault, staker, 1e18); + uint256 underlyingBefore = IERC20(underlying).balanceOf(staker); + + // Redeem shares — creates a lock in the UnstakingManager + vm.recordLogs(); + vm.prank(staker); + vault.redeem(1e18, staker, staker); + assertEq(IERC20(cfg.stakingVault).balanceOf(staker), 0, string.concat(cfg.symbol, ": vault shares not burned")); + + uint256 lockId = _findLockId(cfg.symbol, vm.getRecordedLogs(), unstakingManager, staker); + + // Warp past the unstaking delay and claim + vm.warp(block.timestamp + vault.unstakingDelay() + 1); + IUnstakingManager(unstakingManager).claimLock(lockId); + + assertGt( + IERC20(underlying).balanceOf(staker), + underlyingBefore, + string.concat(cfg.symbol, ": no underlying after unstake") + ); + } + + /// @dev Reads the lock id off the LockCreated event; vaults shared by several DTFs hold too many + /// locks to scan by index + function _findLockId( + string memory symbol, + Vm.Log[] memory logs, + address unstakingManager, + address staker + ) internal pure returns (uint256 lockId) { + for (uint256 i; i < logs.length; i++) { + // topics can be empty: anonymous events carry none + if (logs[i].emitter != unstakingManager || logs[i].topics.length == 0) { + continue; + } + + if (logs[i].topics[0] != LOCK_CREATED_TOPIC) { + continue; + } + + (uint256 id, address user, , ) = abi.decode(logs[i].data, (uint256, address, uint256, uint256)); + if (user == staker) { + return id; + } + } + + revert(string.concat(symbol, ": no unstaking lock created")); + } + + /// @dev Everything a holder is still entitled to once the DTF is deprecated + function _assertRedemptionOnlyMode(DTFConfig memory cfg) internal { + _assertDeprecated(cfg); + _assertRedeemStillWorks(cfg); + _assertMintBlocked(cfg); + _assertUnstakeStillWorks(cfg); + } + + // ==== Proposal helpers ==== + + /// @dev Decodes the propose() call out of a generated Safe Transaction Builder JSON + function _loadProposalJson( + string memory jsonPath + ) + internal + view + returns ( + address to, + uint256 chainId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) + { + string memory json = vm.readFile(jsonPath); + + chainId = vm.parseUint(vm.parseJsonString(json, ".chainId")); + + // one proposal per DTF: a single propose() call carrying every deprecation action + to = vm.parseJsonAddress(json, ".transactions[0].to"); + + bytes memory data = vm.parseJsonBytes(json, ".transactions[0].data"); + assertEq(bytes4(data), PROPOSE_SELECTOR, "transaction is not a propose() call"); + + (targets, values, calldatas, description) = abi.decode( + _stripSelector(data), + (address[], uint256[], bytes[], string) + ); + } + + /// @dev Asserts the action set fully deprecates the DTF, in an order that keeps every call authorized + function _assertDeprecationActions( + DTFConfig memory cfg, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas + ) internal view { + // 1 deprecate + 1 rebalance manager (optional) + N launchers + 1 admin + 1 renounce + uint256 expectedLength = 3 + cfg.auctionLaunchers.length + (cfg.tradingTimelock != address(0) ? 1 : 0); + assertEq(targets.length, expectedLength, string.concat(cfg.symbol, ": unexpected action count")); + assertEq(values.length, expectedLength, string.concat(cfg.symbol, ": values length mismatch")); + assertEq(calldatas.length, expectedLength, string.concat(cfg.symbol, ": calldatas length mismatch")); + + for (uint256 i; i < values.length; i++) { + assertEq(values[i], 0, string.concat(cfg.symbol, ": nonzero value")); + } + + // first action deprecates the Folio + assertEq(targets[0], cfg.folio, string.concat(cfg.symbol, ": action 0 not the Folio")); + assertEq(bytes4(calldatas[0]), DEPRECATE_FOLIO_SELECTOR, string.concat(cfg.symbol, ": action 0 not deprecate")); + + // middle actions revoke roles that are currently held + uint256 lastFolioAction = expectedLength - 2; + for (uint256 i = 1; i <= lastFolioAction; i++) { + assertEq(targets[i], cfg.folio, string.concat(cfg.symbol, ": revoke not targeting the Folio")); + assertEq(bytes4(calldatas[i]), REVOKE_ROLE_SELECTOR, string.concat(cfg.symbol, ": not a revokeRole call")); + + (bytes32 role, address account) = abi.decode(_stripSelector(calldatas[i]), (bytes32, address)); + assertTrue( + IAccessControlEnumerable(cfg.folio).hasRole(role, account), + string.concat(cfg.symbol, ": revoking a role that is not held") + ); + } + + // the admin revocation is the final Folio action, after every call that needs admin rights + (bytes32 lastRole, address lastAccount) = abi.decode( + _stripSelector(calldatas[lastFolioAction]), + (bytes32, address) + ); + assertEq( + lastRole, + DEFAULT_ADMIN_ROLE, + string.concat(cfg.symbol, ": last Folio action is not the admin revoke") + ); + assertEq(lastAccount, cfg.ownerTimelock, string.concat(cfg.symbol, ": admin revoked from the wrong account")); + + // ProxyAdmin renounce closes the proposal; it is owner-gated, not role-gated + assertEq( + targets[expectedLength - 1], + cfg.proxyAdmin, + string.concat(cfg.symbol, ": last action not ProxyAdmin") + ); + assertEq( + bytes4(calldatas[expectedLength - 1]), + RENOUNCE_OWNERSHIP_SELECTOR, + string.concat(cfg.symbol, ": last action not renounceOwnership") + ); + } + + /// @dev Executes the actions the way the timelock would, without waiting on governance + function _executeAsTimelock( + DTFConfig memory cfg, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas + ) internal { + for (uint256 i; i < targets.length; i++) { + vm.prank(cfg.ownerTimelock); + (bool success, ) = targets[i].call{ value: values[i] }(calldatas[i]); + assertTrue(success, string.concat(cfg.symbol, ": action reverted")); + } + } + + /// @dev Executes a queued onchain proposal through the Governor + function _executeViaGovernor( + DTFConfig memory cfg, + address governor, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) internal { + IGovernor gov = IGovernor(governor); + bytes32 descriptionHash = keccak256(bytes(description)); + uint256 proposalId = gov.hashProposal(targets, values, calldatas, descriptionHash); + + assertEq( + uint256(gov.state(proposalId)), + uint256(IGovernor.ProposalState.Queued), + string.concat(cfg.symbol, ": proposal not queued") + ); + + vm.warp(gov.proposalEta(proposalId) + 1); + gov.execute(targets, values, calldatas, descriptionHash); + + assertEq( + uint256(gov.state(proposalId)), + uint256(IGovernor.ProposalState.Executed), + string.concat(cfg.symbol, ": proposal not executed") + ); + } + + function _stripSelector(bytes memory data) internal pure returns (bytes memory out) { + require(data.length >= 4, "calldata too short"); + + out = new bytes(data.length - 4); + for (uint256 i; i < out.length; i++) { + out[i] = data[i + 4]; + } + } +} diff --git a/test/base/PendingDeprecations.sol b/test/base/PendingDeprecations.sol new file mode 100644 index 00000000..213797a0 --- /dev/null +++ b/test/base/PendingDeprecations.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import { BaseDeprecationForkTest } from "./BaseDeprecationForkTest.sol"; + +/** + * @title PendingDeprecations + * @notice Onchain config for the DTFs queued up for deprecation, so the suites that cover them before and + * after submission read from one place. + */ +abstract contract PendingDeprecations is BaseDeprecationForkTest { + /// @dev BED and SMEL share this vlRSR staking vault, which is also the governance voting token + address internal constant VLRSR_STEAKHOUSE = 0x5CdE24d90f9Fe1C1893dD30Ca74F99265b46818F; + + /// @dev Both DTFs use the same three auction launchers + function _mainnetLaunchers() internal pure returns (address[] memory launchers) { + launchers = new address[](3); + launchers[0] = 0x280730d9277EF586d58dB74c277Aa710ca8F87C9; + launchers[1] = 0xC6625129C9df3314a4dd604845488f4bA62F9dB8; + launchers[2] = 0x7DaAf7Bc2eE8bf4C0ac7f37E6b6cfaEB3ed9a868; + } + + function _bed() internal pure returns (PendingDTF memory) { + return + PendingDTF({ + cfg: DTFConfig({ + symbol: "BED", + folio: 0x4E3B170DcBe704b248df5f56D488114acE01B1C5, + ownerTimelock: 0x6B45fe3F4464477f657702b8e942b71C3bA83944, + tradingTimelock: 0x8E530CD0C47d515558229AAE193DD119cc791A40, + auctionLaunchers: _mainnetLaunchers(), + proxyAdmin: 0xEAa356F6CD6b3fd15B47838d03cF34fa79F7c712, + stakingVault: VLRSR_STEAKHOUSE + }), + governor: 0xFaD4823Ae478637fD8FfdafB6c912f63c8cd1Dd7, + jsonPath: "script/deprecation/proposals/deprecate-BED.json" + }); + } + + function _smel() internal pure returns (PendingDTF memory) { + return + PendingDTF({ + cfg: DTFConfig({ + symbol: "SMEL", + folio: 0xF91384484F4717314798E8975BCd904A35fc2BF1, + ownerTimelock: 0x476aE35B6dEccda7969F105e983a916BCc12C31A, + tradingTimelock: 0x395417220aE7447D19752f38327B96fAF52e1911, + auctionLaunchers: _mainnetLaunchers(), + proxyAdmin: 0xDd885B0F2f97703B94d2790320b30017a17768BF, + stakingVault: VLRSR_STEAKHOUSE + }), + governor: 0x622c0b5aD82a2A47F330D4a2061a0e3562F583b0, + jsonPath: "script/deprecation/proposals/deprecate-SMEL.json" + }); + } +}