From ff43de1b03257e78e9b6417029b01dca8e2a592c Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Tue, 28 Apr 2026 14:48:53 -0400 Subject: [PATCH 01/28] add optimistic governance upgrade spell --- .github/workflows/test.yml | 1 + .../spells/GovernanceSpell_04_17_2026.sol | 358 ++++++++++++ package.json | 2 +- pnpm-lock.yaml | 42 +- remappings.txt | 3 +- test/base/BaseTest.sol | 5 +- .../GenericGovernanceSpell_04_17_2026.t.sol | 531 ++++++++++++++++++ .../GovernanceSpellBase_04_17_2026.t.sol | 152 +++++ .../GovernanceSpellBsc_04_17_2026.t.sol | 31 + .../GovernanceSpellEthereum_04_17_2026.t.sol | 252 +++++++++ 10 files changed, 1337 insertions(+), 40 deletions(-) create mode 100644 contracts/spells/GovernanceSpell_04_17_2026.sol create mode 100644 test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol create mode 100644 test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol create mode 100644 test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBsc_04_17_2026.t.sol create mode 100644 test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 56a5673e..665fc7e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,6 +73,7 @@ jobs: env: FORK_RPC_MAINNET: "https://eth-mainnet.g.alchemy.com/v2/${{ secrets.ALCHEMY_MAINNET_KEY }}" FORK_RPC_BASE: "https://base-mainnet.g.alchemy.com/v2/${{ secrets.ALCHEMY_BASE_KEY }}" + FORK_RPC_BSC: "https://bnb-mainnet.g.alchemy.com/v2/${{ secrets.ALCHEMY_MAINNET_KEY }}" extreme: runs-on: ubuntu-latest diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol new file mode 100644 index 00000000..b6eaba46 --- /dev/null +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import { IAccessControlEnumerable } from "@openzeppelin/contracts/access/extensions/IAccessControlEnumerable.sol"; +import { IGovernor } from "@openzeppelin/contracts/governance/IGovernor.sol"; +import { TimelockController } from "@openzeppelin/contracts/governance/TimelockController.sol"; +import { IERC5805 } from "@openzeppelin/contracts/interfaces/IERC5805.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +import { IReserveOptimisticGovernorDeployer } from "@reserve-protocol/reserve-governor/contracts/interfaces/IDeployer.sol"; +import { IOptimisticSelectorRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IOptimisticSelectorRegistry.sol"; +import { IReserveOptimisticGovernor } from "@reserve-protocol/reserve-governor/contracts/interfaces/IReserveOptimisticGovernor.sol"; + +import { IFolio, Folio } from "@src/Folio.sol"; +import { FolioProxyAdmin } from "@folio/FolioProxy.sol"; +import { DEFAULT_ADMIN_ROLE, REBALANCE_MANAGER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; + +bytes32 constant VERSION_1_0_0 = keccak256("1.0.0"); +bytes32 constant CANCELLER_ROLE = keccak256("CANCELLER_ROLE"); + +interface IFolioGovernor is IGovernor { + function token() external view returns (address); + function timelock() external view returns (address); + function quorumNumerator() external view returns (uint256); + function quorumDenominator() external view returns (uint256); +} + +interface IVersioned { + function version() external view returns (string memory); +} + +interface IStakingVault is IERC5805, IERC4626, IVersioned {} + +// old staking vault model +interface IOwnableStakingVault is IStakingVault { + function owner() external view returns (address); + function transferOwnership(address newOwner) external; + function renounceOwnership() external; + function setUnstakingDelay(uint256 unstakingDelay) external; + function setRewardRatio(uint256 rewardHalfLife) external; +} + +/** + * @title GovernanceSpell_04_17_2026 + * @author akshatmittal, julianmrodri, tbrent + * @notice Optimistic governance upgrade spell for DTFs + * + * Upgrade spell to move a StakingVault + Folio onto the optimistic governance system. + * + * Each StakingVault and Folio has its own governor/timelock system. A single StakingVault can be used + * as the governance token in multiple Folios, but always has its own governor/timelock for its own governance. + * + * Upgrade flow: + * 1. deploySuccessorStakingVault: Permissionlessly deploy a NEW StakingVault with its own NEW + * governor/timelock system, isolated from the old vault. No permissions required. + * 2. upgradeFolio: Deploy NEW Folio governance system on the successor StakingVault; rotate Folio roles, + * proxy admin ownership, and fee recipients from old StakingVault to new StakingVault. + * Caller: old timelock of Folio + * 3. retireOldStakingVault: After every dependent Folio has completed step 2, permanently seal the + * old StakingVault (zero unstaking delay, fast reward handout, renounce ownership). + * Caller: timelock of old StakingVault + */ +contract GovernanceSpell_04_17_2026 { + error UpgradeError(uint256 code); + + event NewGovernanceDeployment(NewDeployment newDeployment); + event StakingVaultRetired(address oldStakingVault); + + struct NewDeployment { + address stakingVault; + address newGovernor; + address newTimelock; + address newSelectorRegistry; + } + + IReserveOptimisticGovernorDeployer public immutable governorDeployer; + + constructor(IReserveOptimisticGovernorDeployer _governorDeployer) { + require(keccak256(bytes(IVersioned(address(_governorDeployer)).version())) == VERSION_1_0_0, UpgradeError(0)); + + governorDeployer = _governorDeployer; + } + + /// Deploy a successor StakingVault with a fresh optimistic governance system + /// @dev Permissionless: does not require or change any ownership on the old staking vault + /// @param optimisticSelectorData Include Folio.startRebalance.selector if optimistic rebalancing should be enabled + /// @param optimisticProposers Use empty set to disable optimistic governance altogether + /// @param guardians Must be a subset of the old staking vault timelock's CANCELLER_ROLE members + function deploySuccessorStakingVault( + IFolioGovernor stakingVaultGovernor, + IReserveOptimisticGovernor.OptimisticGovernanceParams calldata optimisticParams, + IOptimisticSelectorRegistry.SelectorData[] calldata optimisticSelectorData, + address[] calldata optimisticProposers, + address[] calldata guardians, + address[] calldata rewardTokens, + bytes32 deploymentNonce + ) public returns (NewDeployment memory newDeployment) { + IStakingVault oldStakingVault = IStakingVault(stakingVaultGovernor.token()); + address newUnderlying = oldStakingVault.asset(); + require(newUnderlying != address(0), UpgradeError(21)); + + IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams = _baseDeploymentParams( + stakingVaultGovernor, + optimisticParams, + optimisticSelectorData, + optimisticProposers, + guardians + ); + IReserveOptimisticGovernorDeployer.NewStakingVaultParams + memory newStakingVaultParams = IReserveOptimisticGovernorDeployer.NewStakingVaultParams({ + underlying: IERC20Metadata(newUnderlying), + rewardTokens: rewardTokens, + rewardHalfLife: 3.5 days, + unstakingDelay: 1 weeks + }); + + ( + newDeployment.stakingVault, + newDeployment.newGovernor, + newDeployment.newTimelock, + newDeployment.newSelectorRegistry + ) = governorDeployer.deployWithNewStakingVault(baseParams, newStakingVaultParams, deploymentNonce); + + require(newDeployment.newTimelock != address(0), UpgradeError(22)); + require(IStakingVault(newDeployment.stakingVault).asset() == newUnderlying, UpgradeError(23)); + require(newDeployment.stakingVault != address(oldStakingVault), UpgradeError(24)); + require( + IAccessControlEnumerable(newDeployment.stakingVault).getRoleMemberCount(DEFAULT_ADMIN_ROLE) == 1, + UpgradeError(26) + ); + require( + IAccessControlEnumerable(newDeployment.stakingVault).getRoleMember(DEFAULT_ADMIN_ROLE, 0) == + newDeployment.newTimelock, + UpgradeError(27) + ); + + emit NewGovernanceDeployment(newDeployment); + } + + /// Deploy a new Folio governor/timelock on an existing staking vault and transfer Folio ownership/roles + /// @dev Requirements: + /// - Caller is old Folio timelock + /// - Self is Folio admin + /// - Self is FolioProxyAdmin owner + /// @dev New Governance system will use standard 2-3-2 day voting independent of previous voting settings + /// @param newStakingVault New staking vault to use for the new governor + /// @param oldFolioGovernor Governor currently attached to the Folio being upgraded + /// @param optimisticSelectorData Include Folio.startRebalance.selector if optimistic rebalancing should be enabled + /// @param optimisticProposers Use empty set to disable optimistic governance altogether + /// @param guardians Must be a subset of the old Folio timelock's CANCELLER_ROLE members + /// The shared Guardian contract will be included as a CANCELLER_ROLE member by default + function upgradeFolio( + Folio folio, + FolioProxyAdmin folioProxyAdmin, + IStakingVault newStakingVault, + IFolioGovernor oldFolioGovernor, + IReserveOptimisticGovernor.OptimisticGovernanceParams calldata optimisticParams, + IOptimisticSelectorRegistry.SelectorData[] calldata optimisticSelectorData, + address[] calldata optimisticProposers, + address[] calldata guardians, + bytes32 deploymentNonce + ) public returns (NewDeployment memory newDeployment) { + require(oldFolioGovernor.timelock() == msg.sender, UpgradeError(1)); + + newDeployment.stakingVault = address(newStakingVault); + (newDeployment.newGovernor, newDeployment.newTimelock, newDeployment.newSelectorRegistry) = governorDeployer + .deployWithExistingStakingVault( + _baseDeploymentParams( + oldFolioGovernor, + optimisticParams, + optimisticSelectorData, + optimisticProposers, + guardians + ), + address(newStakingVault), + deploymentNonce + ); + require(newDeployment.newTimelock != address(0), UpgradeError(2)); + + // newStakingVault must not be the old immmutable kind, must be new and upgradeable + require(keccak256(bytes(IVersioned(address(newStakingVault)).version())) == VERSION_1_0_0, UpgradeError(3)); + + // confirm Folio admins are self + old timelock + require(folio.getRoleMemberCount(DEFAULT_ADMIN_ROLE) == 2, UpgradeError(4)); + require(folio.hasRole(DEFAULT_ADMIN_ROLE, address(this)), UpgradeError(5)); + require(folio.hasRole(DEFAULT_ADMIN_ROLE, msg.sender), UpgradeError(6)); + + // rotate Folio fee recipients from old staking vault to new staking vault + _rotateFeeRecipients(folio, oldFolioGovernor.token(), address(newStakingVault)); + + // rotate Folio REBALANCE_MANAGERs + { + for (uint256 i = folio.getRoleMemberCount(REBALANCE_MANAGER); i > 0; i--) { + address rebalanceManager = folio.getRoleMember(REBALANCE_MANAGER, i - 1); + folio.revokeRole(REBALANCE_MANAGER, rebalanceManager); + } + } + folio.grantRole(REBALANCE_MANAGER, newDeployment.newTimelock); + require(folio.getRoleMemberCount(REBALANCE_MANAGER) == 1, UpgradeError(7)); + require(folio.getRoleMember(REBALANCE_MANAGER, 0) == newDeployment.newTimelock, UpgradeError(8)); + + // transfer Folio proxy admin ownership + require(folioProxyAdmin.owner() == address(this), UpgradeError(9)); + folioProxyAdmin.transferOwnership(newDeployment.newTimelock); + require(folioProxyAdmin.owner() == newDeployment.newTimelock, UpgradeError(10)); + + // rotate Folio DEFAULT_ADMIN_ROLE + folio.revokeRole(DEFAULT_ADMIN_ROLE, msg.sender); + folio.grantRole(DEFAULT_ADMIN_ROLE, newDeployment.newTimelock); + folio.renounceRole(DEFAULT_ADMIN_ROLE, address(this)); + require(folio.getRoleMemberCount(DEFAULT_ADMIN_ROLE) == 1, UpgradeError(11)); + require(folio.getRoleMember(DEFAULT_ADMIN_ROLE, 0) == newDeployment.newTimelock, UpgradeError(12)); + + emit NewGovernanceDeployment(newDeployment); + } + + /// Permanently retire an old StakingVault after every dependent Folio has upgraded + /// @dev IMPORTANT: Current governance must transfer ownership of `oldStakingVault` to this spell contract first + /// @dev Enumeration of dependent Folios is off-chain: current governance is responsible for + /// confirming no Folio governor still uses this StakingVault as its voting token. + function retireOldStakingVault(IOwnableStakingVault oldStakingVault) public { + require(oldStakingVault.owner() == address(this), UpgradeError(13)); + + oldStakingVault.setUnstakingDelay(0); + oldStakingVault.setRewardRatio(1 days); + oldStakingVault.renounceOwnership(); + require(oldStakingVault.owner() == address(0), UpgradeError(14)); + + emit StakingVaultRetired(address(oldStakingVault)); + } + + // === Internal === + + function _baseDeploymentParams( + IFolioGovernor oldGovernor, + IReserveOptimisticGovernor.OptimisticGovernanceParams calldata optimisticParams, + IOptimisticSelectorRegistry.SelectorData[] calldata optimisticSelectorData, + address[] calldata optimisticProposers, + address[] calldata guardians + ) internal view returns (IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams) { + IStakingVault oldStakingVault = IStakingVault(oldGovernor.token()); + + // Optimistic governance params + baseParams.optimisticParams = optimisticParams; + + // Standard governance params + baseParams.standardParams.votingDelay = 2 days; + baseParams.standardParams.votingPeriod = 3 days; + baseParams.standardParams.voteExtension = 2 days; + ( + baseParams.standardParams.proposalThreshold, + baseParams.standardParams.quorumNumerator + ) = _proposalThresholdAndQuorum(oldStakingVault, oldGovernor); + // hard-coded long standard governance params to unify across DTFs + + // Optimistic whitelists + baseParams.selectorData = optimisticSelectorData; + baseParams.optimisticProposers = optimisticProposers; + + // Guardians + _validateGuardians(oldGovernor, guardians); + baseParams.additionalGuardians = guardians; + + // Timelock delay + baseParams.timelockDelay = 2 days; + + // Proposal throttle + baseParams.proposalThrottleCapacity = 3; + } + + /// @return proposalThreshold D18{1} + /// @return quorumNumerator D18{1} + function _proposalThresholdAndQuorum( + IStakingVault stakingVault, + IFolioGovernor governor + ) internal view returns (uint256 proposalThreshold, uint256 quorumNumerator) { + uint256 pastSupply = stakingVault.getPastTotalSupply(stakingVault.clock() - 1); + + // {tok} + uint256 proposalThresholdWithSupply = governor.proposalThreshold(); + + // D18{1} = {tok} * D18{1} / {tok} + proposalThreshold = (proposalThresholdWithSupply * 1e18 + pastSupply - 1) / pastSupply; + require(proposalThreshold >= 0.0001e18 && proposalThreshold <= 0.1e18, UpgradeError(15)); + + uint256 quorumDenominator = governor.quorumDenominator(); + + // D18{1} + quorumNumerator = (governor.quorumNumerator() * 1e18 + quorumDenominator - 1) / quorumDenominator; + require(quorumNumerator >= 0.01e18 && quorumNumerator <= 0.25e18, UpgradeError(16)); + } + + /// Require `guardians` is a subset of the old timelock's CANCELLER_ROLE members (excl old governor) + /// @dev Does NOT confirm `guardians` is the complete set of CANCELLER_ROLE members + function _validateGuardians(IFolioGovernor oldGovernor, address[] memory guardians) internal view { + TimelockController oldTimelock = TimelockController(payable(oldGovernor.timelock())); + + for (uint256 i; i < guardians.length; i++) { + require(guardians[i] != address(0) && guardians[i] != address(oldGovernor), UpgradeError(17)); + require(oldTimelock.hasRole(CANCELLER_ROLE, guardians[i]), UpgradeError(18)); + } + } + + /// Rotate the fee recipient entry for the old StakingVault to the new StakingVault + function _rotateFeeRecipients(Folio folio, address oldStakingVault, address newStakingVault) internal { + IFolio.FeeRecipient[] memory recipients = _feeRecipients(folio); + uint256 oldStakingVaultRecipientCount; + uint256 oldStakingVaultRecipientIndex; + + for (uint256 i; i < recipients.length; i++) { + address recipient = recipients[i].recipient; + + if (recipient == oldStakingVault) { + oldStakingVaultRecipientCount++; + oldStakingVaultRecipientIndex = i; + } + + require(recipient != newStakingVault, UpgradeError(19)); + } + + require(oldStakingVaultRecipientCount == 1, UpgradeError(20)); + + recipients[oldStakingVaultRecipientIndex].recipient = newStakingVault; + _sortFeeRecipients(recipients); + folio.setFeeRecipients(recipients); + } + + function _feeRecipients(Folio folio) internal view returns (IFolio.FeeRecipient[] memory recipients) { + // no accessor for feeRecipients.length + uint256 length; + for (; length < MAX_FEE_RECIPIENTS; length++) { + try folio.feeRecipients(length) returns (address, uint96) { + // no-op + } catch { + break; + } + } + + recipients = new IFolio.FeeRecipient[](length); + for (uint256 i; i < length; i++) { + (address recipient, uint96 portion) = folio.feeRecipients(i); + recipients[i] = IFolio.FeeRecipient({ recipient: recipient, portion: portion }); + } + } + + function _sortFeeRecipients(IFolio.FeeRecipient[] memory recipients) internal pure { + for (uint256 i = 1; i < recipients.length; i++) { + IFolio.FeeRecipient memory recipient = recipients[i]; + uint256 j = i; + while (j > 0 && uint160(recipients[j - 1].recipient) > uint160(recipient.recipient)) { + recipients[j] = recipients[j - 1]; + j--; + } + recipients[j] = recipient; + } + } +} diff --git a/package.json b/package.json index b42df71e..827bf9b0 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "@openzeppelin/contracts-upgradeable": "5.1.0", "@prb/math": "4.1.0", "@reserve-protocol/trusted-fillers": "github:reserve-protocol/trusted-fillers#eabd9dbd60e8aaaec2c50db43742991e4cb55208", - "@reserve-protocol/reserve-governor": "github:reserve-protocol/reserve-governor#c2b17012567bafcb99b03d20f31d516fd5482f93", + "@reserve-protocol/reserve-governor": "github:reserve-protocol/reserve-governor#3e6c3af0e3b6db36c9135f9206f65ec20c0fd4f0", "forge-std": "github:foundry-rs/forge-std#v1.14.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28e24f1a..67b3fb71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: 4.1.0 version: 4.1.0 '@reserve-protocol/reserve-governor': - specifier: github:reserve-protocol/reserve-governor#c2b17012567bafcb99b03d20f31d516fd5482f93 - version: https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/c2b17012567bafcb99b03d20f31d516fd5482f93(cross-fetch@3.2.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)(typescript@5.9.3) + specifier: github:reserve-protocol/reserve-governor#3e6c3af0e3b6db36c9135f9206f65ec20c0fd4f0 + version: https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/3e6c3af0e3b6db36c9135f9206f65ec20c0fd4f0 '@reserve-protocol/trusted-fillers': specifier: github:reserve-protocol/trusted-fillers#eabd9dbd60e8aaaec2c50db43742991e4cb55208 version: https://codeload.github.com/reserve-protocol/trusted-fillers/tar.gz/eabd9dbd60e8aaaec2c50db43742991e4cb55208(cross-fetch@3.2.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)(typescript@5.9.3) @@ -457,13 +457,10 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} - '@reserve-protocol/reserve-governor@https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/c2b17012567bafcb99b03d20f31d516fd5482f93': - resolution: {tarball: https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/c2b17012567bafcb99b03d20f31d516fd5482f93} + '@reserve-protocol/reserve-governor@https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/3e6c3af0e3b6db36c9135f9206f65ec20c0fd4f0': + resolution: {tarball: https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/3e6c3af0e3b6db36c9135f9206f65ec20c0fd4f0} version: 0.1.0 - '@reserve-protocol/reserve-index-dtf@file:': - resolution: {directory: '', type: directory} - '@reserve-protocol/trusted-fillers@https://codeload.github.com/reserve-protocol/trusted-fillers/tar.gz/eabd9dbd60e8aaaec2c50db43742991e4cb55208': resolution: {tarball: https://codeload.github.com/reserve-protocol/trusted-fillers/tar.gz/eabd9dbd60e8aaaec2c50db43742991e4cb55208} version: 0.1.0 @@ -2013,41 +2010,12 @@ snapshots: '@protobufjs/utf8@1.1.0': {} - '@reserve-protocol/reserve-governor@https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/c2b17012567bafcb99b03d20f31d516fd5482f93(cross-fetch@3.2.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)(typescript@5.9.3)': + '@reserve-protocol/reserve-governor@https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/3e6c3af0e3b6db36c9135f9206f65ec20c0fd4f0': dependencies: '@openzeppelin/contracts': 5.4.0 '@openzeppelin/contracts-upgradeable': 5.4.0(@openzeppelin/contracts@5.4.0) - '@reserve-protocol/reserve-index-dtf': file:(cross-fetch@3.2.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)(typescript@5.9.3) - forge-std: https://codeload.github.com/foundry-rs/forge-std/tar.gz/1801b0541f4fda118a10798fd3486bb7051c5dd6 - transitivePeerDependencies: - - bufferutil - - cross-fetch - - encoding - - ipfs-only-hash - - multiformats - - supports-color - - typescript - - utf-8-validate - - zod - - '@reserve-protocol/reserve-index-dtf@file:(cross-fetch@3.2.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)(typescript@5.9.3)': - dependencies: - '@openzeppelin/contracts': 5.1.0 - '@openzeppelin/contracts-upgradeable': 5.1.0(@openzeppelin/contracts@5.1.0) '@prb/math': 4.1.0 - '@reserve-protocol/reserve-governor': https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/c2b17012567bafcb99b03d20f31d516fd5482f93(cross-fetch@3.2.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)(typescript@5.9.3) - '@reserve-protocol/trusted-fillers': https://codeload.github.com/reserve-protocol/trusted-fillers/tar.gz/eabd9dbd60e8aaaec2c50db43742991e4cb55208(cross-fetch@3.2.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)(typescript@5.9.3) forge-std: https://codeload.github.com/foundry-rs/forge-std/tar.gz/1801b0541f4fda118a10798fd3486bb7051c5dd6 - transitivePeerDependencies: - - bufferutil - - cross-fetch - - encoding - - ipfs-only-hash - - multiformats - - supports-color - - typescript - - utf-8-validate - - zod '@reserve-protocol/trusted-fillers@https://codeload.github.com/reserve-protocol/trusted-fillers/tar.gz/eabd9dbd60e8aaaec2c50db43742991e4cb55208(cross-fetch@3.2.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)(typescript@5.9.3)': dependencies: diff --git a/remappings.txt b/remappings.txt index c0c9c662..daea278d 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,6 +1,7 @@ forge-std/=node_modules/forge-std/src/ @openzeppelin/contracts/=node_modules/@openzeppelin/contracts/ @reserve-protocol/trusted-fillers/=node_modules/@reserve-protocol/trusted-fillers/ +@reserve-protocol/reserve-governor/=node_modules/@reserve-protocol/reserve-governor/ @openzeppelin/contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/ @prb/math/=node_modules/@prb/math/ utils/=test/utils/ @@ -14,4 +15,4 @@ utils/=test/utils/ @staking/=contracts/staking/ @deployer/=contracts/deployer/ @spells/=contracts/spells/ -@periphery/=contracts/periphery/ \ No newline at end of file +@periphery/=contracts/periphery/ diff --git a/test/base/BaseTest.sol b/test/base/BaseTest.sol index 02cc3e98..75b773e4 100644 --- a/test/base/BaseTest.sol +++ b/test/base/BaseTest.sol @@ -85,7 +85,8 @@ abstract contract BaseTest is Script, Test { enum ForkNetwork { ETHEREUM, - BASE + BASE, + BSC } struct DeploymentData { @@ -163,6 +164,8 @@ abstract contract BaseTest is Script, Test { forkRpc = vm.envString("FORK_RPC_MAINNET"); } else if (target == ForkNetwork.BASE) { forkRpc = vm.envString("FORK_RPC_BASE"); + } else if (target == ForkNetwork.BSC) { + forkRpc = vm.envString("FORK_RPC_BSC"); } } diff --git a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol new file mode 100644 index 00000000..f493cb7d --- /dev/null +++ b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol @@ -0,0 +1,531 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import "../../base/BaseTest.sol"; + +import { IGovernor } from "@openzeppelin/contracts/governance/IGovernor.sol"; +import { IVotes } from "@openzeppelin/contracts/governance/utils/IVotes.sol"; +import { IAccessControlEnumerable } from "@openzeppelin/contracts/access/extensions/IAccessControlEnumerable.sol"; +import { console2 } from "forge-std/console2.sol"; + +import { GovernanceSpell_04_17_2026, IFolioGovernor, IOwnableStakingVault, IStakingVault } from "@spells/GovernanceSpell_04_17_2026.sol"; +import { StakingVaultDeployer } from "@reserve-protocol/reserve-governor/contracts/artifacts/StakingVaultDeployer.sol"; +import { ReserveOptimisticGovernorDeployer } from "@reserve-protocol/reserve-governor/contracts/artifacts/ReserveOptimisticGovernorDeployer.sol"; +import { TimelockControllerOptimisticDeployer } from "@reserve-protocol/reserve-governor/contracts/artifacts/TimelockControllerOptimisticDeployer.sol"; +import { OptimisticSelectorRegistryDeployer } from "@reserve-protocol/reserve-governor/contracts/artifacts/OptimisticSelectorRegistryDeployer.sol"; +import { ReserveOptimisticGovernorDeployerDeployer } from "@reserve-protocol/reserve-governor/contracts/artifacts/ReserveOptimisticGovernorDeployerDeployer.sol"; +import { IReserveOptimisticGovernorDeployer } from "@reserve-protocol/reserve-governor/contracts/interfaces/IDeployer.sol"; +import { IReserveOptimisticGovernor } from "@reserve-protocol/reserve-governor/contracts/interfaces/IReserveOptimisticGovernor.sol"; +import { IOptimisticSelectorRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IOptimisticSelectorRegistry.sol"; +import { IRoleRegistry as IRewardRoleRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IRoleRegistry.sol"; +import { RewardTokenRegistry } from "@reserve-protocol/reserve-governor/contracts/staking/RewardTokenRegistry.sol"; +import { REBALANCE_MANAGER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; +import { MockRoleRegistry } from "utils/MockRoleRegistry.sol"; + +interface IVersionedLike { + function version() external view returns (string memory); +} + +contract MockGovernanceVersionRegistry { + IReserveOptimisticGovernorDeployer private _latestDeployer; + bytes32 private _latestVersionHash; + + function registerVersion(IReserveOptimisticGovernorDeployer deployer) external { + _latestDeployer = deployer; + _latestVersionHash = keccak256(bytes(IVersionedLike(address(deployer)).version())); + } + + function getLatestVersion() + external + view + returns ( + bytes32 versionHash, + string memory version, + IReserveOptimisticGovernorDeployer deployer, + bool deprecated + ) + { + deployer = _latestDeployer; + versionHash = _latestVersionHash; + version = IVersionedLike(address(deployer)).version(); + deprecated = false; + } +} + +interface IReserveOptimisticGovernorLike is IFolioGovernor { + function proposeOptimistic( + address[] calldata targets, + uint256[] calldata values, + bytes[] calldata calldatas, + string calldata description + ) external returns (uint256); + + function isOptimistic(uint256 proposalId) external view returns (bool); +} + +interface IRetiredStakingVaultLike is IOwnableStakingVault { + function unstakingDelay() external view returns (uint256); +} + +interface IRewardedStakingVaultLike is IStakingVault { + function getAllRewardTokens() external view returns (address[] memory); +} + +abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { + struct Config { + Folio folio; + FolioProxyAdmin proxyAdmin; + IFolioGovernor stakingVaultGovernor; + IFolioGovernor oldFolioGovernor; + address[] guardians; + } + + struct SuccessorDeployment { + address newStakingVault; + address newGovernor; + address newTimelock; + address newSelectorRegistry; + } + + Config[] public CONFIGS; + GovernanceSpell_04_17_2026 public spell; + RewardTokenRegistry public rewardTokenRegistry; + IReserveOptimisticGovernorDeployer public optimisticGovernanceDeployer; + + function _setUp() public virtual override { + super._setUp(); + _deployOptimisticGovernanceDeployer(); + spell = new GovernanceSpell_04_17_2026(optimisticGovernanceDeployer); + } + + function test_upgradeFlow_fork() public { + for (uint256 i; i < CONFIGS.length; i++) { + Config memory cfg = CONFIGS[i]; + _logFolioSymbol(cfg.folio); + _runUpgradeFlowCase(cfg, i); + } + } + + // === Internal === + + function _logFolioSymbol(Folio folio) internal view { + console2.log("Folio symbol", folio.symbol()); + } + + function _upgradeFolio( + Config memory cfg, + IStakingVault newStakingVault, + address optimisticProposer, + bytes32 deploymentNonce + ) internal returns (GovernanceSpell_04_17_2026.NewDeployment memory dep) { + address oldFolioTimelock = cfg.oldFolioGovernor.timelock(); + assertEq(cfg.proxyAdmin.owner(), oldFolioTimelock, "old folio timelock should own proxy admin"); + + vm.startPrank(oldFolioTimelock); + cfg.proxyAdmin.transferOwnership(address(spell)); + cfg.folio.grantRole(DEFAULT_ADMIN_ROLE, address(spell)); + dep = spell.upgradeFolio( + cfg.folio, + cfg.proxyAdmin, + newStakingVault, + cfg.oldFolioGovernor, + _optimisticParams(), + _selectorDataForFolio(cfg.folio, optimisticProposer), + _singleAddressArray(optimisticProposer), + cfg.guardians, + deploymentNonce + ); + vm.stopPrank(); + } + + function _runUpgradeFlowCase(Config memory cfg, uint256 configIndex) internal { + uint256 snapshot = vm.snapshotState(); + IOwnableStakingVault oldStakingVault = IOwnableStakingVault(cfg.stakingVaultGovernor.token()); + SuccessorDeployment memory stakingVaultDep; + + { + address oldStakingVaultOwner = oldStakingVault.owner(); + address newUnderlying = IStakingVault(address(oldStakingVault)).asset(); + address[] memory expectedRewardTokens = _rewardTokensForUnderlying( + newUnderlying, + _singleAddressArray(address(cfg.folio)) + ); + + stakingVaultDep = _deploySuccessorStakingVault( + cfg, + _singleAddressArray(address(cfg.folio)), + new address[](0), + keccak256(abi.encode(configIndex, "proposal-new")) + ); + + assertEq(oldStakingVault.owner(), oldStakingVaultOwner, "step 1 should not alter old vault owner"); + _assertSuccessorStakingVaultDeployment( + address(oldStakingVault), + stakingVaultDep, + newUnderlying, + expectedRewardTokens + ); + } + + { + address oldFolioStakingVault = cfg.oldFolioGovernor.token(); + address folioOptimisticProposer = makeAddr(string.concat("new-folio-opt-", vm.toString(configIndex))); + address standardProposer = makeAddr(string.concat("new-std-", vm.toString(configIndex))); + uint96 oldVaultFeePortionBefore = _feeRecipientPortion(cfg.folio, oldFolioStakingVault); + uint96 newVaultFeePortionBefore = _feeRecipientPortion(cfg.folio, stakingVaultDep.newStakingVault); + assertGt(uint256(oldVaultFeePortionBefore), 0, "old vault should receive folio fees"); + + GovernanceSpell_04_17_2026.NewDeployment memory folioDep = _upgradeFolio( + cfg, + IStakingVault(stakingVaultDep.newStakingVault), + folioOptimisticProposer, + keccak256(abi.encode(configIndex, "folio-new")) + ); + + assertEq(folioDep.stakingVault, stakingVaultDep.newStakingVault, "folio upgrade should return new vault"); + assertTrue(folioDep.newGovernor != stakingVaultDep.newGovernor, "folio governor should be distinct"); + assertTrue(folioDep.newTimelock != stakingVaultDep.newTimelock, "folio timelock should be distinct"); + assertEq(IFolioGovernor(folioDep.newGovernor).timelock(), folioDep.newTimelock, "admin mismatch"); + assertEq( + IFolioGovernor(folioDep.newGovernor).token(), + stakingVaultDep.newStakingVault, + "folio governor should use the upgraded staking vault" + ); + _assertFolioGovernanceInstalled(cfg, folioDep.newTimelock); + _assertFeeRecipientMigrated( + cfg.folio, + oldFolioStakingVault, + stakingVaultDep.newStakingVault, + oldVaultFeePortionBefore, + newVaultFeePortionBefore + ); + + _assertCanCreateBothProposalTypes( + IReserveOptimisticGovernorLike(folioDep.newGovernor), + IStakingVault(stakingVaultDep.newStakingVault), + cfg.folio, + standardProposer, + folioOptimisticProposer + ); + } + + _retireOldStakingVault(oldStakingVault); + + vm.revertToState(snapshot); + } + + function _deploySuccessorStakingVault( + Config memory cfg, + address[] memory folios, + address[] memory optimisticProposers, + bytes32 deploymentNonce + ) internal returns (SuccessorDeployment memory dep) { + address newUnderlying = IStakingVault(cfg.stakingVaultGovernor.token()).asset(); + address[] memory rewardTokens = _rewardTokensForUnderlying(newUnderlying, folios); + _registerRewardTokens(rewardTokens); + + address permissionlessCaller = makeAddr("permissionless-step1-caller"); + GovernanceSpell_04_17_2026.NewDeployment memory newDeployment; + vm.prank(permissionlessCaller); + newDeployment = spell.deploySuccessorStakingVault( + cfg.stakingVaultGovernor, + _optimisticParams(), + _selectorDataForStartRebalanceFolios(folios), + optimisticProposers, + cfg.guardians, + rewardTokens, + deploymentNonce + ); + + dep = SuccessorDeployment({ + newStakingVault: newDeployment.stakingVault, + newGovernor: newDeployment.newGovernor, + newTimelock: newDeployment.newTimelock, + newSelectorRegistry: newDeployment.newSelectorRegistry + }); + } + + function _assertSuccessorStakingVaultDeployment( + address oldStakingVault, + SuccessorDeployment memory dep, + address newUnderlying, + address[] memory expectedRewardTokens + ) internal view { + assertTrue(dep.newStakingVault != oldStakingVault, "expected new staking vault path"); + assertEq(IStakingVault(dep.newStakingVault).asset(), newUnderlying, "new vault asset mismatch"); + assertEq( + keccak256(bytes(IStakingVault(dep.newStakingVault).version())), + keccak256(bytes("1.0.0")), + "new vault version mismatch" + ); + assertEq(IFolioGovernor(dep.newGovernor).timelock(), dep.newTimelock, "governor timelock mismatch"); + assertEq( + IAccessControlEnumerable(dep.newStakingVault).getRoleMemberCount(DEFAULT_ADMIN_ROLE), + 1, + "unexpected new vault admin count" + ); + assertTrue( + IAccessControlEnumerable(dep.newStakingVault).hasRole(DEFAULT_ADMIN_ROLE, dep.newTimelock), + "new vault admin mismatch" + ); + _assertRewardTokens(dep.newStakingVault, expectedRewardTokens); + } + + function _assertFolioGovernanceInstalled(Config memory cfg, address expectedTimelock) internal view { + assertEq(cfg.proxyAdmin.owner(), expectedTimelock, "proxy admin owner mismatch"); + assertEq(cfg.folio.getRoleMemberCount(REBALANCE_MANAGER), 1, "unexpected rebalance manager count"); + assertEq(cfg.folio.getRoleMember(REBALANCE_MANAGER, 0), expectedTimelock, "rebalance manager mismatch"); + assertEq(cfg.folio.getRoleMemberCount(DEFAULT_ADMIN_ROLE), 1, "unexpected admin count"); + assertEq(cfg.folio.getRoleMember(DEFAULT_ADMIN_ROLE, 0), expectedTimelock, "admin mismatch"); + } + + function _assertRewardTokens(address stakingVault, address[] memory expectedRewardTokens) internal view { + address[] memory rewardTokens = IRewardedStakingVaultLike(stakingVault).getAllRewardTokens(); + assertEq(rewardTokens.length, expectedRewardTokens.length, "unexpected reward token count"); + + for (uint256 i; i < expectedRewardTokens.length; i++) { + assertEq(rewardTokens[i], expectedRewardTokens[i], "reward token mismatch"); + } + } + + function _retireOldStakingVault(IOwnableStakingVault oldStakingVault) internal { + address oldStakingVaultOwner = oldStakingVault.owner(); + assertTrue(oldStakingVaultOwner != address(0), "old vault should still be owned"); + + vm.startPrank(oldStakingVaultOwner); + oldStakingVault.transferOwnership(address(spell)); + spell.retireOldStakingVault(oldStakingVault); + vm.stopPrank(); + + assertEq(oldStakingVault.owner(), address(0), "old vault should be retired"); + assertEq(IRetiredStakingVaultLike(address(oldStakingVault)).unstakingDelay(), 0, "old vault should unlock"); + } + + function _assertCanCreateBothProposalTypes( + IReserveOptimisticGovernorLike governor, + IStakingVault stakingVault, + Folio folio, + address standardProposer, + address optimisticProposer + ) internal { + uint256 proposalThreshold = governor.proposalThreshold(); + _seedVotes(address(stakingVault), standardProposer, proposalThreshold + 1e18); + + // Standard proposal (pessimistic path) + ( + address[] memory standardTargets, + uint256[] memory standardValues, + bytes[] memory standardCalldatas + ) = _singleCall(address(folio), 0, abi.encodeCall(Folio.setName, ("standard proposal"))); + + vm.prank(standardProposer); + uint256 standardProposalId = governor.propose( + standardTargets, + standardValues, + standardCalldatas, + "standard proposal" + ); + assertEq(uint256(governor.state(standardProposalId)), uint256(IGovernor.ProposalState.Pending)); + assertFalse(governor.isOptimistic(standardProposalId)); + + // Optimistic proposal (fast path) + ( + address[] memory optimisticTargets, + uint256[] memory optimisticValues, + bytes[] memory optimisticCalldatas + ) = _singleCall(address(folio), 0, abi.encodeCall(Folio.setName, ("optimistic proposal"))); + + vm.prank(optimisticProposer); + uint256 optimisticProposalId = governor.proposeOptimistic( + optimisticTargets, + optimisticValues, + optimisticCalldatas, + "optimistic proposal" + ); + assertEq(uint256(governor.state(optimisticProposalId)), uint256(IGovernor.ProposalState.Pending)); + assertTrue(governor.isOptimistic(optimisticProposalId)); + } + + function _assertCanCreateOptimisticStartRebalanceProposal( + IReserveOptimisticGovernorLike governor, + Folio folio, + address optimisticProposer, + string memory description + ) internal { + ( + address[] memory optimisticTargets, + uint256[] memory optimisticValues, + bytes[] memory optimisticCalldatas + ) = _singleCall(address(folio), 0, _startRebalanceCalldata()); + + vm.prank(optimisticProposer); + uint256 optimisticProposalId = governor.proposeOptimistic( + optimisticTargets, + optimisticValues, + optimisticCalldatas, + description + ); + assertEq(uint256(governor.state(optimisticProposalId)), uint256(IGovernor.ProposalState.Pending)); + assertTrue(governor.isOptimistic(optimisticProposalId)); + } + + function _seedVotes(address stakingVault, address voter, uint256 amount) internal { + deal(stakingVault, voter, amount, true); + vm.prank(voter); + IVotes(stakingVault).delegate(voter); + vm.warp(block.timestamp + 1); + } + + function _selectorDataForFolio( + Folio folio, + address + ) internal pure returns (IOptimisticSelectorRegistry.SelectorData[] memory selectorData) { + selectorData = new IOptimisticSelectorRegistry.SelectorData[](1); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = Folio.setName.selector; + selectorData[0] = IOptimisticSelectorRegistry.SelectorData({ target: address(folio), selectors: selectors }); + } + + function _selectorDataForStartRebalanceFolios( + address[] memory folios + ) internal pure returns (IOptimisticSelectorRegistry.SelectorData[] memory selectorData) { + selectorData = new IOptimisticSelectorRegistry.SelectorData[](folios.length); + + for (uint256 i; i < folios.length; i++) { + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = Folio.startRebalance.selector; + selectorData[i] = IOptimisticSelectorRegistry.SelectorData({ target: folios[i], selectors: selectors }); + } + } + + function _rewardTokensForUnderlying( + address newUnderlying, + address[] memory folios + ) internal pure returns (address[] memory rewardTokens) { + uint256 rewardTokenCount; + address[] memory uniqueRewardTokens = new address[](folios.length); + + for (uint256 i; i < folios.length; i++) { + address folio = folios[i]; + if (folio == address(0) || folio == newUnderlying) continue; + + bool alreadyAdded; + for (uint256 j; j < rewardTokenCount; j++) { + if (uniqueRewardTokens[j] == folio) { + alreadyAdded = true; + break; + } + } + if (alreadyAdded) continue; + + uniqueRewardTokens[rewardTokenCount] = folio; + rewardTokenCount++; + } + + rewardTokens = new address[](rewardTokenCount); + for (uint256 i; i < rewardTokenCount; i++) { + rewardTokens[i] = uniqueRewardTokens[i]; + } + } + + function _deployOptimisticGovernanceDeployer() internal { + MockGovernanceVersionRegistry governanceVersionRegistry = new MockGovernanceVersionRegistry(); + MockRoleRegistry rewardRoleRegistry = new MockRoleRegistry(); + rewardTokenRegistry = new RewardTokenRegistry(IRewardRoleRegistry(address(rewardRoleRegistry))); + + address stakingVaultImpl = StakingVaultDeployer.deploy(bytes32(uint256(1))); + address governorImpl = ReserveOptimisticGovernorDeployer.deploy(bytes32(uint256(2))); + address timelockImpl = TimelockControllerOptimisticDeployer.deploy(bytes32(uint256(3))); + address selectorRegistryImpl = OptimisticSelectorRegistryDeployer.deploy(bytes32(uint256(4))); + + optimisticGovernanceDeployer = IReserveOptimisticGovernorDeployer( + ReserveOptimisticGovernorDeployerDeployer.deploy( + address(governanceVersionRegistry), + address(rewardTokenRegistry), + user1, + stakingVaultImpl, + governorImpl, + timelockImpl, + selectorRegistryImpl, + bytes32(uint256(5)) + ) + ); + governanceVersionRegistry.registerVersion(optimisticGovernanceDeployer); + } + + function _registerRewardToken(address rewardToken) internal { + if (rewardToken == address(0) || rewardTokenRegistry.isRegistered(rewardToken)) return; + + rewardTokenRegistry.registerRewardToken(rewardToken); + } + + function _registerRewardTokens(address[] memory rewardTokens) internal { + for (uint256 i; i < rewardTokens.length; i++) { + _registerRewardToken(rewardTokens[i]); + } + } + + function _singleAddressArray(address value) internal pure returns (address[] memory arr) { + arr = new address[](1); + arr[0] = value; + } + + function _doubleAddressArray(address first, address second) internal pure returns (address[] memory arr) { + arr = new address[](2); + arr[0] = first; + arr[1] = second; + } + + function _feeRecipientPortion(Folio folio, address recipient) internal view returns (uint96 portion) { + for (uint256 i; i < MAX_FEE_RECIPIENTS; i++) { + try folio.feeRecipients(i) returns (address feeRecipient, uint96 feePortion) { + if (feeRecipient == recipient) portion += feePortion; + } catch { + break; + } + } + } + + function _assertFeeRecipientMigrated( + Folio folio, + address oldStakingVault, + address newStakingVault, + uint96 oldVaultFeePortionBefore, + uint96 newVaultFeePortionBefore + ) internal view { + assertEq(_feeRecipientPortion(folio, oldStakingVault), 0, "old vault should not receive folio fees"); + assertEq( + _feeRecipientPortion(folio, newStakingVault), + oldVaultFeePortionBefore + newVaultFeePortionBefore, + "new vault should receive migrated folio fee share" + ); + } + + function _singleCall( + address target, + uint256 value, + bytes memory calldata_ + ) internal pure returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) { + targets = new address[](1); + values = new uint256[](1); + calldatas = new bytes[](1); + targets[0] = target; + values[0] = value; + calldatas[0] = calldata_; + } + + function _startRebalanceCalldata() internal pure returns (bytes memory calldata_) { + IFolio.TokenRebalanceParams[] memory tokens = new IFolio.TokenRebalanceParams[](0); + IFolio.RebalanceLimits memory limits = IFolio.RebalanceLimits({ low: 1, spot: 1, high: 1 }); + calldata_ = abi.encodeCall(Folio.startRebalance, (tokens, limits, 0, 1)); + } + + function _optimisticParams() internal pure returns (IReserveOptimisticGovernor.OptimisticGovernanceParams memory) { + return + IReserveOptimisticGovernor.OptimisticGovernanceParams({ + vetoDelay: 1 seconds, + vetoPeriod: 1 days, + vetoThreshold: 0.05e18 + }); + } +} diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol new file mode 100644 index 00000000..8cd6ce1d --- /dev/null +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import "./GenericGovernanceSpell_04_17_2026.t.sol"; + +contract GovernanceSpellBase_04_17_2026_Test is GenericGovernanceSpell_04_17_2026_Test { + constructor() { + deploymentData = DeploymentData({ + deploymentType: Deployment.FORK, + forkTarget: ForkNetwork.BASE, + forkBlock: 42440100 + }); + + // LCAP + { + address[] memory guardians = new address[](3); + guardians[0] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; + guardians[1] = 0x5e6EF4cFd64e29981fB3a8703a584DDE407032d2; + guardians[2] = 0x718841C68eab4038EF389C154f8e91f9923b2fdA; + + CONFIGS.push( + Config({ + folio: Folio(0x4dA9A0f397dB1397902070f93a4D6ddBC0E0E6e8), + proxyAdmin: FolioProxyAdmin(0xf6Db82f6b5F343d74A1D88af9e58fA1d2D89562e), + stakingVaultGovernor: IFolioGovernor(0x2DEE428BD8131FAa4288750d707De6F3901AfE3c), + oldFolioGovernor: IFolioGovernor(0x719eDEd05c7a6468E44AcFBBD19b2DF2EED7759E), + guardians: guardians + }) + ); + } + + // VLONE + { + address[] memory guardians = new address[](2); + guardians[0] = 0x82a28b41DF407a99Eb13F975856AaeEb757B98f4; + guardians[1] = 0x7f7bf1d0B4bb7395bb68E99e20C732f3AEFFfe47; + + CONFIGS.push( + Config({ + folio: Folio(0xe00CFa595841fb331105b93C19827797C925E3E4), + proxyAdmin: FolioProxyAdmin(0x17747f766e375a73959EBc0dBc623A174D4DB317), + stakingVaultGovernor: IFolioGovernor(0x42F72247FeFe2a4702e7E7aa71E3e1784c46f6Ae), + oldFolioGovernor: IFolioGovernor(0xA4556436cc4547F07DC3E61474Ae5E839fF3D150), + guardians: guardians + }) + ); + } + + // BGCI + { + address[] memory guardians = new address[](2); + guardians[0] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; + guardians[1] = 0xD8B0F4e54a8dac04E0A57392f5A630cEdb99C940; + + CONFIGS.push( + Config({ + folio: Folio(0x23418De10d422AD71C9D5713a2B8991a9c586443), + proxyAdmin: FolioProxyAdmin(0x2330a29DE3238b07b4a1Db70a244A25b8f21ab91), + stakingVaultGovernor: IFolioGovernor(0xbe8DDD7A3ad097DFa84EaBF4D57a879d0c41a148), + oldFolioGovernor: IFolioGovernor(0x858c2C08B4984AD4f045F8Bf6D85B916b723ed5b), + guardians: guardians + }) + ); + } + + // ZORA + { + address[] memory guardians = new address[](3); + guardians[0] = 0x6BC2F0cefE18ec4e5AFEB8f810c7063BeD3f92B9; + guardians[1] = 0x12808Cfbf64BE76aca0B13c523985BBb88015401; + guardians[2] = 0x7f7bf1d0B4bb7395bb68E99e20C732f3AEFFfe47; + + CONFIGS.push( + Config({ + folio: Folio(0x160c18476F6f5099f374033fbc695c9234Cda495), + proxyAdmin: FolioProxyAdmin(0xE6179EEF5312487e6caB447356c855eEE805781E), + stakingVaultGovernor: IFolioGovernor(0xE54C0534D71BAaCdeC2B9D0C576d73D76fef0869), + oldFolioGovernor: IFolioGovernor(0xD71981CC95f29077199B4cABE601BE78B662a88C), + guardians: guardians + }) + ); + } + + // AIndex + { + address[] memory guardians = new address[](2); + guardians[0] = 0x5edB66B4c01355B07dF3Ea9e4c2508e4Cc542c6a; + guardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; + + CONFIGS.push( + Config({ + folio: Folio(0xfe45EDa533e97198d9f3dEEDA9aE6c147141f6F9), + proxyAdmin: FolioProxyAdmin(0x456219b7897384217ca224f735DBbC30c395C87F), + stakingVaultGovernor: IFolioGovernor(0x61FA1b18F37A361E961c5fB07D730EE37DC0dC4d), + oldFolioGovernor: IFolioGovernor(0x26305E88587ecFde34a9DCE37D7CB292a3b51B02), + guardians: guardians + }) + ); + } + + // CLANKER + { + address[] memory guardians = new address[](2); + guardians[0] = 0x1eaf444ebDf6495C57aD52A04C61521bBf564ace; + guardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; + + CONFIGS.push( + Config({ + folio: Folio(0x44551CA46Fa5592bb572E20043f7C3D54c85cAD7), + proxyAdmin: FolioProxyAdmin(0x4472F1f3aD832Bed3FDeF75ace6540c2f3E5a187), + stakingVaultGovernor: IFolioGovernor(0xa83E456ebC4bCED953e64F085c8A8C4E2a8a5Fa0), + oldFolioGovernor: IFolioGovernor(0x1C58617D79daeE2F51DA6c98186334431D338721), + guardians: guardians + }) + ); + } + + // VIRTUALS + { + address[] memory guardians = new address[](2); + guardians[0] = 0x50B7a52556e0746F190663fc58a8133427fB6be2; + guardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; + + CONFIGS.push( + Config({ + folio: Folio(0x47686106181b3CEfe4eAf94C4c10b48Ac750370b), + proxyAdmin: FolioProxyAdmin(0x7C1fAFfc7F3a52aa9Dbd265E5709202eeA3A8A48), + stakingVaultGovernor: IFolioGovernor(0xD8f869c8d9EE22f4dD786EA37eFcd236810F9942), + oldFolioGovernor: IFolioGovernor(0xA8Ce43762De703D285B019fAC8829148e3013442), + guardians: guardians + }) + ); + } + + // BDTF + { + address[] memory guardians = new address[](2); + guardians[0] = 0xA80149d051764f9e4854ee83B197bAD648046d51; + guardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; + + CONFIGS.push( + Config({ + folio: Folio(0xb8753941196692E322846cfEE9C14C97AC81928A), + proxyAdmin: FolioProxyAdmin(0xADC76fB0A5ae3495443E8df8D411FD37a836F763), + stakingVaultGovernor: IFolioGovernor(0xAD3e49d114F193583c1904f93EF25784C381874b), + oldFolioGovernor: IFolioGovernor(0x0D5a4a0FEe1c4f0422938608400d00B9E0037684), + guardians: guardians + }) + ); + } + } +} diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBsc_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBsc_04_17_2026.t.sol new file mode 100644 index 00000000..623737ee --- /dev/null +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBsc_04_17_2026.t.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import "./GenericGovernanceSpell_04_17_2026.t.sol"; + +contract GovernanceSpellBsc_04_17_2026_Test is GenericGovernanceSpell_04_17_2026_Test { + constructor() { + deploymentData = DeploymentData({ + deploymentType: Deployment.FORK, + forkTarget: ForkNetwork.BSC, + forkBlock: 82987668 + }); + + // CMC20 + { + address[] memory guardians = new address[](2); + guardians[0] = 0x7f7bf1d0B4bb7395bb68E99e20C732f3AEFFfe47; + guardians[1] = 0xF49BCA9c5119e340E01Af83E452F0A27A5321898; + + CONFIGS.push( + Config({ + folio: Folio(0x2f8A339B5889FfaC4c5A956787cdA593b3c36867), + proxyAdmin: FolioProxyAdmin(0x91a42b577189A52F211E830b73dc5479D611579A), + stakingVaultGovernor: IFolioGovernor(0x3D047aBc5b95BC9989904c557789C1bCf3057d99), + oldFolioGovernor: IFolioGovernor(0x6304135c135DA8553d66b0065C8A7c3b0d16c1e8), + guardians: guardians + }) + ); + } + } +} diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol new file mode 100644 index 00000000..847d32d7 --- /dev/null +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import "./GenericGovernanceSpell_04_17_2026.t.sol"; + +contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17_2026_Test { + constructor() { + deploymentData = DeploymentData({ + deploymentType: Deployment.FORK, + forkTarget: ForkNetwork.ETHEREUM, + forkBlock: 24505217 + }); + + // OPEN + { + address[] memory guardians = new address[](1); + guardians[0] = 0xdE3B1a502a6f25B92434a00C4169B195B2F1528c; + + CONFIGS.push( + Config({ + folio: Folio(0x323c03c48660fE31186fa82c289b0766d331Ce21), + proxyAdmin: FolioProxyAdmin(0x0b79E381eD8D6d676C772Dba61cbeEA0B2d28c7D), + stakingVaultGovernor: IFolioGovernor(0x020d7c4a87485709D91E78AEeB2B2177ebFbaf41), + oldFolioGovernor: IFolioGovernor(0x020d7c4a87485709D91E78AEeB2B2177ebFbaf41), + guardians: guardians + }) + ); + } + + // BED + { + address[] memory guardians = new address[](2); + guardians[0] = 0x280730d9277EF586d58dB74c277Aa710ca8F87C9; + guardians[1] = 0xd5fE2780Eb882D1Da78f2136b81c2A4395488C98; + + CONFIGS.push( + Config({ + folio: Folio(0x4E3B170DcBe704b248df5f56D488114acE01B1C5), + proxyAdmin: FolioProxyAdmin(0xEAa356F6CD6b3fd15B47838d03cF34fa79F7c712), + stakingVaultGovernor: IFolioGovernor(0xD2f9c1D649F104e5D6B9453f3817c05911Cf765E), + oldFolioGovernor: IFolioGovernor(0xFaD4823Ae478637fD8FfdafB6c912f63c8cd1Dd7), + guardians: guardians + }) + ); + } + + // SMEL + { + address[] memory guardians = new address[](2); + guardians[0] = 0x280730d9277EF586d58dB74c277Aa710ca8F87C9; + guardians[1] = 0xd5fE2780Eb882D1Da78f2136b81c2A4395488C98; + + CONFIGS.push( + Config({ + folio: Folio(0xF91384484F4717314798E8975BCd904A35fc2BF1), + proxyAdmin: FolioProxyAdmin(0xDd885B0F2f97703B94d2790320b30017a17768BF), + stakingVaultGovernor: IFolioGovernor(0xD2f9c1D649F104e5D6B9453f3817c05911Cf765E), + oldFolioGovernor: IFolioGovernor(0x622c0b5aD82a2A47F330D4a2061a0e3562F583b0), + guardians: guardians + }) + ); + } + + // mvRWA + { + address[] memory guardians = new address[](1); + guardians[0] = 0x38afC3aA2c76b4cA1F8e1DabA68e998e1F4782DB; + + CONFIGS.push( + Config({ + folio: Folio(0xA5cdea03B11042fc10B52aF9eCa48bb17A2107d2), + proxyAdmin: FolioProxyAdmin(0x019318674560C233893aA31Bc0A380dc71dc2dDf), + stakingVaultGovernor: IFolioGovernor(0x83d070B91aef472CE993BCC25907e7c3959483b4), + oldFolioGovernor: IFolioGovernor(0x58e72A9a9E9Dc5209D02335d5Ac67eD28a86EAe9), + guardians: guardians + }) + ); + } + + // DFX + { + address[] memory guardians = new address[](2); + guardians[0] = 0xE86399fE6d7007FdEcb08A2ee1434Ee677a04433; + guardians[1] = 0xd5fE2780Eb882D1Da78f2136b81c2A4395488C98; + + CONFIGS.push( + Config({ + folio: Folio(0x188D12Eb13a5Eadd0867074ce8354B1AD6f4790b), + proxyAdmin: FolioProxyAdmin(0x0e3B2EF9701d5Ef230CB67Ee8851bA3071cf557C), + stakingVaultGovernor: IFolioGovernor(0xCaA7E91E752db5d79912665774be7B9Bf5171b9E), + oldFolioGovernor: IFolioGovernor(0x404859dE65229b7596Fe58784b6572bB3732DfAc), + guardians: guardians + }) + ); + } + + // ixEdel + { + address[] memory guardians = new address[](1); + guardians[0] = 0xe93F01A34B0a1f037e48381b8a9e03AECb2ff77d; + + CONFIGS.push( + Config({ + folio: Folio(0xe4a10951f962e6cB93Cb843a4ef05d2F99DB1F94), + proxyAdmin: FolioProxyAdmin(0x7a6C7064e0069D60A4D90B16545C1051d3487f63), + stakingVaultGovernor: IFolioGovernor(0xB3b141c115203932B6127423D33f60C83cAb3F69), + oldFolioGovernor: IFolioGovernor(0x8F56a509f39F16D30Da576C10B1a52908cA6ac4d), + guardians: guardians + }) + ); + } + + // DGI + { + address[] memory guardians = new address[](2); + guardians[0] = 0xf163D77B8EfC151757fEcBa3D463f3BAc7a4D808; + guardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; + + CONFIGS.push( + Config({ + folio: Folio(0x9a1741E151233a82Cf69209A2F1bC7442B1fB29C), + proxyAdmin: FolioProxyAdmin(0xe24e3DBBEd0db2a9aC2C1d2EA54c6132Dce181b7), + stakingVaultGovernor: IFolioGovernor(0xb01C1070E191A3a5535912489Fbff6Cc3f4bb865), + oldFolioGovernor: IFolioGovernor(0xDd36672d48caA6c8c45E49e83DB266568446EEfe), + guardians: guardians + }) + ); + } + } + + function test_upgradeFlow_sharedNewStakingVault_fork() public { + address[] memory mvDefiGuardians = new address[](2); + mvDefiGuardians[0] = 0x38afC3aA2c76b4cA1F8e1DabA68e998e1F4782DB; + mvDefiGuardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; + + Config memory mvRwaCfg = _configByFolio(0xA5cdea03B11042fc10B52aF9eCa48bb17A2107d2); + + Config memory mvDefiCfg = Config({ + folio: Folio(0x20d81101D254729a6E689418526bE31e2c544290), + proxyAdmin: FolioProxyAdmin(0x3927882f047944A9c561F29E204C370Dd84852Fd), + stakingVaultGovernor: IFolioGovernor(0x83d070B91aef472CE993BCC25907e7c3959483b4), + oldFolioGovernor: IFolioGovernor(0xa5168b7b5c081a2098420892c9DA26B6B30fc496), + guardians: mvDefiGuardians + }); + + address sharedStakingVault = mvRwaCfg.stakingVaultGovernor.token(); + address oldSharedStakingVaultOwner = IOwnableStakingVault(sharedStakingVault).owner(); + assertEq(sharedStakingVault, mvDefiCfg.stakingVaultGovernor.token(), "expected shared staking vault"); + + address newUnderlying = IStakingVault(sharedStakingVault).asset(); + address sharedOptimisticProposer = makeAddr("shared-staking-vault-opt"); + SuccessorDeployment memory stakingVaultDep = _deploySuccessorStakingVault( + mvRwaCfg, + _doubleAddressArray(address(mvRwaCfg.folio), address(mvDefiCfg.folio)), + _singleAddressArray(sharedOptimisticProposer), + keccak256("mvRWA-shared-vault") + ); + + assertEq( + IOwnableStakingVault(sharedStakingVault).owner(), + oldSharedStakingVaultOwner, + "step 1 should not alter shared vault owner" + ); + assertTrue(stakingVaultDep.newStakingVault != sharedStakingVault, "expected new staking vault path"); + assertEq(IStakingVault(stakingVaultDep.newStakingVault).asset(), newUnderlying, "new vault asset mismatch"); + _assertRewardTokens( + stakingVaultDep.newStakingVault, + _doubleAddressArray(address(mvRwaCfg.folio), address(mvDefiCfg.folio)) + ); + + uint96 mvRwaOldVaultFeePortionBefore = _feeRecipientPortion(mvRwaCfg.folio, sharedStakingVault); + uint96 mvRwaNewVaultFeePortionBefore = _feeRecipientPortion(mvRwaCfg.folio, stakingVaultDep.newStakingVault); + assertGt(uint256(mvRwaOldVaultFeePortionBefore), 0, "mvRWA old vault should receive folio fees"); + GovernanceSpell_04_17_2026.NewDeployment memory mvRwaFolioDep = _upgradeFolio( + mvRwaCfg, + IStakingVault(stakingVaultDep.newStakingVault), + makeAddr("mvrwa-folio-opt"), + keccak256("mvRWA-folio") + ); + assertEq(mvRwaFolioDep.stakingVault, stakingVaultDep.newStakingVault, "mvRWA returned vault mismatch"); + _assertFeeRecipientMigrated( + mvRwaCfg.folio, + sharedStakingVault, + stakingVaultDep.newStakingVault, + mvRwaOldVaultFeePortionBefore, + mvRwaNewVaultFeePortionBefore + ); + _assertFolioGovernanceInstalled(mvRwaCfg, mvRwaFolioDep.newTimelock); + assertEq( + IFolioGovernor(mvRwaFolioDep.newGovernor).token(), + stakingVaultDep.newStakingVault, + "mvRWA folio governor should use the upgraded staking vault" + ); + + uint96 mvDefiOldVaultFeePortionBefore = _feeRecipientPortion(mvDefiCfg.folio, sharedStakingVault); + uint96 mvDefiNewVaultFeePortionBefore = _feeRecipientPortion(mvDefiCfg.folio, stakingVaultDep.newStakingVault); + assertGt(uint256(mvDefiOldVaultFeePortionBefore), 0, "mvDEFI old vault should receive folio fees"); + GovernanceSpell_04_17_2026.NewDeployment memory mvDefiFolioDep = _upgradeFolio( + mvDefiCfg, + IStakingVault(stakingVaultDep.newStakingVault), + makeAddr("mvdefi-folio-opt"), + keccak256("mvDEFI-folio") + ); + assertEq(mvDefiFolioDep.stakingVault, stakingVaultDep.newStakingVault, "mvDEFI returned vault mismatch"); + _assertFeeRecipientMigrated( + mvDefiCfg.folio, + sharedStakingVault, + stakingVaultDep.newStakingVault, + mvDefiOldVaultFeePortionBefore, + mvDefiNewVaultFeePortionBefore + ); + _assertFolioGovernanceInstalled(mvDefiCfg, mvDefiFolioDep.newTimelock); + assertEq( + IFolioGovernor(mvDefiFolioDep.newGovernor).token(), + stakingVaultDep.newStakingVault, + "mvDEFI folio governor should use the upgraded staking vault" + ); + assertTrue( + mvRwaFolioDep.newGovernor != stakingVaultDep.newGovernor, + "mvRWA folio governor should be distinct from staking vault governance" + ); + assertTrue( + mvDefiFolioDep.newGovernor != stakingVaultDep.newGovernor, + "mvDEFI folio governor should be distinct from staking vault governance" + ); + assertTrue(mvRwaFolioDep.newGovernor != mvDefiFolioDep.newGovernor, "folios should not share a governor"); + assertTrue(mvRwaFolioDep.newTimelock != mvDefiFolioDep.newTimelock, "folios should not share a timelock"); + + _assertCanCreateOptimisticStartRebalanceProposal( + IReserveOptimisticGovernorLike(stakingVaultDep.newGovernor), + mvRwaCfg.folio, + sharedOptimisticProposer, + "mvRWA optimistic start rebalance" + ); + _assertCanCreateOptimisticStartRebalanceProposal( + IReserveOptimisticGovernorLike(stakingVaultDep.newGovernor), + mvDefiCfg.folio, + sharedOptimisticProposer, + "mvDEFI optimistic start rebalance" + ); + + _retireOldStakingVault(IOwnableStakingVault(sharedStakingVault)); + } + + function _configByFolio(address folio) internal view returns (Config memory cfg) { + for (uint256 i; i < CONFIGS.length; i++) { + if (address(CONFIGS[i].folio) == folio) return CONFIGS[i]; + } + + revert("CONFIG_NOT_FOUND"); + } +} From 86c30cffb096ebf70e9d1194be6f82b688985f92 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 6 May 2026 10:54:05 -0400 Subject: [PATCH 02/28] explicitly document new stake requirement --- contracts/spells/GovernanceSpell_04_17_2026.sol | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index b6eaba46..f66e7ef3 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -54,8 +54,8 @@ interface IOwnableStakingVault is IStakingVault { * Upgrade flow: * 1. deploySuccessorStakingVault: Permissionlessly deploy a NEW StakingVault with its own NEW * governor/timelock system, isolated from the old vault. No permissions required. - * 2. upgradeFolio: Deploy NEW Folio governance system on the successor StakingVault; rotate Folio roles, - * proxy admin ownership, and fee recipients from old StakingVault to new StakingVault. + * 2. upgradeFolio: Deploy NEW Folio governance system on the successor StakingVault; rotate Folio roles + * and fee recipients from old StakingVault to new StakingVault. Wait for new stake before calling. * Caller: old timelock of Folio * 3. retireOldStakingVault: After every dependent Folio has completed step 2, permanently seal the * old StakingVault (zero unstaking delay, fast reward handout, renounce ownership). @@ -144,6 +144,7 @@ contract GovernanceSpell_04_17_2026 { /// - Self is Folio admin /// - Self is FolioProxyAdmin owner /// @dev New Governance system will use standard 2-3-2 day voting independent of previous voting settings + /// @dev IMPORTANT: Do not call until the `newStakingVault` has been sufficiently populated by new stake /// @param newStakingVault New staking vault to use for the new governor /// @param oldFolioGovernor Governor currently attached to the Folio being upgraded /// @param optimisticSelectorData Include Folio.startRebalance.selector if optimistic rebalancing should be enabled From d3d35e23b15f832d62aaae4370e1a47c71fef7fc Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 6 May 2026 12:43:57 -0400 Subject: [PATCH 03/28] lock down optimistic selector data --- .../spells/GovernanceSpell_04_17_2026.sol | 37 ++++++------- .../GenericGovernanceSpell_04_17_2026.t.sol | 53 +++++++++---------- .../GovernanceSpellEthereum_04_17_2026.t.sol | 4 +- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index f66e7ef3..e88d06f1 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -84,13 +84,11 @@ contract GovernanceSpell_04_17_2026 { /// Deploy a successor StakingVault with a fresh optimistic governance system /// @dev Permissionless: does not require or change any ownership on the old staking vault - /// @param optimisticSelectorData Include Folio.startRebalance.selector if optimistic rebalancing should be enabled /// @param optimisticProposers Use empty set to disable optimistic governance altogether /// @param guardians Must be a subset of the old staking vault timelock's CANCELLER_ROLE members function deploySuccessorStakingVault( IFolioGovernor stakingVaultGovernor, IReserveOptimisticGovernor.OptimisticGovernanceParams calldata optimisticParams, - IOptimisticSelectorRegistry.SelectorData[] calldata optimisticSelectorData, address[] calldata optimisticProposers, address[] calldata guardians, address[] calldata rewardTokens, @@ -103,7 +101,6 @@ contract GovernanceSpell_04_17_2026 { IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams = _baseDeploymentParams( stakingVaultGovernor, optimisticParams, - optimisticSelectorData, optimisticProposers, guardians ); @@ -147,7 +144,6 @@ contract GovernanceSpell_04_17_2026 { /// @dev IMPORTANT: Do not call until the `newStakingVault` has been sufficiently populated by new stake /// @param newStakingVault New staking vault to use for the new governor /// @param oldFolioGovernor Governor currently attached to the Folio being upgraded - /// @param optimisticSelectorData Include Folio.startRebalance.selector if optimistic rebalancing should be enabled /// @param optimisticProposers Use empty set to disable optimistic governance altogether /// @param guardians Must be a subset of the old Folio timelock's CANCELLER_ROLE members /// The shared Guardian contract will be included as a CANCELLER_ROLE member by default @@ -157,26 +153,23 @@ contract GovernanceSpell_04_17_2026 { IStakingVault newStakingVault, IFolioGovernor oldFolioGovernor, IReserveOptimisticGovernor.OptimisticGovernanceParams calldata optimisticParams, - IOptimisticSelectorRegistry.SelectorData[] calldata optimisticSelectorData, address[] calldata optimisticProposers, address[] calldata guardians, bytes32 deploymentNonce ) public returns (NewDeployment memory newDeployment) { require(oldFolioGovernor.timelock() == msg.sender, UpgradeError(1)); + IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams = _baseDeploymentParams( + oldFolioGovernor, + optimisticParams, + optimisticProposers, + guardians + ); + baseParams.selectorData = _startRebalanceSelectorData(folio); + newDeployment.stakingVault = address(newStakingVault); (newDeployment.newGovernor, newDeployment.newTimelock, newDeployment.newSelectorRegistry) = governorDeployer - .deployWithExistingStakingVault( - _baseDeploymentParams( - oldFolioGovernor, - optimisticParams, - optimisticSelectorData, - optimisticProposers, - guardians - ), - address(newStakingVault), - deploymentNonce - ); + .deployWithExistingStakingVault(baseParams, address(newStakingVault), deploymentNonce); require(newDeployment.newTimelock != address(0), UpgradeError(2)); // newStakingVault must not be the old immmutable kind, must be new and upgradeable @@ -236,7 +229,6 @@ contract GovernanceSpell_04_17_2026 { function _baseDeploymentParams( IFolioGovernor oldGovernor, IReserveOptimisticGovernor.OptimisticGovernanceParams calldata optimisticParams, - IOptimisticSelectorRegistry.SelectorData[] calldata optimisticSelectorData, address[] calldata optimisticProposers, address[] calldata guardians ) internal view returns (IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams) { @@ -256,7 +248,7 @@ contract GovernanceSpell_04_17_2026 { // hard-coded long standard governance params to unify across DTFs // Optimistic whitelists - baseParams.selectorData = optimisticSelectorData; + baseParams.selectorData = new IOptimisticSelectorRegistry.SelectorData[](0); baseParams.optimisticProposers = optimisticProposers; // Guardians @@ -270,6 +262,15 @@ contract GovernanceSpell_04_17_2026 { baseParams.proposalThrottleCapacity = 3; } + function _startRebalanceSelectorData( + Folio folio + ) internal pure returns (IOptimisticSelectorRegistry.SelectorData[] memory selectorData) { + selectorData = new IOptimisticSelectorRegistry.SelectorData[](1); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = Folio.startRebalance.selector; + selectorData[0] = IOptimisticSelectorRegistry.SelectorData({ target: address(folio), selectors: selectors }); + } + /// @return proposalThreshold D18{1} /// @return quorumNumerator D18{1} function _proposalThresholdAndQuorum( diff --git a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol index f493cb7d..04e4f338 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol @@ -16,7 +16,6 @@ import { OptimisticSelectorRegistryDeployer } from "@reserve-protocol/reserve-go import { ReserveOptimisticGovernorDeployerDeployer } from "@reserve-protocol/reserve-governor/contracts/artifacts/ReserveOptimisticGovernorDeployerDeployer.sol"; import { IReserveOptimisticGovernorDeployer } from "@reserve-protocol/reserve-governor/contracts/interfaces/IDeployer.sol"; import { IReserveOptimisticGovernor } from "@reserve-protocol/reserve-governor/contracts/interfaces/IReserveOptimisticGovernor.sol"; -import { IOptimisticSelectorRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IOptimisticSelectorRegistry.sol"; import { IRoleRegistry as IRewardRoleRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IRoleRegistry.sol"; import { RewardTokenRegistry } from "@reserve-protocol/reserve-governor/contracts/staking/RewardTokenRegistry.sol"; import { REBALANCE_MANAGER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; @@ -130,7 +129,6 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { newStakingVault, cfg.oldFolioGovernor, _optimisticParams(), - _selectorDataForFolio(cfg.folio, optimisticProposer), _singleAddressArray(optimisticProposer), cfg.guardians, deploymentNonce @@ -230,7 +228,6 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { newDeployment = spell.deploySuccessorStakingVault( cfg.stakingVaultGovernor, _optimisticParams(), - _selectorDataForStartRebalanceFolios(folios), optimisticProposers, cfg.guardians, rewardTokens, @@ -328,12 +325,12 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { assertEq(uint256(governor.state(standardProposalId)), uint256(IGovernor.ProposalState.Pending)); assertFalse(governor.isOptimistic(standardProposalId)); - // Optimistic proposal (fast path) + // Optimistic proposal (fast path) is limited to Folio.startRebalance. ( address[] memory optimisticTargets, uint256[] memory optimisticValues, bytes[] memory optimisticCalldatas - ) = _singleCall(address(folio), 0, abi.encodeCall(Folio.setName, ("optimistic proposal"))); + ) = _singleCall(address(folio), 0, _startRebalanceCalldata()); vm.prank(optimisticProposer); uint256 optimisticProposalId = governor.proposeOptimistic( @@ -346,6 +343,30 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { assertTrue(governor.isOptimistic(optimisticProposalId)); } + function _assertCannotCreateOptimisticStartRebalanceProposal( + IReserveOptimisticGovernorLike governor, + Folio folio, + address optimisticProposer, + string memory description + ) internal { + bytes memory calldata_ = _startRebalanceCalldata(); + (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _singleCall( + address(folio), + 0, + calldata_ + ); + + vm.expectRevert( + abi.encodeWithSelector( + IReserveOptimisticGovernor.OptimisticGovernor__InvalidCall.selector, + address(folio), + calldata_ + ) + ); + vm.prank(optimisticProposer); + governor.proposeOptimistic(targets, values, calldatas, description); + } + function _assertCanCreateOptimisticStartRebalanceProposal( IReserveOptimisticGovernorLike governor, Folio folio, @@ -376,28 +397,6 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { vm.warp(block.timestamp + 1); } - function _selectorDataForFolio( - Folio folio, - address - ) internal pure returns (IOptimisticSelectorRegistry.SelectorData[] memory selectorData) { - selectorData = new IOptimisticSelectorRegistry.SelectorData[](1); - bytes4[] memory selectors = new bytes4[](1); - selectors[0] = Folio.setName.selector; - selectorData[0] = IOptimisticSelectorRegistry.SelectorData({ target: address(folio), selectors: selectors }); - } - - function _selectorDataForStartRebalanceFolios( - address[] memory folios - ) internal pure returns (IOptimisticSelectorRegistry.SelectorData[] memory selectorData) { - selectorData = new IOptimisticSelectorRegistry.SelectorData[](folios.length); - - for (uint256 i; i < folios.length; i++) { - bytes4[] memory selectors = new bytes4[](1); - selectors[0] = Folio.startRebalance.selector; - selectorData[i] = IOptimisticSelectorRegistry.SelectorData({ target: folios[i], selectors: selectors }); - } - } - function _rewardTokensForUnderlying( address newUnderlying, address[] memory folios diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol index 847d32d7..25ef8ffe 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol @@ -226,13 +226,13 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 assertTrue(mvRwaFolioDep.newGovernor != mvDefiFolioDep.newGovernor, "folios should not share a governor"); assertTrue(mvRwaFolioDep.newTimelock != mvDefiFolioDep.newTimelock, "folios should not share a timelock"); - _assertCanCreateOptimisticStartRebalanceProposal( + _assertCannotCreateOptimisticStartRebalanceProposal( IReserveOptimisticGovernorLike(stakingVaultDep.newGovernor), mvRwaCfg.folio, sharedOptimisticProposer, "mvRWA optimistic start rebalance" ); - _assertCanCreateOptimisticStartRebalanceProposal( + _assertCannotCreateOptimisticStartRebalanceProposal( IReserveOptimisticGovernorLike(stakingVaultDep.newGovernor), mvDefiCfg.folio, sharedOptimisticProposer, From 9d685b72e93ea4c81b5e5ac304031581bce9ae1a Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 6 May 2026 12:52:49 -0400 Subject: [PATCH 04/28] ensure StakingVault is already setup to handout revenue after upgradeFolio() --- .../spells/GovernanceSpell_04_17_2026.sol | 16 +++-- .../GenericGovernanceSpell_04_17_2026.t.sol | 66 +++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index e88d06f1..56c19c5c 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -11,6 +11,7 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I import { IReserveOptimisticGovernorDeployer } from "@reserve-protocol/reserve-governor/contracts/interfaces/IDeployer.sol"; import { IOptimisticSelectorRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IOptimisticSelectorRegistry.sol"; import { IReserveOptimisticGovernor } from "@reserve-protocol/reserve-governor/contracts/interfaces/IReserveOptimisticGovernor.sol"; +import { IRewardTokenRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IRewardTokenRegistry.sol"; import { IFolio, Folio } from "@src/Folio.sol"; import { FolioProxyAdmin } from "@folio/FolioProxy.sol"; @@ -30,7 +31,9 @@ interface IVersioned { function version() external view returns (string memory); } -interface IStakingVault is IERC5805, IERC4626, IVersioned {} +interface IStakingVault is IERC5805, IERC4626, IVersioned { + function rewardTokenRegistry() external view returns (IRewardTokenRegistry); +} // old staking vault model interface IOwnableStakingVault is IStakingVault { @@ -167,14 +170,19 @@ contract GovernanceSpell_04_17_2026 { ); baseParams.selectorData = _startRebalanceSelectorData(folio); + // newStakingVault must not be the old immmutable kind, must be new and upgradeable + require(keccak256(bytes(IVersioned(address(newStakingVault)).version())) == VERSION_1_0_0, UpgradeError(3)); + require( + address(folio) == newStakingVault.asset() || + newStakingVault.rewardTokenRegistry().isRegistered(address(folio)), + UpgradeError(28) + ); + newDeployment.stakingVault = address(newStakingVault); (newDeployment.newGovernor, newDeployment.newTimelock, newDeployment.newSelectorRegistry) = governorDeployer .deployWithExistingStakingVault(baseParams, address(newStakingVault), deploymentNonce); require(newDeployment.newTimelock != address(0), UpgradeError(2)); - // newStakingVault must not be the old immmutable kind, must be new and upgradeable - require(keccak256(bytes(IVersioned(address(newStakingVault)).version())) == VERSION_1_0_0, UpgradeError(3)); - // confirm Folio admins are self + old timelock require(folio.getRoleMemberCount(DEFAULT_ADMIN_ROLE) == 2, UpgradeError(4)); require(folio.hasRole(DEFAULT_ADMIN_ROLE, address(this)), UpgradeError(5)); diff --git a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol index 04e4f338..32da66e1 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol @@ -16,11 +16,34 @@ import { OptimisticSelectorRegistryDeployer } from "@reserve-protocol/reserve-go import { ReserveOptimisticGovernorDeployerDeployer } from "@reserve-protocol/reserve-governor/contracts/artifacts/ReserveOptimisticGovernorDeployerDeployer.sol"; import { IReserveOptimisticGovernorDeployer } from "@reserve-protocol/reserve-governor/contracts/interfaces/IDeployer.sol"; import { IReserveOptimisticGovernor } from "@reserve-protocol/reserve-governor/contracts/interfaces/IReserveOptimisticGovernor.sol"; +import { IRewardTokenRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IRewardTokenRegistry.sol"; import { IRoleRegistry as IRewardRoleRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IRoleRegistry.sol"; import { RewardTokenRegistry } from "@reserve-protocol/reserve-governor/contracts/staking/RewardTokenRegistry.sol"; import { REBALANCE_MANAGER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; import { MockRoleRegistry } from "utils/MockRoleRegistry.sol"; +contract MockUpgradeStakingVault { + address private immutable _asset; + IRewardTokenRegistry private immutable _rewardTokenRegistry; + + constructor(address asset_, IRewardTokenRegistry rewardTokenRegistry_) { + _asset = asset_; + _rewardTokenRegistry = rewardTokenRegistry_; + } + + function version() external pure returns (string memory) { + return "1.0.0"; + } + + function asset() external view returns (address) { + return _asset; + } + + function rewardTokenRegistry() external view returns (IRewardTokenRegistry) { + return _rewardTokenRegistry; + } +} + interface IVersionedLike { function version() external view returns (string memory); } @@ -105,6 +128,49 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { } } + function test_upgradeFolio_revertsIfFolioIsNeitherAssetNorRewardToken_fork() public { + Config memory cfg; + bool found; + + for (uint256 i; i < CONFIGS.length; i++) { + Config memory candidate = CONFIGS[i]; + if (address(candidate.folio) != IStakingVault(candidate.stakingVaultGovernor.token()).asset()) { + cfg = candidate; + found = true; + break; + } + } + + require(found, "expected non-asset folio config"); + + IStakingVault mockStakingVault = IStakingVault( + address( + new MockUpgradeStakingVault( + IStakingVault(cfg.stakingVaultGovernor.token()).asset(), + IRewardTokenRegistry(address(rewardTokenRegistry)) + ) + ) + ); + + address oldFolioTimelock = cfg.oldFolioGovernor.timelock(); + vm.startPrank(oldFolioTimelock); + cfg.proxyAdmin.transferOwnership(address(spell)); + cfg.folio.grantRole(DEFAULT_ADMIN_ROLE, address(spell)); + + vm.expectRevert(abi.encodeWithSelector(GovernanceSpell_04_17_2026.UpgradeError.selector, 28)); + spell.upgradeFolio( + cfg.folio, + cfg.proxyAdmin, + mockStakingVault, + cfg.oldFolioGovernor, + _optimisticParams(), + new address[](0), + cfg.guardians, + keccak256("unregistered-folio-upgrade") + ); + vm.stopPrank(); + } + // === Internal === function _logFolioSymbol(Folio folio) internal view { From 8456ff6ed2ff9e1d29f498eff06648d03e082065 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 6 May 2026 13:04:15 -0400 Subject: [PATCH 05/28] propagate caller through to deploySuccessorStakingVault deploymentNonce --- contracts/spells/GovernanceSpell_04_17_2026.sol | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index 56c19c5c..58832024 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -120,7 +120,11 @@ contract GovernanceSpell_04_17_2026 { newDeployment.newGovernor, newDeployment.newTimelock, newDeployment.newSelectorRegistry - ) = governorDeployer.deployWithNewStakingVault(baseParams, newStakingVaultParams, deploymentNonce); + ) = governorDeployer.deployWithNewStakingVault( + baseParams, + newStakingVaultParams, + keccak256(abi.encode(deploymentNonce, msg.sender)) + ); require(newDeployment.newTimelock != address(0), UpgradeError(22)); require(IStakingVault(newDeployment.stakingVault).asset() == newUnderlying, UpgradeError(23)); From 9d6cdc0c3cb3dcd523b4834bb41f42cf852c7077 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 6 May 2026 15:42:20 -0400 Subject: [PATCH 06/28] Revert "ensure StakingVault is already setup to handout revenue after upgradeFolio()" This reverts commit 9d685b72e93ea4c81b5e5ac304031581bce9ae1a. --- .../spells/GovernanceSpell_04_17_2026.sol | 16 ++--- .../GenericGovernanceSpell_04_17_2026.t.sol | 66 ------------------- 2 files changed, 4 insertions(+), 78 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index 58832024..90872b79 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -11,7 +11,6 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I import { IReserveOptimisticGovernorDeployer } from "@reserve-protocol/reserve-governor/contracts/interfaces/IDeployer.sol"; import { IOptimisticSelectorRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IOptimisticSelectorRegistry.sol"; import { IReserveOptimisticGovernor } from "@reserve-protocol/reserve-governor/contracts/interfaces/IReserveOptimisticGovernor.sol"; -import { IRewardTokenRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IRewardTokenRegistry.sol"; import { IFolio, Folio } from "@src/Folio.sol"; import { FolioProxyAdmin } from "@folio/FolioProxy.sol"; @@ -31,9 +30,7 @@ interface IVersioned { function version() external view returns (string memory); } -interface IStakingVault is IERC5805, IERC4626, IVersioned { - function rewardTokenRegistry() external view returns (IRewardTokenRegistry); -} +interface IStakingVault is IERC5805, IERC4626, IVersioned {} // old staking vault model interface IOwnableStakingVault is IStakingVault { @@ -174,19 +171,14 @@ contract GovernanceSpell_04_17_2026 { ); baseParams.selectorData = _startRebalanceSelectorData(folio); - // newStakingVault must not be the old immmutable kind, must be new and upgradeable - require(keccak256(bytes(IVersioned(address(newStakingVault)).version())) == VERSION_1_0_0, UpgradeError(3)); - require( - address(folio) == newStakingVault.asset() || - newStakingVault.rewardTokenRegistry().isRegistered(address(folio)), - UpgradeError(28) - ); - newDeployment.stakingVault = address(newStakingVault); (newDeployment.newGovernor, newDeployment.newTimelock, newDeployment.newSelectorRegistry) = governorDeployer .deployWithExistingStakingVault(baseParams, address(newStakingVault), deploymentNonce); require(newDeployment.newTimelock != address(0), UpgradeError(2)); + // newStakingVault must not be the old immmutable kind, must be new and upgradeable + require(keccak256(bytes(IVersioned(address(newStakingVault)).version())) == VERSION_1_0_0, UpgradeError(3)); + // confirm Folio admins are self + old timelock require(folio.getRoleMemberCount(DEFAULT_ADMIN_ROLE) == 2, UpgradeError(4)); require(folio.hasRole(DEFAULT_ADMIN_ROLE, address(this)), UpgradeError(5)); diff --git a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol index 32da66e1..04e4f338 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol @@ -16,34 +16,11 @@ import { OptimisticSelectorRegistryDeployer } from "@reserve-protocol/reserve-go import { ReserveOptimisticGovernorDeployerDeployer } from "@reserve-protocol/reserve-governor/contracts/artifacts/ReserveOptimisticGovernorDeployerDeployer.sol"; import { IReserveOptimisticGovernorDeployer } from "@reserve-protocol/reserve-governor/contracts/interfaces/IDeployer.sol"; import { IReserveOptimisticGovernor } from "@reserve-protocol/reserve-governor/contracts/interfaces/IReserveOptimisticGovernor.sol"; -import { IRewardTokenRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IRewardTokenRegistry.sol"; import { IRoleRegistry as IRewardRoleRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IRoleRegistry.sol"; import { RewardTokenRegistry } from "@reserve-protocol/reserve-governor/contracts/staking/RewardTokenRegistry.sol"; import { REBALANCE_MANAGER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; import { MockRoleRegistry } from "utils/MockRoleRegistry.sol"; -contract MockUpgradeStakingVault { - address private immutable _asset; - IRewardTokenRegistry private immutable _rewardTokenRegistry; - - constructor(address asset_, IRewardTokenRegistry rewardTokenRegistry_) { - _asset = asset_; - _rewardTokenRegistry = rewardTokenRegistry_; - } - - function version() external pure returns (string memory) { - return "1.0.0"; - } - - function asset() external view returns (address) { - return _asset; - } - - function rewardTokenRegistry() external view returns (IRewardTokenRegistry) { - return _rewardTokenRegistry; - } -} - interface IVersionedLike { function version() external view returns (string memory); } @@ -128,49 +105,6 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { } } - function test_upgradeFolio_revertsIfFolioIsNeitherAssetNorRewardToken_fork() public { - Config memory cfg; - bool found; - - for (uint256 i; i < CONFIGS.length; i++) { - Config memory candidate = CONFIGS[i]; - if (address(candidate.folio) != IStakingVault(candidate.stakingVaultGovernor.token()).asset()) { - cfg = candidate; - found = true; - break; - } - } - - require(found, "expected non-asset folio config"); - - IStakingVault mockStakingVault = IStakingVault( - address( - new MockUpgradeStakingVault( - IStakingVault(cfg.stakingVaultGovernor.token()).asset(), - IRewardTokenRegistry(address(rewardTokenRegistry)) - ) - ) - ); - - address oldFolioTimelock = cfg.oldFolioGovernor.timelock(); - vm.startPrank(oldFolioTimelock); - cfg.proxyAdmin.transferOwnership(address(spell)); - cfg.folio.grantRole(DEFAULT_ADMIN_ROLE, address(spell)); - - vm.expectRevert(abi.encodeWithSelector(GovernanceSpell_04_17_2026.UpgradeError.selector, 28)); - spell.upgradeFolio( - cfg.folio, - cfg.proxyAdmin, - mockStakingVault, - cfg.oldFolioGovernor, - _optimisticParams(), - new address[](0), - cfg.guardians, - keccak256("unregistered-folio-upgrade") - ); - vm.stopPrank(); - } - // === Internal === function _logFolioSymbol(Folio folio) internal view { From 4d8969e8ce26886aa5aa011da51ca814a033a888 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 6 May 2026 15:54:42 -0400 Subject: [PATCH 07/28] document revenue token limitation instead --- contracts/spells/GovernanceSpell_04_17_2026.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index 90872b79..ce035a3e 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -144,8 +144,10 @@ contract GovernanceSpell_04_17_2026 { /// - Caller is old Folio timelock /// - Self is Folio admin /// - Self is FolioProxyAdmin owner - /// @dev New Governance system will use standard 2-3-2 day voting independent of previous voting settings /// @dev IMPORTANT: Do not call until the `newStakingVault` has been sufficiently populated by new stake + /// @dev New Governance system will use standard 2-3-2 day voting independent of previous voting settings + /// @dev It is not verified that the new StakingVault is already configured to handout the Folio as reward token. + /// This is an accepted limitation to reduce the overall number of blocking steps in the upgrade sequence. /// @param newStakingVault New staking vault to use for the new governor /// @param oldFolioGovernor Governor currently attached to the Folio being upgraded /// @param optimisticProposers Use empty set to disable optimistic governance altogether From 987d3dc00392072fca1cd5234f17b72917244ab8 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 6 May 2026 15:54:59 -0400 Subject: [PATCH 08/28] compatibility with reserve-governor --- foundry.toml | 2 +- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/foundry.toml b/foundry.toml index a9199508..c170e92c 100644 --- a/foundry.toml +++ b/foundry.toml @@ -11,7 +11,7 @@ memory_limit = 1073741824 # 1 GB # Compiler Options bytecode_hash = "none" -evm_version = "paris" +evm_version = "cancun" optimizer = true optimizer_runs = 200 solc_version = "0.8.28" diff --git a/package.json b/package.json index 827bf9b0..d547655b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "@openzeppelin/contracts-upgradeable": "5.1.0", "@prb/math": "4.1.0", "@reserve-protocol/trusted-fillers": "github:reserve-protocol/trusted-fillers#eabd9dbd60e8aaaec2c50db43742991e4cb55208", - "@reserve-protocol/reserve-governor": "github:reserve-protocol/reserve-governor#3e6c3af0e3b6db36c9135f9206f65ec20c0fd4f0", + "@reserve-protocol/reserve-governor": "github:reserve-protocol/reserve-governor#dc27a68463cec356ff18bbdd3d8edfe9b2534372", "forge-std": "github:foundry-rs/forge-std#v1.14.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67b3fb71..e03aa572 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: 4.1.0 version: 4.1.0 '@reserve-protocol/reserve-governor': - specifier: github:reserve-protocol/reserve-governor#3e6c3af0e3b6db36c9135f9206f65ec20c0fd4f0 - version: https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/3e6c3af0e3b6db36c9135f9206f65ec20c0fd4f0 + specifier: github:reserve-protocol/reserve-governor#dc27a68463cec356ff18bbdd3d8edfe9b2534372 + version: https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/dc27a68463cec356ff18bbdd3d8edfe9b2534372 '@reserve-protocol/trusted-fillers': specifier: github:reserve-protocol/trusted-fillers#eabd9dbd60e8aaaec2c50db43742991e4cb55208 version: https://codeload.github.com/reserve-protocol/trusted-fillers/tar.gz/eabd9dbd60e8aaaec2c50db43742991e4cb55208(cross-fetch@3.2.0)(ipfs-only-hash@4.0.0)(multiformats@9.9.0)(typescript@5.9.3) @@ -457,8 +457,8 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} - '@reserve-protocol/reserve-governor@https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/3e6c3af0e3b6db36c9135f9206f65ec20c0fd4f0': - resolution: {tarball: https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/3e6c3af0e3b6db36c9135f9206f65ec20c0fd4f0} + '@reserve-protocol/reserve-governor@https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/dc27a68463cec356ff18bbdd3d8edfe9b2534372': + resolution: {tarball: https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/dc27a68463cec356ff18bbdd3d8edfe9b2534372} version: 0.1.0 '@reserve-protocol/trusted-fillers@https://codeload.github.com/reserve-protocol/trusted-fillers/tar.gz/eabd9dbd60e8aaaec2c50db43742991e4cb55208': @@ -2010,7 +2010,7 @@ snapshots: '@protobufjs/utf8@1.1.0': {} - '@reserve-protocol/reserve-governor@https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/3e6c3af0e3b6db36c9135f9206f65ec20c0fd4f0': + '@reserve-protocol/reserve-governor@https://codeload.github.com/reserve-protocol/reserve-governor/tar.gz/dc27a68463cec356ff18bbdd3d8edfe9b2534372': dependencies: '@openzeppelin/contracts': 5.4.0 '@openzeppelin/contracts-upgradeable': 5.4.0(@openzeppelin/contracts@5.4.0) From a9b45b25b0025e3c2c1e0ba7a2a71764abf167f2 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Thu, 7 May 2026 16:05:04 +0000 Subject: [PATCH 09/28] remove successor staking vault optimistic proposers --- contracts/spells/GovernanceSpell_04_17_2026.sol | 6 ++---- .../GenericGovernanceSpell_04_17_2026.t.sol | 11 +++-------- .../GovernanceSpellEthereum_04_17_2026.t.sol | 6 ++---- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index ce035a3e..21349e34 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -84,12 +84,10 @@ contract GovernanceSpell_04_17_2026 { /// Deploy a successor StakingVault with a fresh optimistic governance system /// @dev Permissionless: does not require or change any ownership on the old staking vault - /// @param optimisticProposers Use empty set to disable optimistic governance altogether /// @param guardians Must be a subset of the old staking vault timelock's CANCELLER_ROLE members function deploySuccessorStakingVault( IFolioGovernor stakingVaultGovernor, IReserveOptimisticGovernor.OptimisticGovernanceParams calldata optimisticParams, - address[] calldata optimisticProposers, address[] calldata guardians, address[] calldata rewardTokens, bytes32 deploymentNonce @@ -101,7 +99,7 @@ contract GovernanceSpell_04_17_2026 { IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams = _baseDeploymentParams( stakingVaultGovernor, optimisticParams, - optimisticProposers, + new address[](0), guardians ); IReserveOptimisticGovernorDeployer.NewStakingVaultParams @@ -235,7 +233,7 @@ contract GovernanceSpell_04_17_2026 { function _baseDeploymentParams( IFolioGovernor oldGovernor, IReserveOptimisticGovernor.OptimisticGovernanceParams calldata optimisticParams, - address[] calldata optimisticProposers, + address[] memory optimisticProposers, address[] calldata guardians ) internal view returns (IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams) { IStakingVault oldStakingVault = IStakingVault(oldGovernor.token()); diff --git a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol index 04e4f338..50c04f96 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol @@ -152,7 +152,6 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { stakingVaultDep = _deploySuccessorStakingVault( cfg, _singleAddressArray(address(cfg.folio)), - new address[](0), keccak256(abi.encode(configIndex, "proposal-new")) ); @@ -215,7 +214,6 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { function _deploySuccessorStakingVault( Config memory cfg, address[] memory folios, - address[] memory optimisticProposers, bytes32 deploymentNonce ) internal returns (SuccessorDeployment memory dep) { address newUnderlying = IStakingVault(cfg.stakingVaultGovernor.token()).asset(); @@ -228,7 +226,6 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { newDeployment = spell.deploySuccessorStakingVault( cfg.stakingVaultGovernor, _optimisticParams(), - optimisticProposers, cfg.guardians, rewardTokens, deploymentNonce @@ -349,18 +346,16 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { address optimisticProposer, string memory description ) internal { - bytes memory calldata_ = _startRebalanceCalldata(); (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _singleCall( address(folio), 0, - calldata_ + _startRebalanceCalldata() ); vm.expectRevert( abi.encodeWithSelector( - IReserveOptimisticGovernor.OptimisticGovernor__InvalidCall.selector, - address(folio), - calldata_ + IReserveOptimisticGovernor.OptimisticGovernor__NotOptimisticProposer.selector, + optimisticProposer ) ); vm.prank(optimisticProposer); diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol index 25ef8ffe..d29fd1be 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol @@ -148,11 +148,9 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 assertEq(sharedStakingVault, mvDefiCfg.stakingVaultGovernor.token(), "expected shared staking vault"); address newUnderlying = IStakingVault(sharedStakingVault).asset(); - address sharedOptimisticProposer = makeAddr("shared-staking-vault-opt"); SuccessorDeployment memory stakingVaultDep = _deploySuccessorStakingVault( mvRwaCfg, _doubleAddressArray(address(mvRwaCfg.folio), address(mvDefiCfg.folio)), - _singleAddressArray(sharedOptimisticProposer), keccak256("mvRWA-shared-vault") ); @@ -229,13 +227,13 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 _assertCannotCreateOptimisticStartRebalanceProposal( IReserveOptimisticGovernorLike(stakingVaultDep.newGovernor), mvRwaCfg.folio, - sharedOptimisticProposer, + makeAddr("shared-staking-vault-opt"), "mvRWA optimistic start rebalance" ); _assertCannotCreateOptimisticStartRebalanceProposal( IReserveOptimisticGovernorLike(stakingVaultDep.newGovernor), mvDefiCfg.folio, - sharedOptimisticProposer, + makeAddr("shared-staking-vault-opt"), "mvDEFI optimistic start rebalance" ); From 5489aa68de785c7230683ffed3aa4abe245e25df Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Fri, 15 May 2026 23:08:59 +0000 Subject: [PATCH 10/28] test BED/SMEL shared case --- .../GovernanceSpellEthereum_04_17_2026.t.sol | 155 +++++++++++------- 1 file changed, 98 insertions(+), 57 deletions(-) diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol index d29fd1be..ec05bd32 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol @@ -77,6 +77,23 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 ); } + // mvDEFI + { + address[] memory guardians = new address[](2); + guardians[0] = 0x38afC3aA2c76b4cA1F8e1DabA68e998e1F4782DB; + guardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; + + CONFIGS.push( + Config({ + folio: Folio(0x20d81101D254729a6E689418526bE31e2c544290), + proxyAdmin: FolioProxyAdmin(0x3927882f047944A9c561F29E204C370Dd84852Fd), + stakingVaultGovernor: IFolioGovernor(0x83d070B91aef472CE993BCC25907e7c3959483b4), + oldFolioGovernor: IFolioGovernor(0xa5168b7b5c081a2098420892c9DA26B6B30fc496), + guardians: guardians + }) + ); + } + // DFX { address[] memory guardians = new address[](2); @@ -129,29 +146,36 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 } function test_upgradeFlow_sharedNewStakingVault_fork() public { - address[] memory mvDefiGuardians = new address[](2); - mvDefiGuardians[0] = 0x38afC3aA2c76b4cA1F8e1DabA68e998e1F4782DB; - mvDefiGuardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; - - Config memory mvRwaCfg = _configByFolio(0xA5cdea03B11042fc10B52aF9eCa48bb17A2107d2); - - Config memory mvDefiCfg = Config({ - folio: Folio(0x20d81101D254729a6E689418526bE31e2c544290), - proxyAdmin: FolioProxyAdmin(0x3927882f047944A9c561F29E204C370Dd84852Fd), - stakingVaultGovernor: IFolioGovernor(0x83d070B91aef472CE993BCC25907e7c3959483b4), - oldFolioGovernor: IFolioGovernor(0xa5168b7b5c081a2098420892c9DA26B6B30fc496), - guardians: mvDefiGuardians - }); + _runSharedNewStakingVaultFlow( + _configByFolio(0xA5cdea03B11042fc10B52aF9eCa48bb17A2107d2), + _configByFolio(0x20d81101D254729a6E689418526bE31e2c544290), + "mvRWA", + "mvDEFI" + ); - address sharedStakingVault = mvRwaCfg.stakingVaultGovernor.token(); + _runSharedNewStakingVaultFlow( + _configByFolio(0x4E3B170DcBe704b248df5f56D488114acE01B1C5), + _configByFolio(0xF91384484F4717314798E8975BCd904A35fc2BF1), + "BED", + "SMEL" + ); + } + + function _runSharedNewStakingVaultFlow( + Config memory firstCfg, + Config memory secondCfg, + string memory firstLabel, + string memory secondLabel + ) internal { + address sharedStakingVault = firstCfg.stakingVaultGovernor.token(); address oldSharedStakingVaultOwner = IOwnableStakingVault(sharedStakingVault).owner(); - assertEq(sharedStakingVault, mvDefiCfg.stakingVaultGovernor.token(), "expected shared staking vault"); + assertEq(sharedStakingVault, secondCfg.stakingVaultGovernor.token(), "expected shared staking vault"); address newUnderlying = IStakingVault(sharedStakingVault).asset(); SuccessorDeployment memory stakingVaultDep = _deploySuccessorStakingVault( - mvRwaCfg, - _doubleAddressArray(address(mvRwaCfg.folio), address(mvDefiCfg.folio)), - keccak256("mvRWA-shared-vault") + firstCfg, + _doubleAddressArray(address(firstCfg.folio), address(secondCfg.folio)), + keccak256(abi.encode(firstLabel, secondLabel, "shared-vault")) ); assertEq( @@ -163,78 +187,95 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 assertEq(IStakingVault(stakingVaultDep.newStakingVault).asset(), newUnderlying, "new vault asset mismatch"); _assertRewardTokens( stakingVaultDep.newStakingVault, - _doubleAddressArray(address(mvRwaCfg.folio), address(mvDefiCfg.folio)) + _rewardTokensForUnderlying( + newUnderlying, + _doubleAddressArray(address(firstCfg.folio), address(secondCfg.folio)) + ) ); - uint96 mvRwaOldVaultFeePortionBefore = _feeRecipientPortion(mvRwaCfg.folio, sharedStakingVault); - uint96 mvRwaNewVaultFeePortionBefore = _feeRecipientPortion(mvRwaCfg.folio, stakingVaultDep.newStakingVault); - assertGt(uint256(mvRwaOldVaultFeePortionBefore), 0, "mvRWA old vault should receive folio fees"); - GovernanceSpell_04_17_2026.NewDeployment memory mvRwaFolioDep = _upgradeFolio( - mvRwaCfg, + uint96 firstOldVaultFeePortionBefore = _feeRecipientPortion(firstCfg.folio, sharedStakingVault); + uint96 firstNewVaultFeePortionBefore = _feeRecipientPortion(firstCfg.folio, stakingVaultDep.newStakingVault); + assertGt( + uint256(firstOldVaultFeePortionBefore), 0, string.concat(firstLabel, " old vault should receive folio fees") + ); + GovernanceSpell_04_17_2026.NewDeployment memory firstFolioDep = _upgradeFolio( + firstCfg, IStakingVault(stakingVaultDep.newStakingVault), - makeAddr("mvrwa-folio-opt"), - keccak256("mvRWA-folio") + makeAddr(string.concat(firstLabel, "-folio-opt")), + keccak256(abi.encode(firstLabel, "folio")) + ); + assertEq( + firstFolioDep.stakingVault, + stakingVaultDep.newStakingVault, + string.concat(firstLabel, " returned vault mismatch") ); - assertEq(mvRwaFolioDep.stakingVault, stakingVaultDep.newStakingVault, "mvRWA returned vault mismatch"); _assertFeeRecipientMigrated( - mvRwaCfg.folio, + firstCfg.folio, sharedStakingVault, stakingVaultDep.newStakingVault, - mvRwaOldVaultFeePortionBefore, - mvRwaNewVaultFeePortionBefore + firstOldVaultFeePortionBefore, + firstNewVaultFeePortionBefore ); - _assertFolioGovernanceInstalled(mvRwaCfg, mvRwaFolioDep.newTimelock); + _assertFolioGovernanceInstalled(firstCfg, firstFolioDep.newTimelock); assertEq( - IFolioGovernor(mvRwaFolioDep.newGovernor).token(), + IFolioGovernor(firstFolioDep.newGovernor).token(), stakingVaultDep.newStakingVault, - "mvRWA folio governor should use the upgraded staking vault" + string.concat(firstLabel, " folio governor should use the upgraded staking vault") ); - uint96 mvDefiOldVaultFeePortionBefore = _feeRecipientPortion(mvDefiCfg.folio, sharedStakingVault); - uint96 mvDefiNewVaultFeePortionBefore = _feeRecipientPortion(mvDefiCfg.folio, stakingVaultDep.newStakingVault); - assertGt(uint256(mvDefiOldVaultFeePortionBefore), 0, "mvDEFI old vault should receive folio fees"); - GovernanceSpell_04_17_2026.NewDeployment memory mvDefiFolioDep = _upgradeFolio( - mvDefiCfg, + uint96 secondOldVaultFeePortionBefore = _feeRecipientPortion(secondCfg.folio, sharedStakingVault); + uint96 secondNewVaultFeePortionBefore = _feeRecipientPortion(secondCfg.folio, stakingVaultDep.newStakingVault); + assertGt( + uint256(secondOldVaultFeePortionBefore), + 0, + string.concat(secondLabel, " old vault should receive folio fees") + ); + GovernanceSpell_04_17_2026.NewDeployment memory secondFolioDep = _upgradeFolio( + secondCfg, IStakingVault(stakingVaultDep.newStakingVault), - makeAddr("mvdefi-folio-opt"), - keccak256("mvDEFI-folio") + makeAddr(string.concat(secondLabel, "-folio-opt")), + keccak256(abi.encode(secondLabel, "folio")) + ); + assertEq( + secondFolioDep.stakingVault, + stakingVaultDep.newStakingVault, + string.concat(secondLabel, " returned vault mismatch") ); - assertEq(mvDefiFolioDep.stakingVault, stakingVaultDep.newStakingVault, "mvDEFI returned vault mismatch"); _assertFeeRecipientMigrated( - mvDefiCfg.folio, + secondCfg.folio, sharedStakingVault, stakingVaultDep.newStakingVault, - mvDefiOldVaultFeePortionBefore, - mvDefiNewVaultFeePortionBefore + secondOldVaultFeePortionBefore, + secondNewVaultFeePortionBefore ); - _assertFolioGovernanceInstalled(mvDefiCfg, mvDefiFolioDep.newTimelock); + _assertFolioGovernanceInstalled(secondCfg, secondFolioDep.newTimelock); assertEq( - IFolioGovernor(mvDefiFolioDep.newGovernor).token(), + IFolioGovernor(secondFolioDep.newGovernor).token(), stakingVaultDep.newStakingVault, - "mvDEFI folio governor should use the upgraded staking vault" + string.concat(secondLabel, " folio governor should use the upgraded staking vault") ); assertTrue( - mvRwaFolioDep.newGovernor != stakingVaultDep.newGovernor, - "mvRWA folio governor should be distinct from staking vault governance" + firstFolioDep.newGovernor != stakingVaultDep.newGovernor, + string.concat(firstLabel, " folio governor should be distinct from staking vault governance") ); assertTrue( - mvDefiFolioDep.newGovernor != stakingVaultDep.newGovernor, - "mvDEFI folio governor should be distinct from staking vault governance" + secondFolioDep.newGovernor != stakingVaultDep.newGovernor, + string.concat(secondLabel, " folio governor should be distinct from staking vault governance") ); - assertTrue(mvRwaFolioDep.newGovernor != mvDefiFolioDep.newGovernor, "folios should not share a governor"); - assertTrue(mvRwaFolioDep.newTimelock != mvDefiFolioDep.newTimelock, "folios should not share a timelock"); + assertTrue(firstFolioDep.newGovernor != secondFolioDep.newGovernor, "folios should not share a governor"); + assertTrue(firstFolioDep.newTimelock != secondFolioDep.newTimelock, "folios should not share a timelock"); _assertCannotCreateOptimisticStartRebalanceProposal( IReserveOptimisticGovernorLike(stakingVaultDep.newGovernor), - mvRwaCfg.folio, + firstCfg.folio, makeAddr("shared-staking-vault-opt"), - "mvRWA optimistic start rebalance" + string.concat(firstLabel, " optimistic start rebalance") ); _assertCannotCreateOptimisticStartRebalanceProposal( IReserveOptimisticGovernorLike(stakingVaultDep.newGovernor), - mvDefiCfg.folio, + secondCfg.folio, makeAddr("shared-staking-vault-opt"), - "mvDEFI optimistic start rebalance" + string.concat(secondLabel, " optimistic start rebalance") ); _retireOldStakingVault(IOwnableStakingVault(sharedStakingVault)); From dad9642aacfd7bb056a00a0383aa660bef64e3c2 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Sat, 16 May 2026 01:10:51 +0000 Subject: [PATCH 11/28] hardcode proposal threshold / quorum --- .../spells/GovernanceSpell_04_17_2026.sol | 32 +----------- .../GenericGovernanceSpell_04_17_2026.t.sol | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+), 30 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index 21349e34..aabcff98 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -22,8 +22,6 @@ bytes32 constant CANCELLER_ROLE = keccak256("CANCELLER_ROLE"); interface IFolioGovernor is IGovernor { function token() external view returns (address); function timelock() external view returns (address); - function quorumNumerator() external view returns (uint256); - function quorumDenominator() external view returns (uint256); } interface IVersioned { @@ -236,8 +234,6 @@ contract GovernanceSpell_04_17_2026 { address[] memory optimisticProposers, address[] calldata guardians ) internal view returns (IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams) { - IStakingVault oldStakingVault = IStakingVault(oldGovernor.token()); - // Optimistic governance params baseParams.optimisticParams = optimisticParams; @@ -245,10 +241,8 @@ contract GovernanceSpell_04_17_2026 { baseParams.standardParams.votingDelay = 2 days; baseParams.standardParams.votingPeriod = 3 days; baseParams.standardParams.voteExtension = 2 days; - ( - baseParams.standardParams.proposalThreshold, - baseParams.standardParams.quorumNumerator - ) = _proposalThresholdAndQuorum(oldStakingVault, oldGovernor); + baseParams.standardParams.proposalThreshold = 0.001e18; // 0.1% + baseParams.standardParams.quorumNumerator = 0.1e18; // 10% // hard-coded long standard governance params to unify across DTFs // Optimistic whitelists @@ -275,28 +269,6 @@ contract GovernanceSpell_04_17_2026 { selectorData[0] = IOptimisticSelectorRegistry.SelectorData({ target: address(folio), selectors: selectors }); } - /// @return proposalThreshold D18{1} - /// @return quorumNumerator D18{1} - function _proposalThresholdAndQuorum( - IStakingVault stakingVault, - IFolioGovernor governor - ) internal view returns (uint256 proposalThreshold, uint256 quorumNumerator) { - uint256 pastSupply = stakingVault.getPastTotalSupply(stakingVault.clock() - 1); - - // {tok} - uint256 proposalThresholdWithSupply = governor.proposalThreshold(); - - // D18{1} = {tok} * D18{1} / {tok} - proposalThreshold = (proposalThresholdWithSupply * 1e18 + pastSupply - 1) / pastSupply; - require(proposalThreshold >= 0.0001e18 && proposalThreshold <= 0.1e18, UpgradeError(15)); - - uint256 quorumDenominator = governor.quorumDenominator(); - - // D18{1} - quorumNumerator = (governor.quorumNumerator() * 1e18 + quorumDenominator - 1) / quorumDenominator; - require(quorumNumerator >= 0.01e18 && quorumNumerator <= 0.25e18, UpgradeError(16)); - } - /// Require `guardians` is a subset of the old timelock's CANCELLER_ROLE members (excl old governor) /// @dev Does NOT confirm `guardians` is the complete set of CANCELLER_ROLE members function _validateGuardians(IFolioGovernor oldGovernor, address[] memory guardians) internal view { diff --git a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol index 50c04f96..e6507a1b 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol @@ -51,6 +51,19 @@ contract MockGovernanceVersionRegistry { } } +contract GovernanceSpell_04_17_2026_Harness is GovernanceSpell_04_17_2026 { + constructor(IReserveOptimisticGovernorDeployer governorDeployer) GovernanceSpell_04_17_2026(governorDeployer) {} + + function baseDeploymentParams( + IFolioGovernor oldGovernor, + IReserveOptimisticGovernor.OptimisticGovernanceParams calldata optimisticParams, + address[] memory optimisticProposers, + address[] calldata guardians + ) external view returns (IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory) { + return _baseDeploymentParams(oldGovernor, optimisticParams, optimisticProposers, guardians); + } +} + interface IReserveOptimisticGovernorLike is IFolioGovernor { function proposeOptimistic( address[] calldata targets, @@ -60,6 +73,10 @@ interface IReserveOptimisticGovernorLike is IFolioGovernor { ) external returns (uint256); function isOptimistic(uint256 proposalId) external view returns (bool); + + function quorumNumerator() external view returns (uint256); + + function quorumDenominator() external view returns (uint256); } interface IRetiredStakingVaultLike is IOwnableStakingVault { @@ -105,6 +122,18 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { } } + function test_standardGovernanceParamsAreHardcoded_fork() public { + GovernanceSpell_04_17_2026_Harness harness = new GovernanceSpell_04_17_2026_Harness( + optimisticGovernanceDeployer + ); + address[] memory optimisticProposers = new address[](0); + + for (uint256 i; i < CONFIGS.length; i++) { + _assertHardcodedBaseParams(harness, CONFIGS[i].stakingVaultGovernor, optimisticProposers, CONFIGS[i].guardians); + _assertHardcodedBaseParams(harness, CONFIGS[i].oldFolioGovernor, optimisticProposers, CONFIGS[i].guardians); + } + } + // === Internal === function _logFolioSymbol(Folio folio) internal view { @@ -304,6 +333,7 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { ) internal { uint256 proposalThreshold = governor.proposalThreshold(); _seedVotes(address(stakingVault), standardProposer, proposalThreshold + 1e18); + _assertDeployedGovernanceParams(governor); // Standard proposal (pessimistic path) ( @@ -340,6 +370,28 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { assertTrue(governor.isOptimistic(optimisticProposalId)); } + function _assertDeployedGovernanceParams(IReserveOptimisticGovernorLike governor) internal view { + assertEq(governor.quorumNumerator(), 0.1e18, "quorum numerator mismatch"); + assertEq(governor.quorumDenominator(), 1e18, "quorum denominator mismatch"); + } + + function _assertHardcodedBaseParams( + GovernanceSpell_04_17_2026_Harness harness, + IFolioGovernor oldGovernor, + address[] memory optimisticProposers, + address[] memory guardians + ) internal view { + IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams = harness.baseDeploymentParams( + oldGovernor, + _optimisticParams(), + optimisticProposers, + guardians + ); + + assertEq(baseParams.standardParams.proposalThreshold, 0.001e18, "proposal threshold param mismatch"); + assertEq(baseParams.standardParams.quorumNumerator, 0.1e18, "quorum numerator param mismatch"); + } + function _assertCannotCreateOptimisticStartRebalanceProposal( IReserveOptimisticGovernorLike governor, Folio folio, From 48c52f04125b537cf2ac24d5bfbbcd383952447d Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Sat, 16 May 2026 01:35:42 +0000 Subject: [PATCH 12/28] latest block; remove non-upgradeable DTFs --- .../GovernanceSpellBase_04_17_2026.t.sol | 60 +------------------ .../GovernanceSpellBsc_04_17_2026.t.sol | 2 +- .../GovernanceSpellEthereum_04_17_2026.t.sol | 42 +------------ 3 files changed, 5 insertions(+), 99 deletions(-) diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol index 8cd6ce1d..f9a4eab2 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol @@ -8,15 +8,13 @@ contract GovernanceSpellBase_04_17_2026_Test is GenericGovernanceSpell_04_17_202 deploymentData = DeploymentData({ deploymentType: Deployment.FORK, forkTarget: ForkNetwork.BASE, - forkBlock: 42440100 + forkBlock: 46051387 }); // LCAP { - address[] memory guardians = new address[](3); - guardians[0] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; - guardians[1] = 0x5e6EF4cFd64e29981fB3a8703a584DDE407032d2; - guardians[2] = 0x718841C68eab4038EF389C154f8e91f9923b2fdA; + address[] memory guardians = new address[](1); + guardians[0] = 0x718841C68eab4038EF389C154f8e91f9923b2fdA; CONFIGS.push( Config({ @@ -63,41 +61,6 @@ contract GovernanceSpellBase_04_17_2026_Test is GenericGovernanceSpell_04_17_202 ); } - // ZORA - { - address[] memory guardians = new address[](3); - guardians[0] = 0x6BC2F0cefE18ec4e5AFEB8f810c7063BeD3f92B9; - guardians[1] = 0x12808Cfbf64BE76aca0B13c523985BBb88015401; - guardians[2] = 0x7f7bf1d0B4bb7395bb68E99e20C732f3AEFFfe47; - - CONFIGS.push( - Config({ - folio: Folio(0x160c18476F6f5099f374033fbc695c9234Cda495), - proxyAdmin: FolioProxyAdmin(0xE6179EEF5312487e6caB447356c855eEE805781E), - stakingVaultGovernor: IFolioGovernor(0xE54C0534D71BAaCdeC2B9D0C576d73D76fef0869), - oldFolioGovernor: IFolioGovernor(0xD71981CC95f29077199B4cABE601BE78B662a88C), - guardians: guardians - }) - ); - } - - // AIndex - { - address[] memory guardians = new address[](2); - guardians[0] = 0x5edB66B4c01355B07dF3Ea9e4c2508e4Cc542c6a; - guardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; - - CONFIGS.push( - Config({ - folio: Folio(0xfe45EDa533e97198d9f3dEEDA9aE6c147141f6F9), - proxyAdmin: FolioProxyAdmin(0x456219b7897384217ca224f735DBbC30c395C87F), - stakingVaultGovernor: IFolioGovernor(0x61FA1b18F37A361E961c5fB07D730EE37DC0dC4d), - oldFolioGovernor: IFolioGovernor(0x26305E88587ecFde34a9DCE37D7CB292a3b51B02), - guardians: guardians - }) - ); - } - // CLANKER { address[] memory guardians = new address[](2); @@ -115,23 +78,6 @@ contract GovernanceSpellBase_04_17_2026_Test is GenericGovernanceSpell_04_17_202 ); } - // VIRTUALS - { - address[] memory guardians = new address[](2); - guardians[0] = 0x50B7a52556e0746F190663fc58a8133427fB6be2; - guardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; - - CONFIGS.push( - Config({ - folio: Folio(0x47686106181b3CEfe4eAf94C4c10b48Ac750370b), - proxyAdmin: FolioProxyAdmin(0x7C1fAFfc7F3a52aa9Dbd265E5709202eeA3A8A48), - stakingVaultGovernor: IFolioGovernor(0xD8f869c8d9EE22f4dD786EA37eFcd236810F9942), - oldFolioGovernor: IFolioGovernor(0xA8Ce43762De703D285B019fAC8829148e3013442), - guardians: guardians - }) - ); - } - // BDTF { address[] memory guardians = new address[](2); diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBsc_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBsc_04_17_2026.t.sol index 623737ee..fb306633 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBsc_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBsc_04_17_2026.t.sol @@ -8,7 +8,7 @@ contract GovernanceSpellBsc_04_17_2026_Test is GenericGovernanceSpell_04_17_2026 deploymentData = DeploymentData({ deploymentType: Deployment.FORK, forkTarget: ForkNetwork.BSC, - forkBlock: 82987668 + forkBlock: 98532091 }); // CMC20 diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol index ec05bd32..3962bb0b 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol @@ -8,7 +8,7 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 deploymentData = DeploymentData({ deploymentType: Deployment.FORK, forkTarget: ForkNetwork.ETHEREUM, - forkBlock: 24505217 + forkBlock: 25104004 }); // OPEN @@ -61,39 +61,6 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 ); } - // mvRWA - { - address[] memory guardians = new address[](1); - guardians[0] = 0x38afC3aA2c76b4cA1F8e1DabA68e998e1F4782DB; - - CONFIGS.push( - Config({ - folio: Folio(0xA5cdea03B11042fc10B52aF9eCa48bb17A2107d2), - proxyAdmin: FolioProxyAdmin(0x019318674560C233893aA31Bc0A380dc71dc2dDf), - stakingVaultGovernor: IFolioGovernor(0x83d070B91aef472CE993BCC25907e7c3959483b4), - oldFolioGovernor: IFolioGovernor(0x58e72A9a9E9Dc5209D02335d5Ac67eD28a86EAe9), - guardians: guardians - }) - ); - } - - // mvDEFI - { - address[] memory guardians = new address[](2); - guardians[0] = 0x38afC3aA2c76b4cA1F8e1DabA68e998e1F4782DB; - guardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; - - CONFIGS.push( - Config({ - folio: Folio(0x20d81101D254729a6E689418526bE31e2c544290), - proxyAdmin: FolioProxyAdmin(0x3927882f047944A9c561F29E204C370Dd84852Fd), - stakingVaultGovernor: IFolioGovernor(0x83d070B91aef472CE993BCC25907e7c3959483b4), - oldFolioGovernor: IFolioGovernor(0xa5168b7b5c081a2098420892c9DA26B6B30fc496), - guardians: guardians - }) - ); - } - // DFX { address[] memory guardians = new address[](2); @@ -146,13 +113,6 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 } function test_upgradeFlow_sharedNewStakingVault_fork() public { - _runSharedNewStakingVaultFlow( - _configByFolio(0xA5cdea03B11042fc10B52aF9eCa48bb17A2107d2), - _configByFolio(0x20d81101D254729a6E689418526bE31e2c544290), - "mvRWA", - "mvDEFI" - ); - _runSharedNewStakingVaultFlow( _configByFolio(0x4E3B170DcBe704b248df5f56D488114acE01B1C5), _configByFolio(0xF91384484F4717314798E8975BCd904A35fc2BF1), From 96c42fbefe398af84a24a311b4aca0e8c5d6afb4 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Sat, 16 May 2026 01:54:37 +0000 Subject: [PATCH 13/28] fork test ABX/MVTT10F --- .../GovernanceSpellBase_04_17_2026.t.sol | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol index f9a4eab2..00d4e20f 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol @@ -94,5 +94,39 @@ contract GovernanceSpellBase_04_17_2026_Test is GenericGovernanceSpell_04_17_202 }) ); } + + // ABX + { + address[] memory guardians = new address[](2); + guardians[0] = 0x9F7f914F53Ee403A7a5725f34fE8E6406A4f84cD; + guardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; + + CONFIGS.push( + Config({ + folio: Folio(0xeBcda5b80f62DD4DD2A96357b42BB6Facbf30267), + proxyAdmin: FolioProxyAdmin(0xF3345fca866673BfB58b50F00691219a62Dd6Dc8), + stakingVaultGovernor: IFolioGovernor(0xcdd675d848372596E5eCc1B0FE9e88C1CBc609Af), + oldFolioGovernor: IFolioGovernor(0x6dFF5971cc446479450e51b5f939A250b11F5Ef5), + guardians: guardians + }) + ); + } + + // MVTT10F + { + address[] memory guardians = new address[](2); + guardians[0] = 0xD8B0F4e54a8dac04E0A57392f5A630cEdb99C940; + guardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; + + CONFIGS.push( + Config({ + folio: Folio(0xe8b46b116D3BdFA787CE9CF3f5aCC78dc7cA380E), + proxyAdmin: FolioProxyAdmin(0xBe278Be45C265A589BD0bf8cDC6C9e5a04B3397D), + stakingVaultGovernor: IFolioGovernor(0xa29D5B7DACf13f417a87F9B5FF7C63d86e48F689), + oldFolioGovernor: IFolioGovernor(0x3d14EE40A64F30F3a3515FCA9Cf6787aCA1925b5), + guardians: guardians + }) + ); + } } } From dad21b9db7e487d87bae7a0f083c68be2d769eaf Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Sat, 16 May 2026 01:58:39 +0000 Subject: [PATCH 14/28] comments --- contracts/spells/GovernanceSpell_04_17_2026.sol | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index aabcff98..66b852e3 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -52,8 +52,8 @@ interface IOwnableStakingVault is IStakingVault { * Upgrade flow: * 1. deploySuccessorStakingVault: Permissionlessly deploy a NEW StakingVault with its own NEW * governor/timelock system, isolated from the old vault. No permissions required. - * 2. upgradeFolio: Deploy NEW Folio governance system on the successor StakingVault; rotate Folio roles - * and fee recipients from old StakingVault to new StakingVault. Wait for new stake before calling. + * 2. upgradeFolio: Deploy NEW Folio governance system on the successor StakingVault; rotate Folio roles, + * and rotate fee recipients from old StakingVault to new StakingVault. Wait for new stake before calling. * Caller: old timelock of Folio * 3. retireOldStakingVault: After every dependent Folio has completed step 2, permanently seal the * old StakingVault (zero unstaking delay, fast reward handout, renounce ownership). @@ -159,6 +159,7 @@ contract GovernanceSpell_04_17_2026 { address[] calldata guardians, bytes32 deploymentNonce ) public returns (NewDeployment memory newDeployment) { + // included for readability, root of security is folioProxyAdmin + folio role checks require(oldFolioGovernor.timelock() == msg.sender, UpgradeError(1)); IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams = _baseDeploymentParams( From 7db1a6d3f994607faf485f4fa2ee2db0c07abd7b Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Sat, 16 May 2026 02:33:19 +0000 Subject: [PATCH 15/28] restrict upgrades to 4.0.0 and 5.0.0 --- .../spells/GovernanceSpell_04_17_2026.sol | 20 ++++++++-- .../GenericGovernanceSpell_04_17_2026.t.sol | 40 +++++++++++++++---- .../GovernanceSpellBase_04_17_2026.t.sol | 17 -------- .../GovernanceSpellEthereum_04_17_2026.t.sol | 4 +- 4 files changed, 52 insertions(+), 29 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index 66b852e3..d26f6281 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -17,6 +17,13 @@ import { FolioProxyAdmin } from "@folio/FolioProxy.sol"; import { DEFAULT_ADMIN_ROLE, REBALANCE_MANAGER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; bytes32 constant VERSION_1_0_0 = keccak256("1.0.0"); +bytes32 constant VERSION_4_0_0 = keccak256("4.0.0"); +bytes32 constant VERSION_5_0_0 = keccak256("5.0.0"); +bytes4 constant START_REBALANCE_4_0_0 = bytes4( + keccak256( + "startRebalance(address[],(uint256,uint256,uint256)[],(uint256,uint256,uint256)[],(uint256,uint256),uint256,uint256)" + ) +); bytes32 constant CANCELLER_ROLE = keccak256("CANCELLER_ROLE"); interface IFolioGovernor is IGovernor { @@ -138,11 +145,12 @@ contract GovernanceSpell_04_17_2026 { /// Deploy a new Folio governor/timelock on an existing staking vault and transfer Folio ownership/roles /// @dev Requirements: /// - Caller is old Folio timelock + /// - Folio is exactly version 4.0.0 or 5.0.0 /// - Self is Folio admin /// - Self is FolioProxyAdmin owner /// @dev IMPORTANT: Do not call until the `newStakingVault` has been sufficiently populated by new stake /// @dev New Governance system will use standard 2-3-2 day voting independent of previous voting settings - /// @dev It is not verified that the new StakingVault is already configured to handout the Folio as reward token. + /// @dev It is not verified that the new StakingVault is already configured to handout the Folio as reward token. /// This is an accepted limitation to reduce the overall number of blocking steps in the upgrade sequence. /// @param newStakingVault New staking vault to use for the new governor /// @param oldFolioGovernor Governor currently attached to the Folio being upgraded @@ -161,6 +169,8 @@ contract GovernanceSpell_04_17_2026 { ) public returns (NewDeployment memory newDeployment) { // included for readability, root of security is folioProxyAdmin + folio role checks require(oldFolioGovernor.timelock() == msg.sender, UpgradeError(1)); + bytes32 folioVersion = keccak256(bytes(IVersioned(address(folio)).version())); + require(folioVersion == VERSION_4_0_0 || folioVersion == VERSION_5_0_0, UpgradeError(28)); IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams = _baseDeploymentParams( oldFolioGovernor, @@ -168,7 +178,7 @@ contract GovernanceSpell_04_17_2026 { optimisticProposers, guardians ); - baseParams.selectorData = _startRebalanceSelectorData(folio); + baseParams.selectorData = _startRebalanceSelectorData(folio, folioVersion); newDeployment.stakingVault = address(newStakingVault); (newDeployment.newGovernor, newDeployment.newTimelock, newDeployment.newSelectorRegistry) = governorDeployer @@ -262,11 +272,13 @@ contract GovernanceSpell_04_17_2026 { } function _startRebalanceSelectorData( - Folio folio + Folio folio, + bytes32 folioVersion ) internal pure returns (IOptimisticSelectorRegistry.SelectorData[] memory selectorData) { selectorData = new IOptimisticSelectorRegistry.SelectorData[](1); - bytes4[] memory selectors = new bytes4[](1); + bytes4[] memory selectors = new bytes4[](folioVersion == VERSION_4_0_0 ? 2 : 1); selectors[0] = Folio.startRebalance.selector; + if (folioVersion == VERSION_4_0_0) selectors[1] = START_REBALANCE_4_0_0; selectorData[0] = IOptimisticSelectorRegistry.SelectorData({ target: address(folio), selectors: selectors }); } diff --git a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol index e6507a1b..e9f7d24d 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol @@ -88,6 +88,14 @@ interface IRewardedStakingVaultLike is IStakingVault { } abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { + bytes32 internal constant FOLIO_VERSION_4_0_0 = keccak256("4.0.0"); + bytes4 internal constant START_REBALANCE_4_0_0 = + bytes4( + keccak256( + "startRebalance(address[],(uint256,uint256,uint256)[],(uint256,uint256,uint256)[],(uint256,uint256),uint256,uint256)" + ) + ); + struct Config { Folio folio; FolioProxyAdmin proxyAdmin; @@ -129,7 +137,12 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { address[] memory optimisticProposers = new address[](0); for (uint256 i; i < CONFIGS.length; i++) { - _assertHardcodedBaseParams(harness, CONFIGS[i].stakingVaultGovernor, optimisticProposers, CONFIGS[i].guardians); + _assertHardcodedBaseParams( + harness, + CONFIGS[i].stakingVaultGovernor, + optimisticProposers, + CONFIGS[i].guardians + ); _assertHardcodedBaseParams(harness, CONFIGS[i].oldFolioGovernor, optimisticProposers, CONFIGS[i].guardians); } } @@ -140,6 +153,10 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { console2.log("Folio symbol", folio.symbol()); } + function _folioVersion(Folio folio) internal view returns (bytes32) { + return keccak256(bytes(IVersionedLike(address(folio)).version())); + } + function _upgradeFolio( Config memory cfg, IStakingVault newStakingVault, @@ -340,7 +357,7 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { address[] memory standardTargets, uint256[] memory standardValues, bytes[] memory standardCalldatas - ) = _singleCall(address(folio), 0, abi.encodeCall(Folio.setName, ("standard proposal"))); + ) = _singleCall(address(folio), 0, abi.encodeCall(Folio.setMandate, ("standard proposal"))); vm.prank(standardProposer); uint256 standardProposalId = governor.propose( @@ -357,7 +374,7 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { address[] memory optimisticTargets, uint256[] memory optimisticValues, bytes[] memory optimisticCalldatas - ) = _singleCall(address(folio), 0, _startRebalanceCalldata()); + ) = _singleCall(address(folio), 0, _startRebalanceCalldata(folio)); vm.prank(optimisticProposer); uint256 optimisticProposalId = governor.proposeOptimistic( @@ -401,7 +418,7 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = _singleCall( address(folio), 0, - _startRebalanceCalldata() + _startRebalanceCalldata(folio) ); vm.expectRevert( @@ -424,7 +441,7 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { address[] memory optimisticTargets, uint256[] memory optimisticValues, bytes[] memory optimisticCalldatas - ) = _singleCall(address(folio), 0, _startRebalanceCalldata()); + ) = _singleCall(address(folio), 0, _startRebalanceCalldata(folio)); vm.prank(optimisticProposer); uint256 optimisticProposalId = governor.proposeOptimistic( @@ -560,9 +577,18 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { calldatas[0] = calldata_; } - function _startRebalanceCalldata() internal pure returns (bytes memory calldata_) { - IFolio.TokenRebalanceParams[] memory tokens = new IFolio.TokenRebalanceParams[](0); + function _startRebalanceCalldata(Folio folio) internal view returns (bytes memory calldata_) { IFolio.RebalanceLimits memory limits = IFolio.RebalanceLimits({ low: 1, spot: 1, high: 1 }); + + if (_folioVersion(folio) == FOLIO_VERSION_4_0_0) { + address[] memory v4Tokens = new address[](0); + IFolio.WeightRange[] memory weights = new IFolio.WeightRange[](0); + IFolio.PriceRange[] memory prices = new IFolio.PriceRange[](0); + + return abi.encodeWithSelector(START_REBALANCE_4_0_0, v4Tokens, weights, prices, limits, 0, 1); + } + + IFolio.TokenRebalanceParams[] memory tokens = new IFolio.TokenRebalanceParams[](0); calldata_ = abi.encodeCall(Folio.startRebalance, (tokens, limits, 0, 1)); } diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol index 00d4e20f..0e8e1d4d 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol @@ -78,23 +78,6 @@ contract GovernanceSpellBase_04_17_2026_Test is GenericGovernanceSpell_04_17_202 ); } - // BDTF - { - address[] memory guardians = new address[](2); - guardians[0] = 0xA80149d051764f9e4854ee83B197bAD648046d51; - guardians[1] = 0x6f1D6b86d4ad705385e751e6e88b0FdFDBAdf298; - - CONFIGS.push( - Config({ - folio: Folio(0xb8753941196692E322846cfEE9C14C97AC81928A), - proxyAdmin: FolioProxyAdmin(0xADC76fB0A5ae3495443E8df8D411FD37a836F763), - stakingVaultGovernor: IFolioGovernor(0xAD3e49d114F193583c1904f93EF25784C381874b), - oldFolioGovernor: IFolioGovernor(0x0D5a4a0FEe1c4f0422938608400d00B9E0037684), - guardians: guardians - }) - ); - } - // ABX { address[] memory guardians = new address[](2); diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol index 3962bb0b..f1b8ea9e 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol @@ -156,7 +156,9 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 uint96 firstOldVaultFeePortionBefore = _feeRecipientPortion(firstCfg.folio, sharedStakingVault); uint96 firstNewVaultFeePortionBefore = _feeRecipientPortion(firstCfg.folio, stakingVaultDep.newStakingVault); assertGt( - uint256(firstOldVaultFeePortionBefore), 0, string.concat(firstLabel, " old vault should receive folio fees") + uint256(firstOldVaultFeePortionBefore), + 0, + string.concat(firstLabel, " old vault should receive folio fees") ); GovernanceSpell_04_17_2026.NewDeployment memory firstFolioDep = _upgradeFolio( firstCfg, From 3af3c0749ff042b3595ac4a9e32828ae472a828f Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Sat, 16 May 2026 03:06:27 +0000 Subject: [PATCH 16/28] check there is only 1 REBALANCE_MANAGER --- contracts/spells/GovernanceSpell_04_17_2026.sol | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index d26f6281..d6f2fc4b 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -196,13 +196,9 @@ contract GovernanceSpell_04_17_2026 { // rotate Folio fee recipients from old staking vault to new staking vault _rotateFeeRecipients(folio, oldFolioGovernor.token(), address(newStakingVault)); - // rotate Folio REBALANCE_MANAGERs - { - for (uint256 i = folio.getRoleMemberCount(REBALANCE_MANAGER); i > 0; i--) { - address rebalanceManager = folio.getRoleMember(REBALANCE_MANAGER, i - 1); - folio.revokeRole(REBALANCE_MANAGER, rebalanceManager); - } - } + // rotate Folio REBALANCE_MANAGER + require(folio.getRoleMemberCount(REBALANCE_MANAGER) == 1, UpgradeError(7)); + folio.revokeRole(REBALANCE_MANAGER, folio.getRoleMember(REBALANCE_MANAGER, 0)); folio.grantRole(REBALANCE_MANAGER, newDeployment.newTimelock); require(folio.getRoleMemberCount(REBALANCE_MANAGER) == 1, UpgradeError(7)); require(folio.getRoleMember(REBALANCE_MANAGER, 0) == newDeployment.newTimelock, UpgradeError(8)); From f679e22974902bd1de027cd6c539f8d901d2a26f Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Sat, 16 May 2026 03:08:58 +0000 Subject: [PATCH 17/28] check old timelock is not BRAND_MANAGER/AUCTION_LAUNCHER --- contracts/spells/GovernanceSpell_04_17_2026.sol | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index d6f2fc4b..36494029 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -14,7 +14,7 @@ import { IReserveOptimisticGovernor } from "@reserve-protocol/reserve-governor/c import { IFolio, Folio } from "@src/Folio.sol"; import { FolioProxyAdmin } from "@folio/FolioProxy.sol"; -import { DEFAULT_ADMIN_ROLE, REBALANCE_MANAGER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; +import { DEFAULT_ADMIN_ROLE, REBALANCE_MANAGER, BRAND_MANAGER, AUCTION_LAUNCHER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; bytes32 constant VERSION_1_0_0 = keccak256("1.0.0"); bytes32 constant VERSION_4_0_0 = keccak256("4.0.0"); @@ -193,6 +193,10 @@ contract GovernanceSpell_04_17_2026 { require(folio.hasRole(DEFAULT_ADMIN_ROLE, address(this)), UpgradeError(5)); require(folio.hasRole(DEFAULT_ADMIN_ROLE, msg.sender), UpgradeError(6)); + // BRAND_MANAGER/AUCTION_LAUNCHER + require(!folio.hasRole(BRAND_MANAGER, msg.sender), UpgradeError(29)); + require(!folio.hasRole(AUCTION_LAUNCHER, msg.sender), UpgradeError(30)); + // rotate Folio fee recipients from old staking vault to new staking vault _rotateFeeRecipients(folio, oldFolioGovernor.token(), address(newStakingVault)); From 14f9d2f1aa7e2dc0097946c5b1aa56e3ef7d0594 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Sat, 16 May 2026 03:12:47 +0000 Subject: [PATCH 18/28] check fee recipients excludes old governor/timelock --- .../spells/GovernanceSpell_04_17_2026.sol | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index 36494029..270c3da7 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -198,7 +198,13 @@ contract GovernanceSpell_04_17_2026 { require(!folio.hasRole(AUCTION_LAUNCHER, msg.sender), UpgradeError(30)); // rotate Folio fee recipients from old staking vault to new staking vault - _rotateFeeRecipients(folio, oldFolioGovernor.token(), address(newStakingVault)); + _rotateFeeRecipients( + folio, + oldFolioGovernor.token(), + address(newStakingVault), + address(oldFolioGovernor), + msg.sender + ); // rotate Folio REBALANCE_MANAGER require(folio.getRoleMemberCount(REBALANCE_MANAGER) == 1, UpgradeError(7)); @@ -294,7 +300,13 @@ contract GovernanceSpell_04_17_2026 { } /// Rotate the fee recipient entry for the old StakingVault to the new StakingVault - function _rotateFeeRecipients(Folio folio, address oldStakingVault, address newStakingVault) internal { + function _rotateFeeRecipients( + Folio folio, + address oldStakingVault, + address newStakingVault, + address oldGovernor, + address oldTimelock + ) internal { IFolio.FeeRecipient[] memory recipients = _feeRecipients(folio); uint256 oldStakingVaultRecipientCount; uint256 oldStakingVaultRecipientIndex; @@ -308,6 +320,8 @@ contract GovernanceSpell_04_17_2026 { } require(recipient != newStakingVault, UpgradeError(19)); + require(recipient != oldGovernor, UpgradeError(31)); + require(recipient != oldTimelock, UpgradeError(32)); } require(oldStakingVaultRecipientCount == 1, UpgradeError(20)); From 01b0a8c6c148f9ef2c575db8e28b202b61a26067 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Sat, 16 May 2026 03:29:08 +0000 Subject: [PATCH 19/28] hardcode v4 start rebalance selector --- contracts/spells/GovernanceSpell_04_17_2026.sol | 6 +----- .../GenericGovernanceSpell_04_17_2026.t.sol | 7 +------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index 270c3da7..fa606d5b 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -19,11 +19,7 @@ import { DEFAULT_ADMIN_ROLE, REBALANCE_MANAGER, BRAND_MANAGER, AUCTION_LAUNCHER, bytes32 constant VERSION_1_0_0 = keccak256("1.0.0"); bytes32 constant VERSION_4_0_0 = keccak256("4.0.0"); bytes32 constant VERSION_5_0_0 = keccak256("5.0.0"); -bytes4 constant START_REBALANCE_4_0_0 = bytes4( - keccak256( - "startRebalance(address[],(uint256,uint256,uint256)[],(uint256,uint256,uint256)[],(uint256,uint256),uint256,uint256)" - ) -); +bytes4 constant START_REBALANCE_4_0_0 = 0x235d7142; bytes32 constant CANCELLER_ROLE = keccak256("CANCELLER_ROLE"); interface IFolioGovernor is IGovernor { diff --git a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol index e9f7d24d..d73bb24b 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol @@ -89,12 +89,7 @@ interface IRewardedStakingVaultLike is IStakingVault { abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { bytes32 internal constant FOLIO_VERSION_4_0_0 = keccak256("4.0.0"); - bytes4 internal constant START_REBALANCE_4_0_0 = - bytes4( - keccak256( - "startRebalance(address[],(uint256,uint256,uint256)[],(uint256,uint256,uint256)[],(uint256,uint256),uint256,uint256)" - ) - ); + bytes4 internal constant START_REBALANCE_4_0_0 = 0x235d7142; struct Config { Folio folio; From 7df500b36640015c306f33807f2b9afe4a4ce522 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Sat, 16 May 2026 03:35:04 +0000 Subject: [PATCH 20/28] require Folio is registered as reward token --- .../spells/GovernanceSpell_04_17_2026.sol | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index fa606d5b..0b948183 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -11,6 +11,7 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I import { IReserveOptimisticGovernorDeployer } from "@reserve-protocol/reserve-governor/contracts/interfaces/IDeployer.sol"; import { IOptimisticSelectorRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IOptimisticSelectorRegistry.sol"; import { IReserveOptimisticGovernor } from "@reserve-protocol/reserve-governor/contracts/interfaces/IReserveOptimisticGovernor.sol"; +import { IRewardTokenRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IRewardTokenRegistry.sol"; import { IFolio, Folio } from "@src/Folio.sol"; import { FolioProxyAdmin } from "@folio/FolioProxy.sol"; @@ -33,6 +34,10 @@ interface IVersioned { interface IStakingVault is IERC5805, IERC4626, IVersioned {} +interface IRewardedStakingVault is IStakingVault { + function getAllRewardTokens() external view returns (address[] memory); +} + // old staking vault model interface IOwnableStakingVault is IStakingVault { function owner() external view returns (address); @@ -146,8 +151,6 @@ contract GovernanceSpell_04_17_2026 { /// - Self is FolioProxyAdmin owner /// @dev IMPORTANT: Do not call until the `newStakingVault` has been sufficiently populated by new stake /// @dev New Governance system will use standard 2-3-2 day voting independent of previous voting settings - /// @dev It is not verified that the new StakingVault is already configured to handout the Folio as reward token. - /// This is an accepted limitation to reduce the overall number of blocking steps in the upgrade sequence. /// @param newStakingVault New staking vault to use for the new governor /// @param oldFolioGovernor Governor currently attached to the Folio being upgraded /// @param optimisticProposers Use empty set to disable optimistic governance altogether @@ -168,6 +171,10 @@ contract GovernanceSpell_04_17_2026 { bytes32 folioVersion = keccak256(bytes(IVersioned(address(folio)).version())); require(folioVersion == VERSION_4_0_0 || folioVersion == VERSION_5_0_0, UpgradeError(28)); + // newStakingVault must not be the old immmutable kind, must be new and upgradeable + require(keccak256(bytes(IVersioned(address(newStakingVault)).version())) == VERSION_1_0_0, UpgradeError(3)); + _validateFolioRewardToken(folio, newStakingVault); + IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams = _baseDeploymentParams( oldFolioGovernor, optimisticParams, @@ -181,9 +188,6 @@ contract GovernanceSpell_04_17_2026 { .deployWithExistingStakingVault(baseParams, address(newStakingVault), deploymentNonce); require(newDeployment.newTimelock != address(0), UpgradeError(2)); - // newStakingVault must not be the old immmutable kind, must be new and upgradeable - require(keccak256(bytes(IVersioned(address(newStakingVault)).version())) == VERSION_1_0_0, UpgradeError(3)); - // confirm Folio admins are self + old timelock require(folio.getRoleMemberCount(DEFAULT_ADMIN_ROLE) == 2, UpgradeError(4)); require(folio.hasRole(DEFAULT_ADMIN_ROLE, address(this)), UpgradeError(5)); @@ -284,6 +288,17 @@ contract GovernanceSpell_04_17_2026 { selectorData[0] = IOptimisticSelectorRegistry.SelectorData({ target: address(folio), selectors: selectors }); } + function _validateFolioRewardToken(Folio folio, IStakingVault newStakingVault) internal view { + require(IRewardTokenRegistry(governorDeployer.rewardTokenRegistry()).isRegistered(address(folio)), UpgradeError(33)); + + address[] memory rewardTokens = IRewardedStakingVault(address(newStakingVault)).getAllRewardTokens(); + for (uint256 i; i < rewardTokens.length; i++) { + if (rewardTokens[i] == address(folio)) return; + } + + revert UpgradeError(34); + } + /// Require `guardians` is a subset of the old timelock's CANCELLER_ROLE members (excl old governor) /// @dev Does NOT confirm `guardians` is the complete set of CANCELLER_ROLE members function _validateGuardians(IFolioGovernor oldGovernor, address[] memory guardians) internal view { From 929b75893855fe913dee6521c08b5324e43f09bf Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Mon, 18 May 2026 16:26:37 +0000 Subject: [PATCH 21/28] add trading timelock checks --- .../spells/GovernanceSpell_04_17_2026.sol | 69 ++++++++++++------- .../GenericGovernanceSpell_04_17_2026.t.sol | 22 ++++-- .../GovernanceSpellBase_04_17_2026.t.sol | 6 ++ .../GovernanceSpellBsc_04_17_2026.t.sol | 1 + .../GovernanceSpellEthereum_04_17_2026.t.sol | 6 ++ 5 files changed, 74 insertions(+), 30 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index 0b948183..de717447 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -15,12 +15,13 @@ import { IRewardTokenRegistry } from "@reserve-protocol/reserve-governor/contrac import { IFolio, Folio } from "@src/Folio.sol"; import { FolioProxyAdmin } from "@folio/FolioProxy.sol"; -import { DEFAULT_ADMIN_ROLE, REBALANCE_MANAGER, BRAND_MANAGER, AUCTION_LAUNCHER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; +import { DEFAULT_ADMIN_ROLE, REBALANCE_MANAGER, BRAND_MANAGER, AUCTION_APPROVER, AUCTION_LAUNCHER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; bytes32 constant VERSION_1_0_0 = keccak256("1.0.0"); bytes32 constant VERSION_4_0_0 = keccak256("4.0.0"); bytes32 constant VERSION_5_0_0 = keccak256("5.0.0"); bytes4 constant START_REBALANCE_4_0_0 = 0x235d7142; +bytes32 constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE"); bytes32 constant CANCELLER_ROLE = keccak256("CANCELLER_ROLE"); interface IFolioGovernor is IGovernor { @@ -153,6 +154,7 @@ contract GovernanceSpell_04_17_2026 { /// @dev New Governance system will use standard 2-3-2 day voting independent of previous voting settings /// @param newStakingVault New staking vault to use for the new governor /// @param oldFolioGovernor Governor currently attached to the Folio being upgraded + /// @param tradingGovernor Governor currently managing Folio trading operations /// @param optimisticProposers Use empty set to disable optimistic governance altogether /// @param guardians Must be a subset of the old Folio timelock's CANCELLER_ROLE members /// The shared Guardian contract will be included as a CANCELLER_ROLE member by default @@ -161,6 +163,7 @@ contract GovernanceSpell_04_17_2026 { FolioProxyAdmin folioProxyAdmin, IStakingVault newStakingVault, IFolioGovernor oldFolioGovernor, + IFolioGovernor tradingGovernor, IReserveOptimisticGovernor.OptimisticGovernanceParams calldata optimisticParams, address[] calldata optimisticProposers, address[] calldata guardians, @@ -175,40 +178,53 @@ contract GovernanceSpell_04_17_2026 { require(keccak256(bytes(IVersioned(address(newStakingVault)).version())) == VERSION_1_0_0, UpgradeError(3)); _validateFolioRewardToken(folio, newStakingVault); - IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams = _baseDeploymentParams( - oldFolioGovernor, - optimisticParams, - optimisticProposers, - guardians - ); - baseParams.selectorData = _startRebalanceSelectorData(folio, folioVersion); - - newDeployment.stakingVault = address(newStakingVault); - (newDeployment.newGovernor, newDeployment.newTimelock, newDeployment.newSelectorRegistry) = governorDeployer - .deployWithExistingStakingVault(baseParams, address(newStakingVault), deploymentNonce); - require(newDeployment.newTimelock != address(0), UpgradeError(2)); + { + IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams = _baseDeploymentParams( + oldFolioGovernor, + optimisticParams, + optimisticProposers, + guardians + ); + baseParams.selectorData = _startRebalanceSelectorData(folio, folioVersion); + + newDeployment.stakingVault = address(newStakingVault); + (newDeployment.newGovernor, newDeployment.newTimelock, newDeployment.newSelectorRegistry) = governorDeployer + .deployWithExistingStakingVault(baseParams, address(newStakingVault), deploymentNonce); + require(newDeployment.newTimelock != address(0), UpgradeError(2)); + } // confirm Folio admins are self + old timelock require(folio.getRoleMemberCount(DEFAULT_ADMIN_ROLE) == 2, UpgradeError(4)); require(folio.hasRole(DEFAULT_ADMIN_ROLE, address(this)), UpgradeError(5)); require(folio.hasRole(DEFAULT_ADMIN_ROLE, msg.sender), UpgradeError(6)); - // BRAND_MANAGER/AUCTION_LAUNCHER + // main timelock should only be carrying admin ownership require(!folio.hasRole(BRAND_MANAGER, msg.sender), UpgradeError(29)); require(!folio.hasRole(AUCTION_LAUNCHER, msg.sender), UpgradeError(30)); + address tradingTimelock = tradingGovernor.timelock(); + + // validate old trading governance + require(folio.getRoleMemberCount(REBALANCE_MANAGER) == 1, UpgradeError(7)); + require(folio.getRoleMember(REBALANCE_MANAGER, 0) == tradingTimelock, UpgradeError(8)); + require( + TimelockController(payable(tradingTimelock)).hasRole(PROPOSER_ROLE, address(tradingGovernor)), + UpgradeError(34) + ); + require(!folio.hasRole(AUCTION_APPROVER, tradingTimelock), UpgradeError(29)); + require(!folio.hasRole(BRAND_MANAGER, tradingTimelock), UpgradeError(30)); + // rotate Folio fee recipients from old staking vault to new staking vault - _rotateFeeRecipients( - folio, - oldFolioGovernor.token(), - address(newStakingVault), + address[4] memory invalidFeeRecipients = [ address(oldFolioGovernor), - msg.sender - ); + msg.sender, + address(tradingGovernor), + tradingTimelock + ]; + _rotateFeeRecipients(folio, oldFolioGovernor.token(), address(newStakingVault), invalidFeeRecipients); // rotate Folio REBALANCE_MANAGER - require(folio.getRoleMemberCount(REBALANCE_MANAGER) == 1, UpgradeError(7)); - folio.revokeRole(REBALANCE_MANAGER, folio.getRoleMember(REBALANCE_MANAGER, 0)); + folio.revokeRole(REBALANCE_MANAGER, tradingTimelock); folio.grantRole(REBALANCE_MANAGER, newDeployment.newTimelock); require(folio.getRoleMemberCount(REBALANCE_MANAGER) == 1, UpgradeError(7)); require(folio.getRoleMember(REBALANCE_MANAGER, 0) == newDeployment.newTimelock, UpgradeError(8)); @@ -315,8 +331,7 @@ contract GovernanceSpell_04_17_2026 { Folio folio, address oldStakingVault, address newStakingVault, - address oldGovernor, - address oldTimelock + address[4] memory invalidFeeRecipients ) internal { IFolio.FeeRecipient[] memory recipients = _feeRecipients(folio); uint256 oldStakingVaultRecipientCount; @@ -331,8 +346,10 @@ contract GovernanceSpell_04_17_2026 { } require(recipient != newStakingVault, UpgradeError(19)); - require(recipient != oldGovernor, UpgradeError(31)); - require(recipient != oldTimelock, UpgradeError(32)); + require(recipient != invalidFeeRecipients[0], UpgradeError(31)); + require(recipient != invalidFeeRecipients[1], UpgradeError(32)); + require(recipient != invalidFeeRecipients[2], UpgradeError(35)); + require(recipient != invalidFeeRecipients[3], UpgradeError(36)); } require(oldStakingVaultRecipientCount == 1, UpgradeError(20)); diff --git a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol index d73bb24b..3502df66 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol @@ -18,7 +18,7 @@ import { IReserveOptimisticGovernorDeployer } from "@reserve-protocol/reserve-go import { IReserveOptimisticGovernor } from "@reserve-protocol/reserve-governor/contracts/interfaces/IReserveOptimisticGovernor.sol"; import { IRoleRegistry as IRewardRoleRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IRoleRegistry.sol"; import { RewardTokenRegistry } from "@reserve-protocol/reserve-governor/contracts/staking/RewardTokenRegistry.sol"; -import { REBALANCE_MANAGER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; +import { REBALANCE_MANAGER, BRAND_MANAGER, AUCTION_APPROVER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; import { MockRoleRegistry } from "utils/MockRoleRegistry.sol"; interface IVersionedLike { @@ -89,6 +89,7 @@ interface IRewardedStakingVaultLike is IStakingVault { abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { bytes32 internal constant FOLIO_VERSION_4_0_0 = keccak256("4.0.0"); + bytes32 internal constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE"); bytes4 internal constant START_REBALANCE_4_0_0 = 0x235d7142; struct Config { @@ -96,6 +97,7 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { FolioProxyAdmin proxyAdmin; IFolioGovernor stakingVaultGovernor; IFolioGovernor oldFolioGovernor; + address tradingTimelock; address[] guardians; } @@ -158,10 +160,19 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { address optimisticProposer, bytes32 deploymentNonce ) internal returns (GovernanceSpell_04_17_2026.NewDeployment memory dep) { - address oldFolioTimelock = cfg.oldFolioGovernor.timelock(); - assertEq(cfg.proxyAdmin.owner(), oldFolioTimelock, "old folio timelock should own proxy admin"); + address tradingTimelock = cfg.tradingTimelock; + IFolioGovernor tradingGovernor = IFolioGovernor(makeAddr("trading-governor")); + + assertEq(cfg.proxyAdmin.owner(), cfg.oldFolioGovernor.timelock(), "old folio timelock should own proxy admin"); + assertEq(cfg.folio.getRoleMember(REBALANCE_MANAGER, 0), tradingTimelock, "trading timelock mismatch"); + vm.mockCall(address(tradingGovernor), abi.encodeWithSignature("timelock()"), abi.encode(tradingTimelock)); + vm.mockCall( + tradingTimelock, + abi.encodeWithSignature("hasRole(bytes32,address)", PROPOSER_ROLE, address(tradingGovernor)), + abi.encode(true) + ); - vm.startPrank(oldFolioTimelock); + vm.startPrank(cfg.oldFolioGovernor.timelock()); cfg.proxyAdmin.transferOwnership(address(spell)); cfg.folio.grantRole(DEFAULT_ADMIN_ROLE, address(spell)); dep = spell.upgradeFolio( @@ -169,11 +180,14 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { cfg.proxyAdmin, newStakingVault, cfg.oldFolioGovernor, + tradingGovernor, _optimisticParams(), _singleAddressArray(optimisticProposer), cfg.guardians, deploymentNonce ); + assertFalse(cfg.folio.hasRole(AUCTION_APPROVER, tradingTimelock), "trading timelock still auction approver"); + assertFalse(cfg.folio.hasRole(BRAND_MANAGER, tradingTimelock), "trading timelock still brand manager"); vm.stopPrank(); } diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol index 0e8e1d4d..1ef20ecb 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBase_04_17_2026.t.sol @@ -22,6 +22,7 @@ contract GovernanceSpellBase_04_17_2026_Test is GenericGovernanceSpell_04_17_202 proxyAdmin: FolioProxyAdmin(0xf6Db82f6b5F343d74A1D88af9e58fA1d2D89562e), stakingVaultGovernor: IFolioGovernor(0x2DEE428BD8131FAa4288750d707De6F3901AfE3c), oldFolioGovernor: IFolioGovernor(0x719eDEd05c7a6468E44AcFBBD19b2DF2EED7759E), + tradingTimelock: 0xB785A1daC3724ea73A1368d6E0085B6A930e2CCD, guardians: guardians }) ); @@ -39,6 +40,7 @@ contract GovernanceSpellBase_04_17_2026_Test is GenericGovernanceSpell_04_17_202 proxyAdmin: FolioProxyAdmin(0x17747f766e375a73959EBc0dBc623A174D4DB317), stakingVaultGovernor: IFolioGovernor(0x42F72247FeFe2a4702e7E7aa71E3e1784c46f6Ae), oldFolioGovernor: IFolioGovernor(0xA4556436cc4547F07DC3E61474Ae5E839fF3D150), + tradingTimelock: 0xE9CdD5f7CE534D77b96aaB2716EF895afCBf51c3, guardians: guardians }) ); @@ -56,6 +58,7 @@ contract GovernanceSpellBase_04_17_2026_Test is GenericGovernanceSpell_04_17_202 proxyAdmin: FolioProxyAdmin(0x2330a29DE3238b07b4a1Db70a244A25b8f21ab91), stakingVaultGovernor: IFolioGovernor(0xbe8DDD7A3ad097DFa84EaBF4D57a879d0c41a148), oldFolioGovernor: IFolioGovernor(0x858c2C08B4984AD4f045F8Bf6D85B916b723ed5b), + tradingTimelock: 0x3A1f432aD1F1a2012b2CdB2945Aa0DE7C5a26abA, guardians: guardians }) ); @@ -73,6 +76,7 @@ contract GovernanceSpellBase_04_17_2026_Test is GenericGovernanceSpell_04_17_202 proxyAdmin: FolioProxyAdmin(0x4472F1f3aD832Bed3FDeF75ace6540c2f3E5a187), stakingVaultGovernor: IFolioGovernor(0xa83E456ebC4bCED953e64F085c8A8C4E2a8a5Fa0), oldFolioGovernor: IFolioGovernor(0x1C58617D79daeE2F51DA6c98186334431D338721), + tradingTimelock: 0x8F288a46681AF76512353a296edD389945faEeDA, guardians: guardians }) ); @@ -90,6 +94,7 @@ contract GovernanceSpellBase_04_17_2026_Test is GenericGovernanceSpell_04_17_202 proxyAdmin: FolioProxyAdmin(0xF3345fca866673BfB58b50F00691219a62Dd6Dc8), stakingVaultGovernor: IFolioGovernor(0xcdd675d848372596E5eCc1B0FE9e88C1CBc609Af), oldFolioGovernor: IFolioGovernor(0x6dFF5971cc446479450e51b5f939A250b11F5Ef5), + tradingTimelock: 0x271b312cCD6739cEa623677f656F5658C5E18F46, guardians: guardians }) ); @@ -107,6 +112,7 @@ contract GovernanceSpellBase_04_17_2026_Test is GenericGovernanceSpell_04_17_202 proxyAdmin: FolioProxyAdmin(0xBe278Be45C265A589BD0bf8cDC6C9e5a04B3397D), stakingVaultGovernor: IFolioGovernor(0xa29D5B7DACf13f417a87F9B5FF7C63d86e48F689), oldFolioGovernor: IFolioGovernor(0x3d14EE40A64F30F3a3515FCA9Cf6787aCA1925b5), + tradingTimelock: 0x0c98Dd13D07e4A7eaED952c7E6141bA5c82A344d, guardians: guardians }) ); diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBsc_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBsc_04_17_2026.t.sol index fb306633..5a208381 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBsc_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellBsc_04_17_2026.t.sol @@ -23,6 +23,7 @@ contract GovernanceSpellBsc_04_17_2026_Test is GenericGovernanceSpell_04_17_2026 proxyAdmin: FolioProxyAdmin(0x91a42b577189A52F211E830b73dc5479D611579A), stakingVaultGovernor: IFolioGovernor(0x3D047aBc5b95BC9989904c557789C1bCf3057d99), oldFolioGovernor: IFolioGovernor(0x6304135c135DA8553d66b0065C8A7c3b0d16c1e8), + tradingTimelock: 0x0FEc839EBA2e311daF16663cC367c77473Bbf728, guardians: guardians }) ); diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol index f1b8ea9e..b9bdcd34 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol @@ -22,6 +22,7 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 proxyAdmin: FolioProxyAdmin(0x0b79E381eD8D6d676C772Dba61cbeEA0B2d28c7D), stakingVaultGovernor: IFolioGovernor(0x020d7c4a87485709D91E78AEeB2B2177ebFbaf41), oldFolioGovernor: IFolioGovernor(0x020d7c4a87485709D91E78AEeB2B2177ebFbaf41), + tradingTimelock: 0x075fA5a4fbf5A32Cd0660e9cdFFF4A6bFA0C6b4A, guardians: guardians }) ); @@ -39,6 +40,7 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 proxyAdmin: FolioProxyAdmin(0xEAa356F6CD6b3fd15B47838d03cF34fa79F7c712), stakingVaultGovernor: IFolioGovernor(0xD2f9c1D649F104e5D6B9453f3817c05911Cf765E), oldFolioGovernor: IFolioGovernor(0xFaD4823Ae478637fD8FfdafB6c912f63c8cd1Dd7), + tradingTimelock: 0x8E530CD0C47d515558229AAE193DD119cc791A40, guardians: guardians }) ); @@ -56,6 +58,7 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 proxyAdmin: FolioProxyAdmin(0xDd885B0F2f97703B94d2790320b30017a17768BF), stakingVaultGovernor: IFolioGovernor(0xD2f9c1D649F104e5D6B9453f3817c05911Cf765E), oldFolioGovernor: IFolioGovernor(0x622c0b5aD82a2A47F330D4a2061a0e3562F583b0), + tradingTimelock: 0x395417220aE7447D19752f38327B96fAF52e1911, guardians: guardians }) ); @@ -73,6 +76,7 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 proxyAdmin: FolioProxyAdmin(0x0e3B2EF9701d5Ef230CB67Ee8851bA3071cf557C), stakingVaultGovernor: IFolioGovernor(0xCaA7E91E752db5d79912665774be7B9Bf5171b9E), oldFolioGovernor: IFolioGovernor(0x404859dE65229b7596Fe58784b6572bB3732DfAc), + tradingTimelock: 0xd2Ee1058112585154DC91C957ce1620563c51396, guardians: guardians }) ); @@ -89,6 +93,7 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 proxyAdmin: FolioProxyAdmin(0x7a6C7064e0069D60A4D90B16545C1051d3487f63), stakingVaultGovernor: IFolioGovernor(0xB3b141c115203932B6127423D33f60C83cAb3F69), oldFolioGovernor: IFolioGovernor(0x8F56a509f39F16D30Da576C10B1a52908cA6ac4d), + tradingTimelock: 0xAdB6fE15B1559045CAA4e29BAED7d9d0b50F647C, guardians: guardians }) ); @@ -106,6 +111,7 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 proxyAdmin: FolioProxyAdmin(0xe24e3DBBEd0db2a9aC2C1d2EA54c6132Dce181b7), stakingVaultGovernor: IFolioGovernor(0xb01C1070E191A3a5535912489Fbff6Cc3f4bb865), oldFolioGovernor: IFolioGovernor(0xDd36672d48caA6c8c45E49e83DB266568446EEfe), + tradingTimelock: 0x910b4D1060004Ed035D6aa6D4768a76aA8D8d8D2, guardians: guardians }) ); From 4695436d9f2c31185f46eb6e1ed43f83c8cb4d02 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Mon, 18 May 2026 20:42:26 +0000 Subject: [PATCH 22/28] AUCTION_APPROVER -> AUCTION_LAUNCHER --- contracts/spells/GovernanceSpell_04_17_2026.sol | 4 ++-- .../GenericGovernanceSpell_04_17_2026.t.sol | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index de717447..45501075 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -15,7 +15,7 @@ import { IRewardTokenRegistry } from "@reserve-protocol/reserve-governor/contrac import { IFolio, Folio } from "@src/Folio.sol"; import { FolioProxyAdmin } from "@folio/FolioProxy.sol"; -import { DEFAULT_ADMIN_ROLE, REBALANCE_MANAGER, BRAND_MANAGER, AUCTION_APPROVER, AUCTION_LAUNCHER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; +import { DEFAULT_ADMIN_ROLE, REBALANCE_MANAGER, BRAND_MANAGER, AUCTION_LAUNCHER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; bytes32 constant VERSION_1_0_0 = keccak256("1.0.0"); bytes32 constant VERSION_4_0_0 = keccak256("4.0.0"); @@ -211,7 +211,7 @@ contract GovernanceSpell_04_17_2026 { TimelockController(payable(tradingTimelock)).hasRole(PROPOSER_ROLE, address(tradingGovernor)), UpgradeError(34) ); - require(!folio.hasRole(AUCTION_APPROVER, tradingTimelock), UpgradeError(29)); + require(!folio.hasRole(AUCTION_LAUNCHER, tradingTimelock), UpgradeError(29)); require(!folio.hasRole(BRAND_MANAGER, tradingTimelock), UpgradeError(30)); // rotate Folio fee recipients from old staking vault to new staking vault diff --git a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol index 3502df66..4e2a3e73 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol @@ -18,7 +18,7 @@ import { IReserveOptimisticGovernorDeployer } from "@reserve-protocol/reserve-go import { IReserveOptimisticGovernor } from "@reserve-protocol/reserve-governor/contracts/interfaces/IReserveOptimisticGovernor.sol"; import { IRoleRegistry as IRewardRoleRegistry } from "@reserve-protocol/reserve-governor/contracts/interfaces/IRoleRegistry.sol"; import { RewardTokenRegistry } from "@reserve-protocol/reserve-governor/contracts/staking/RewardTokenRegistry.sol"; -import { REBALANCE_MANAGER, BRAND_MANAGER, AUCTION_APPROVER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; +import { REBALANCE_MANAGER, BRAND_MANAGER, AUCTION_LAUNCHER, MAX_FEE_RECIPIENTS } from "@utils/Constants.sol"; import { MockRoleRegistry } from "utils/MockRoleRegistry.sol"; interface IVersionedLike { @@ -186,7 +186,7 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { cfg.guardians, deploymentNonce ); - assertFalse(cfg.folio.hasRole(AUCTION_APPROVER, tradingTimelock), "trading timelock still auction approver"); + assertFalse(cfg.folio.hasRole(AUCTION_LAUNCHER, tradingTimelock), "trading timelock still auction launcher"); assertFalse(cfg.folio.hasRole(BRAND_MANAGER, tradingTimelock), "trading timelock still brand manager"); vm.stopPrank(); } From c16d107c47464041ede4f77ee6f931242aa097a0 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Tue, 19 May 2026 21:15:19 +0000 Subject: [PATCH 23/28] spell cast preconditions --- contracts/spells/GovernanceSpell_04_17_2026.sol | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index 45501075..7f04f6dc 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -150,7 +150,7 @@ contract GovernanceSpell_04_17_2026 { /// - Folio is exactly version 4.0.0 or 5.0.0 /// - Self is Folio admin /// - Self is FolioProxyAdmin owner - /// @dev IMPORTANT: Do not call until the `newStakingVault` has been sufficiently populated by new stake + /// @dev IMPORTANT: Must atomically grant ownerships to the spell just before calling `upgradeFolio()` /// @dev New Governance system will use standard 2-3-2 day voting independent of previous voting settings /// @param newStakingVault New staking vault to use for the new governor /// @param oldFolioGovernor Governor currently attached to the Folio being upgraded @@ -245,9 +245,10 @@ contract GovernanceSpell_04_17_2026 { } /// Permanently retire an old StakingVault after every dependent Folio has upgraded - /// @dev IMPORTANT: Current governance must transfer ownership of `oldStakingVault` to this spell contract first - /// @dev Enumeration of dependent Folios is off-chain: current governance is responsible for - /// confirming no Folio governor still uses this StakingVault as its voting token. + /// @dev Requirements: + /// - Spell is owner of `oldStakingVault` + /// @dev Enumeration of dependent Folios is off-chain: current governance is responsible for confirming + /// no Folio governor still uses this StakingVault as its voting token before transferring ownership. function retireOldStakingVault(IOwnableStakingVault oldStakingVault) public { require(oldStakingVault.owner() == address(this), UpgradeError(13)); From b3e4bd5a341c61224afa77a63fee1ef511230618 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Tue, 19 May 2026 23:21:10 +0000 Subject: [PATCH 24/28] upgradeFolio() assumptions --- contracts/spells/GovernanceSpell_04_17_2026.sol | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index 7f04f6dc..aae85163 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -67,6 +67,7 @@ interface IOwnableStakingVault is IStakingVault { * 3. retireOldStakingVault: After every dependent Folio has completed step 2, permanently seal the * old StakingVault (zero unstaking delay, fast reward handout, renounce ownership). * Caller: timelock of old StakingVault + * */ contract GovernanceSpell_04_17_2026 { error UpgradeError(uint256 code); @@ -151,6 +152,12 @@ contract GovernanceSpell_04_17_2026 { /// - Self is Folio admin /// - Self is FolioProxyAdmin owner /// @dev IMPORTANT: Must atomically grant ownerships to the spell just before calling `upgradeFolio()` + /// @dev Assumptions: + /// 1. Folio governors SHOULD vote down new staking vaults with proposals in their governor + /// 2. The Guardian emergency council SHOULD cancel malicious proposals proposed to new StakingVault + /// governors while a `upgradeFolio()` proposal is in-flight. + /// 3. The Guardian emergency council SHOULD cancel malicious proposals proposed to new Folio + /// governors directly after `upgradeFolio()` execution, when new stake is still populating. /// @dev New Governance system will use standard 2-3-2 day voting independent of previous voting settings /// @param newStakingVault New staking vault to use for the new governor /// @param oldFolioGovernor Governor currently attached to the Folio being upgraded From ce7c14ac1068d171740adb356219eba36627112c Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Tue, 19 May 2026 23:22:27 +0000 Subject: [PATCH 25/28] retireOldStakingVault() assumptions --- contracts/spells/GovernanceSpell_04_17_2026.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index aae85163..3e90ad42 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -256,6 +256,7 @@ contract GovernanceSpell_04_17_2026 { /// - Spell is owner of `oldStakingVault` /// @dev Enumeration of dependent Folios is off-chain: current governance is responsible for confirming /// no Folio governor still uses this StakingVault as its voting token before transferring ownership. + /// @dev No new Folios should be added to the old StakingVault after `retireOldStakingVault()` is proposed. function retireOldStakingVault(IOwnableStakingVault oldStakingVault) public { require(oldStakingVault.owner() == address(this), UpgradeError(13)); From 1bc933c3dc3ef5565c58d5b15eae22b3fcc16814 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 20 May 2026 21:45:55 +0000 Subject: [PATCH 26/28] add optional fee intermediary --- .../spells/GovernanceSpell_04_17_2026.sol | 75 ++++++++++++------- .../GenericGovernanceSpell_04_17_2026.t.sol | 64 ++++++++++++++-- .../GovernanceSpellEthereum_04_17_2026.t.sol | 2 + 3 files changed, 107 insertions(+), 34 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index 3e90ad42..80f3baad 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -67,7 +67,7 @@ interface IOwnableStakingVault is IStakingVault { * 3. retireOldStakingVault: After every dependent Folio has completed step 2, permanently seal the * old StakingVault (zero unstaking delay, fast reward handout, renounce ownership). * Caller: timelock of old StakingVault - * + * */ contract GovernanceSpell_04_17_2026 { error UpgradeError(uint256 code); @@ -165,6 +165,7 @@ contract GovernanceSpell_04_17_2026 { /// @param optimisticProposers Use empty set to disable optimistic governance altogether /// @param guardians Must be a subset of the old Folio timelock's CANCELLER_ROLE members /// The shared Guardian contract will be included as a CANCELLER_ROLE member by default + /// @param newFeeRecipient Optional address to intermediate new StakingVault revenue function upgradeFolio( Folio folio, FolioProxyAdmin folioProxyAdmin, @@ -174,6 +175,7 @@ contract GovernanceSpell_04_17_2026 { IReserveOptimisticGovernor.OptimisticGovernanceParams calldata optimisticParams, address[] calldata optimisticProposers, address[] calldata guardians, + address newFeeRecipient, bytes32 deploymentNonce ) public returns (NewDeployment memory newDeployment) { // included for readability, root of security is folioProxyAdmin + folio role checks @@ -183,7 +185,6 @@ contract GovernanceSpell_04_17_2026 { // newStakingVault must not be the old immmutable kind, must be new and upgradeable require(keccak256(bytes(IVersioned(address(newStakingVault)).version())) == VERSION_1_0_0, UpgradeError(3)); - _validateFolioRewardToken(folio, newStakingVault); { IReserveOptimisticGovernorDeployer.BaseDeploymentParams memory baseParams = _baseDeploymentParams( @@ -209,29 +210,39 @@ contract GovernanceSpell_04_17_2026 { require(!folio.hasRole(BRAND_MANAGER, msg.sender), UpgradeError(29)); require(!folio.hasRole(AUCTION_LAUNCHER, msg.sender), UpgradeError(30)); - address tradingTimelock = tradingGovernor.timelock(); - - // validate old trading governance - require(folio.getRoleMemberCount(REBALANCE_MANAGER) == 1, UpgradeError(7)); - require(folio.getRoleMember(REBALANCE_MANAGER, 0) == tradingTimelock, UpgradeError(8)); - require( - TimelockController(payable(tradingTimelock)).hasRole(PROPOSER_ROLE, address(tradingGovernor)), - UpgradeError(34) - ); - require(!folio.hasRole(AUCTION_LAUNCHER, tradingTimelock), UpgradeError(29)); - require(!folio.hasRole(BRAND_MANAGER, tradingTimelock), UpgradeError(30)); + { + address tradingTimelock = tradingGovernor.timelock(); + + // validate old trading governance + require(folio.getRoleMemberCount(REBALANCE_MANAGER) == 1, UpgradeError(7)); + require(folio.getRoleMember(REBALANCE_MANAGER, 0) == tradingTimelock, UpgradeError(8)); + require( + TimelockController(payable(tradingTimelock)).hasRole(PROPOSER_ROLE, address(tradingGovernor)), + UpgradeError(34) + ); + require(!folio.hasRole(AUCTION_LAUNCHER, tradingTimelock), UpgradeError(29)); + require(!folio.hasRole(BRAND_MANAGER, tradingTimelock), UpgradeError(30)); + } // rotate Folio fee recipients from old staking vault to new staking vault address[4] memory invalidFeeRecipients = [ address(oldFolioGovernor), msg.sender, address(tradingGovernor), - tradingTimelock + tradingGovernor.timelock() ]; - _rotateFeeRecipients(folio, oldFolioGovernor.token(), address(newStakingVault), invalidFeeRecipients); + + // direct revenue directly at Staking Vault if no new fee recipient is provided + if (newFeeRecipient == address(0)) { + newFeeRecipient = address(newStakingVault); + + _validateFolioRewardToken(folio, newStakingVault); + } + + _rotateFeeRecipients(folio, oldFolioGovernor.token(), newFeeRecipient, invalidFeeRecipients); // rotate Folio REBALANCE_MANAGER - folio.revokeRole(REBALANCE_MANAGER, tradingTimelock); + folio.revokeRole(REBALANCE_MANAGER, tradingGovernor.timelock()); folio.grantRole(REBALANCE_MANAGER, newDeployment.newTimelock); require(folio.getRoleMemberCount(REBALANCE_MANAGER) == 1, UpgradeError(7)); require(folio.getRoleMember(REBALANCE_MANAGER, 0) == newDeployment.newTimelock, UpgradeError(8)); @@ -314,7 +325,10 @@ contract GovernanceSpell_04_17_2026 { } function _validateFolioRewardToken(Folio folio, IStakingVault newStakingVault) internal view { - require(IRewardTokenRegistry(governorDeployer.rewardTokenRegistry()).isRegistered(address(folio)), UpgradeError(33)); + require( + IRewardTokenRegistry(governorDeployer.rewardTokenRegistry()).isRegistered(address(folio)), + UpgradeError(33) + ); address[] memory rewardTokens = IRewardedStakingVault(address(newStakingVault)).getAllRewardTokens(); for (uint256 i; i < rewardTokens.length; i++) { @@ -335,35 +349,40 @@ contract GovernanceSpell_04_17_2026 { } } - /// Rotate the fee recipient entry for the old StakingVault to the new StakingVault + /// Rotate the fee recipient entry for the old fee recipient to the new fee recipient function _rotateFeeRecipients( Folio folio, - address oldStakingVault, - address newStakingVault, + address oldFeeRecipient, + address newFeeRecipient, address[4] memory invalidFeeRecipients ) internal { + require(newFeeRecipient != invalidFeeRecipients[0], UpgradeError(37)); + require(newFeeRecipient != invalidFeeRecipients[1], UpgradeError(38)); + require(newFeeRecipient != invalidFeeRecipients[2], UpgradeError(39)); + require(newFeeRecipient != invalidFeeRecipients[3], UpgradeError(40)); + IFolio.FeeRecipient[] memory recipients = _feeRecipients(folio); - uint256 oldStakingVaultRecipientCount; - uint256 oldStakingVaultRecipientIndex; + uint256 oldFeeRecipientCount; + uint256 oldFeeRecipientIndex; for (uint256 i; i < recipients.length; i++) { address recipient = recipients[i].recipient; - if (recipient == oldStakingVault) { - oldStakingVaultRecipientCount++; - oldStakingVaultRecipientIndex = i; + if (recipient == oldFeeRecipient) { + oldFeeRecipientCount++; + oldFeeRecipientIndex = i; } - require(recipient != newStakingVault, UpgradeError(19)); + require(recipient != newFeeRecipient, UpgradeError(19)); require(recipient != invalidFeeRecipients[0], UpgradeError(31)); require(recipient != invalidFeeRecipients[1], UpgradeError(32)); require(recipient != invalidFeeRecipients[2], UpgradeError(35)); require(recipient != invalidFeeRecipients[3], UpgradeError(36)); } - require(oldStakingVaultRecipientCount == 1, UpgradeError(20)); + require(oldFeeRecipientCount == 1, UpgradeError(20)); - recipients[oldStakingVaultRecipientIndex].recipient = newStakingVault; + recipients[oldFeeRecipientIndex].recipient = newFeeRecipient; _sortFeeRecipients(recipients); folio.setFeeRecipients(recipients); } diff --git a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol index 4e2a3e73..3349e5a2 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol @@ -144,6 +144,10 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { } } + function test_upgradeFolio_nonZeroNewFeeRecipient_fork() public { + _runUpgradeFolioNonZeroFeeRecipientCase(CONFIGS[0], 0); + } + // === Internal === function _logFolioSymbol(Folio folio) internal view { @@ -158,16 +162,21 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { Config memory cfg, IStakingVault newStakingVault, address optimisticProposer, + address newFeeRecipient, bytes32 deploymentNonce ) internal returns (GovernanceSpell_04_17_2026.NewDeployment memory dep) { - address tradingTimelock = cfg.tradingTimelock; IFolioGovernor tradingGovernor = IFolioGovernor(makeAddr("trading-governor")); assertEq(cfg.proxyAdmin.owner(), cfg.oldFolioGovernor.timelock(), "old folio timelock should own proxy admin"); - assertEq(cfg.folio.getRoleMember(REBALANCE_MANAGER, 0), tradingTimelock, "trading timelock mismatch"); - vm.mockCall(address(tradingGovernor), abi.encodeWithSignature("timelock()"), abi.encode(tradingTimelock)); + assertEq(cfg.folio.getRoleMember(REBALANCE_MANAGER, 0), cfg.tradingTimelock, "trading timelock mismatch"); + assertFalse( + cfg.folio.hasRole(AUCTION_LAUNCHER, cfg.tradingTimelock), + "trading timelock still auction launcher" + ); + assertFalse(cfg.folio.hasRole(BRAND_MANAGER, cfg.tradingTimelock), "trading timelock still brand manager"); + vm.mockCall(address(tradingGovernor), abi.encodeWithSignature("timelock()"), abi.encode(cfg.tradingTimelock)); vm.mockCall( - tradingTimelock, + cfg.tradingTimelock, abi.encodeWithSignature("hasRole(bytes32,address)", PROPOSER_ROLE, address(tradingGovernor)), abi.encode(true) ); @@ -184,10 +193,9 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { _optimisticParams(), _singleAddressArray(optimisticProposer), cfg.guardians, + newFeeRecipient, deploymentNonce ); - assertFalse(cfg.folio.hasRole(AUCTION_LAUNCHER, tradingTimelock), "trading timelock still auction launcher"); - assertFalse(cfg.folio.hasRole(BRAND_MANAGER, tradingTimelock), "trading timelock still brand manager"); vm.stopPrank(); } @@ -231,6 +239,7 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { cfg, IStakingVault(stakingVaultDep.newStakingVault), folioOptimisticProposer, + address(0), keccak256(abi.encode(configIndex, "folio-new")) ); @@ -266,6 +275,49 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { vm.revertToState(snapshot); } + function _runUpgradeFolioNonZeroFeeRecipientCase(Config memory cfg, uint256 configIndex) internal { + uint256 snapshot = vm.snapshotState(); + SuccessorDeployment memory stakingVaultDep = _deploySuccessorStakingVault( + cfg, + _singleAddressArray(address(cfg.folio)), + keccak256(abi.encode(configIndex, "fee-recipient-vault")) + ); + + address oldFolioStakingVault = cfg.oldFolioGovernor.token(); + address newFeeRecipient = makeAddr(string.concat("new-fee-recipient-", vm.toString(configIndex))); + address folioOptimisticProposer = makeAddr(string.concat("new-fee-recipient-opt-", vm.toString(configIndex))); + + uint96 oldVaultFeePortionBefore = _feeRecipientPortion(cfg.folio, oldFolioStakingVault); + uint96 newVaultFeePortionBefore = _feeRecipientPortion(cfg.folio, stakingVaultDep.newStakingVault); + uint96 newFeeRecipientPortionBefore = _feeRecipientPortion(cfg.folio, newFeeRecipient); + assertGt(uint256(oldVaultFeePortionBefore), 0, "old vault should receive folio fees"); + assertEq(newFeeRecipientPortionBefore, 0, "new fee recipient should not already receive fees"); + + GovernanceSpell_04_17_2026.NewDeployment memory folioDep = _upgradeFolio( + cfg, + IStakingVault(stakingVaultDep.newStakingVault), + folioOptimisticProposer, + newFeeRecipient, + keccak256(abi.encode(configIndex, "fee-recipient-folio")) + ); + + assertEq(folioDep.stakingVault, stakingVaultDep.newStakingVault, "folio upgrade should return new vault"); + _assertFolioGovernanceInstalled(cfg, folioDep.newTimelock); + assertEq(_feeRecipientPortion(cfg.folio, oldFolioStakingVault), 0, "old vault should not receive folio fees"); + assertEq( + _feeRecipientPortion(cfg.folio, stakingVaultDep.newStakingVault), + newVaultFeePortionBefore, + "new vault fee share should be unchanged" + ); + assertEq( + _feeRecipientPortion(cfg.folio, newFeeRecipient), + oldVaultFeePortionBefore + newFeeRecipientPortionBefore, + "new fee recipient should receive migrated folio fee share" + ); + + vm.revertToState(snapshot); + } + function _deploySuccessorStakingVault( Config memory cfg, address[] memory folios, diff --git a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol index b9bdcd34..aa1141d6 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GovernanceSpellEthereum_04_17_2026.t.sol @@ -170,6 +170,7 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 firstCfg, IStakingVault(stakingVaultDep.newStakingVault), makeAddr(string.concat(firstLabel, "-folio-opt")), + address(0), keccak256(abi.encode(firstLabel, "folio")) ); assertEq( @@ -202,6 +203,7 @@ contract GovernanceSpellEthereum_04_17_2026_Test is GenericGovernanceSpell_04_17 secondCfg, IStakingVault(stakingVaultDep.newStakingVault), makeAddr(string.concat(secondLabel, "-folio-opt")), + address(0), keccak256(abi.encode(secondLabel, "folio")) ); assertEq( From ec113465e98e91128bbffe5681db30994327134a Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 20 May 2026 22:46:55 +0000 Subject: [PATCH 27/28] reward half life to 1 week --- contracts/spells/GovernanceSpell_04_17_2026.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index 80f3baad..86521a5b 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -114,7 +114,7 @@ contract GovernanceSpell_04_17_2026 { memory newStakingVaultParams = IReserveOptimisticGovernorDeployer.NewStakingVaultParams({ underlying: IERC20Metadata(newUnderlying), rewardTokens: rewardTokens, - rewardHalfLife: 3.5 days, + rewardHalfLife: 1 weeks, unstakingDelay: 1 weeks }); From e20c2945983ff74136c00f8aea36a952dcf44dea Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Thu, 21 May 2026 15:43:44 +0000 Subject: [PATCH 28/28] generic token jar checks --- .../spells/GovernanceSpell_04_17_2026.sol | 19 +++++++- .../GenericGovernanceSpell_04_17_2026.t.sol | 48 ++++++++++++++++++- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/contracts/spells/GovernanceSpell_04_17_2026.sol b/contracts/spells/GovernanceSpell_04_17_2026.sol index 86521a5b..06a3b640 100644 --- a/contracts/spells/GovernanceSpell_04_17_2026.sol +++ b/contracts/spells/GovernanceSpell_04_17_2026.sol @@ -35,6 +35,12 @@ interface IVersioned { interface IStakingVault is IERC5805, IERC4626, IVersioned {} +// trusted-fillers GenericTokenJar-compatible interface +interface IGenericTokenJar { + function token() external view returns (address); + function destination() external view returns (address); +} + interface IRewardedStakingVault is IStakingVault { function getAllRewardTokens() external view returns (address[] memory); } @@ -165,7 +171,7 @@ contract GovernanceSpell_04_17_2026 { /// @param optimisticProposers Use empty set to disable optimistic governance altogether /// @param guardians Must be a subset of the old Folio timelock's CANCELLER_ROLE members /// The shared Guardian contract will be included as a CANCELLER_ROLE member by default - /// @param newFeeRecipient Optional address to intermediate new StakingVault revenue + /// @param newFeeRecipient Optional GenericTokenJar address to intermediate new StakingVault revenue function upgradeFolio( Folio folio, FolioProxyAdmin folioProxyAdmin, @@ -232,11 +238,13 @@ contract GovernanceSpell_04_17_2026 { tradingGovernor.timelock() ]; - // direct revenue directly at Staking Vault if no new fee recipient is provided + // direct revenue to StakingVault if no GenericTokenJar is provided if (newFeeRecipient == address(0)) { newFeeRecipient = address(newStakingVault); _validateFolioRewardToken(folio, newStakingVault); + } else { + _validateGenericTokenJar(newFeeRecipient, newStakingVault); } _rotateFeeRecipients(folio, oldFolioGovernor.token(), newFeeRecipient, invalidFeeRecipients); @@ -338,6 +346,13 @@ contract GovernanceSpell_04_17_2026 { revert UpgradeError(34); } + function _validateGenericTokenJar(address newFeeRecipient, IStakingVault newStakingVault) internal view { + IGenericTokenJar jar = IGenericTokenJar(newFeeRecipient); + + require(jar.token() == newStakingVault.asset(), UpgradeError(41)); + require(jar.destination() == address(newStakingVault), UpgradeError(42)); + } + /// Require `guardians` is a subset of the old timelock's CANCELLER_ROLE members (excl old governor) /// @dev Does NOT confirm `guardians` is the complete set of CANCELLER_ROLE members function _validateGuardians(IFolioGovernor oldGovernor, address[] memory guardians) internal view { diff --git a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol index 3349e5a2..47563a60 100644 --- a/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol +++ b/test/spells/GovernanceSpell_04_17_2026/GenericGovernanceSpell_04_17_2026.t.sol @@ -54,6 +54,10 @@ contract MockGovernanceVersionRegistry { contract GovernanceSpell_04_17_2026_Harness is GovernanceSpell_04_17_2026 { constructor(IReserveOptimisticGovernorDeployer governorDeployer) GovernanceSpell_04_17_2026(governorDeployer) {} + function validateGenericTokenJar(address newFeeRecipient, IStakingVault newStakingVault) external view { + _validateGenericTokenJar(newFeeRecipient, newStakingVault); + } + function baseDeploymentParams( IFolioGovernor oldGovernor, IReserveOptimisticGovernor.OptimisticGovernanceParams calldata optimisticParams, @@ -87,6 +91,24 @@ interface IRewardedStakingVaultLike is IStakingVault { function getAllRewardTokens() external view returns (address[] memory); } +contract MockStakingVaultAsset { + address public immutable asset; + + constructor(address _asset) { + asset = _asset; + } +} + +contract MockGenericTokenJar { + address public immutable destination; + address public immutable token; + + constructor(address _destination, address _token) { + destination = _destination; + token = _token; + } +} + abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { bytes32 internal constant FOLIO_VERSION_4_0_0 = keccak256("4.0.0"); bytes32 internal constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE"); @@ -148,6 +170,25 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { _runUpgradeFolioNonZeroFeeRecipientCase(CONFIGS[0], 0); } + function test_validateGenericTokenJar_fork() public { + GovernanceSpell_04_17_2026_Harness harness = new GovernanceSpell_04_17_2026_Harness( + optimisticGovernanceDeployer + ); + address asset = makeAddr("generic-token-jar-asset"); + IStakingVault newStakingVault = IStakingVault(address(new MockStakingVaultAsset(asset))); + + address validJar = address(new MockGenericTokenJar(address(newStakingVault), asset)); + harness.validateGenericTokenJar(validJar, newStakingVault); + + address wrongTokenJar = address(new MockGenericTokenJar(address(newStakingVault), makeAddr("wrong-token"))); + vm.expectRevert(abi.encodeWithSelector(GovernanceSpell_04_17_2026.UpgradeError.selector, 41)); + harness.validateGenericTokenJar(wrongTokenJar, newStakingVault); + + address wrongDestinationJar = address(new MockGenericTokenJar(makeAddr("wrong-destination"), asset)); + vm.expectRevert(abi.encodeWithSelector(GovernanceSpell_04_17_2026.UpgradeError.selector, 42)); + harness.validateGenericTokenJar(wrongDestinationJar, newStakingVault); + } + // === Internal === function _logFolioSymbol(Folio folio) internal view { @@ -284,7 +325,12 @@ abstract contract GenericGovernanceSpell_04_17_2026_Test is BaseTest { ); address oldFolioStakingVault = cfg.oldFolioGovernor.token(); - address newFeeRecipient = makeAddr(string.concat("new-fee-recipient-", vm.toString(configIndex))); + address newFeeRecipient = address( + new MockGenericTokenJar( + stakingVaultDep.newStakingVault, + IStakingVault(stakingVaultDep.newStakingVault).asset() + ) + ); address folioOptimisticProposer = makeAddr(string.concat("new-fee-recipient-opt-", vm.toString(configIndex))); uint96 oldVaultFeePortionBefore = _feeRecipientPortion(cfg.folio, oldFolioStakingVault);