diff --git a/script/operations/e2e/ClaimRewards.s.sol b/script/operations/e2e/ClaimRewards.s.sol new file mode 100644 index 0000000000..728a21e09f --- /dev/null +++ b/script/operations/e2e/ClaimRewards.s.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "forge-std/Script.sol"; + +import "src/contracts/core/RewardsCoordinator.sol"; + +/// @notice Claim RewardsCoordinator rewards using an externally-provided claim JSON. +/// +/// Intended usage: +/// - Your coworker/Sidecar produces a claim JSON (already in the correct shape/encoding). +/// - You run this script against preprod-hoodi (or any Zeus env) to call `processClaim`. +/// +/// Example: +/// forge script script/operations/e2e/ClaimRewards.s.sol \ +/// --rpc-url "$RPC_HOODI" --private-key "$PRIVATE_KEY" --broadcast \ +/// --sig "run(string,address,address)" \ +/// -- "path/to/claim.json" 0xEarner 0xRecipient +contract ClaimRewards is Script { + /// @notice Claim using the RewardsCoordinator address from env var `REWARDS_COORDINATOR`. + function run( + string memory claimFile, + address earner, + address recipient + ) public { + address rc = vm.envAddress("REWARDS_COORDINATOR"); + vm.startBroadcast(); + _claim(RewardsCoordinator(rc), claimFile, earner, recipient); + vm.stopBroadcast(); + } + + /// @notice Claim using an explicitly provided RewardsCoordinator address. + function runWithRewardsCoordinator( + address rewardsCoordinator, + string memory claimFile, + address earner, + address recipient + ) public { + vm.startBroadcast(); + _claim(RewardsCoordinator(rewardsCoordinator), claimFile, earner, recipient); + vm.stopBroadcast(); + } + + function _claim( + RewardsCoordinator rc, + string memory claimFile, + address earner, + address recipient + ) internal { + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _loadClaimFromFile(claimFile); + require(claim.earnerLeaf.earner == earner, "claim earner mismatch"); + rc.processClaim(claim, recipient); + } + + function _loadClaimFromFile( + string memory claimPath + ) internal view returns (IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim) { + string memory fullPath = _resolvePath(claimPath); + string memory json = vm.readFile(fullPath); + + // Accept either: + // - direct object: { rootIndex, earnerIndex, earnerTreeProof, earnerLeaf, tokenIndices, tokenTreeProofs, tokenLeaves } + // - wrapped: { proof: { ... } } (Sidecar-style) + if (_hasJsonPath(json, ".proof")) { + claim.rootIndex = uint32(abi.decode(vm.parseJson(json, ".proof.rootIndex"), (uint256))); + claim.earnerIndex = uint32(abi.decode(vm.parseJson(json, ".proof.earnerIndex"), (uint256))); + claim.earnerTreeProof = abi.decode(vm.parseJson(json, ".proof.earnerTreeProof"), (bytes)); + claim.earnerLeaf = + abi.decode(vm.parseJson(json, ".proof.earnerLeaf"), (IRewardsCoordinatorTypes.EarnerTreeMerkleLeaf)); + + // If the proof contains scalar token fields, wrap them into single-element arrays. + claim.tokenIndices = new uint32[](1); + claim.tokenIndices[0] = uint32(abi.decode(vm.parseJson(json, ".proof.tokenIndices"), (uint256))); + claim.tokenTreeProofs = new bytes[](1); + claim.tokenTreeProofs[0] = abi.decode(vm.parseJson(json, ".proof.tokenTreeProofs"), (bytes)); + claim.tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + claim.tokenLeaves[0] = + abi.decode(vm.parseJson(json, ".proof.tokenLeaves"), (IRewardsCoordinatorTypes.TokenTreeMerkleLeaf)); + return claim; + } + + claim.rootIndex = uint32(abi.decode(vm.parseJson(json, ".rootIndex"), (uint256))); + claim.earnerIndex = uint32(abi.decode(vm.parseJson(json, ".earnerIndex"), (uint256))); + claim.earnerTreeProof = abi.decode(vm.parseJson(json, ".earnerTreeProof"), (bytes)); + claim.earnerLeaf = + abi.decode(vm.parseJson(json, ".earnerLeaf"), (IRewardsCoordinatorTypes.EarnerTreeMerkleLeaf)); + claim.tokenIndices = abi.decode(vm.parseJson(json, ".tokenIndices"), (uint32[])); + claim.tokenTreeProofs = abi.decode(vm.parseJson(json, ".tokenTreeProofs"), (bytes[])); + claim.tokenLeaves = + abi.decode(vm.parseJson(json, ".tokenLeaves"), (IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[])); + } + + function _hasJsonPath( + string memory json, + string memory path + ) internal pure returns (bool) { + try vm.parseJson(json, path) returns (bytes memory raw) { + return raw.length != 0; + } catch { + return false; + } + } + + function _resolvePath( + string memory path + ) internal view returns (string memory) { + bytes memory b = bytes(path); + if (b.length > 0 && b[0] == bytes1("/")) return path; + return string.concat(vm.projectRoot(), "/", path); + } +} + diff --git a/script/operations/e2e/DurationVaultHoodiE2E.s.sol b/script/operations/e2e/DurationVaultHoodiE2E.s.sol new file mode 100644 index 0000000000..054661b3cf --- /dev/null +++ b/script/operations/e2e/DurationVaultHoodiE2E.s.sol @@ -0,0 +1,631 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "forge-std/Script.sol"; +import "forge-std/console2.sol"; + +import "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetFixedSupply.sol"; + +import "src/contracts/core/AllocationManager.sol"; +import "src/contracts/core/DelegationManager.sol"; +import "src/contracts/core/RewardsCoordinator.sol"; +import "src/contracts/core/StrategyManager.sol"; +import "src/contracts/strategies/StrategyFactory.sol"; +import "src/contracts/interfaces/IDurationVaultStrategy.sol"; +import "src/contracts/libraries/OperatorSetLib.sol"; + +import {MockAVSRegistrar} from "./MockAVSRegistrar.sol"; + +/// @notice End-to-end smoke test for DurationVault on Hoodi (or any live env). +/// +/// What this script does on-chain (broadcast): +/// - Creates an AVS (your EOA address) in AllocationManager with a minimal registrar +/// - Creates a redistributing operator set (insurance recipient = you by default) +/// - Deploys an ERC20 test asset + DurationVaultStrategy via StrategyFactory +/// - Adds the vault strategy to the operator set +/// - Delegates + deposits into the vault +/// - Locks the vault, then slashes it and clears redistributable shares to insurance recipient +/// - Creates an AVS rewards submission (sidecar should pick this up and later submit a root) +/// +/// This is designed to validate: +/// - vault deploy works against live core protocol +/// - delegation requirement works +/// - lock/allocations/slashability works +/// - slashing routes value to the operator set redistribution recipient +/// - rewards submission plumbing works end-to-end with the sidecar (root + claim is follow-up) +/// +/// Address wiring: +/// - You can either pass the 5 core contract addresses as args (recommended), or set env vars: +/// - ALLOCATION_MANAGER +/// - DELEGATION_MANAGER +/// - STRATEGY_MANAGER +/// - STRATEGY_FACTORY +/// - REWARDS_COORDINATOR +contract DurationVaultHoodiE2E is Script { + using OperatorSetLib for OperatorSet; + + struct PersistedState { + address avs; + uint32 operatorSetId; + address insuranceRecipient; + address registrar; + address asset; + address vault; + uint32 allocationEffectBlock; + } + + struct E2EContext { + // core + AllocationManager allocationManager; + DelegationManager delegationManager; + StrategyManager strategyManager; + StrategyFactory strategyFactory; + RewardsCoordinator rewardsCoordinator; + // identities + address eoa; + address avs; + address insuranceRecipient; + uint32 operatorSetId; + OperatorSet opSet; + // deployed + MockAVSRegistrar registrar; + ERC20PresetFixedSupply asset; + IDurationVaultStrategy vault; + // config + uint256 maxPerDeposit; + uint256 stakeCap; + uint32 durationSeconds; + uint256 depositAmount; + uint256 slashWad; + // ephemeral + IStrategy[] strategies; + } + + /// Optional environment overrides: + /// - E2E_OPERATOR_SET_ID (uint) + /// - E2E_VAULT_DURATION_SECONDS (uint) + /// - E2E_DEPOSIT_AMOUNT (uint) + /// - E2E_STAKE_CAP (uint) + /// - E2E_MAX_PER_DEPOSIT (uint) + /// - E2E_SLASH_WAD (uint) // 1e18 = 100% + /// - E2E_INSURANCE_RECIPIENT (address) + /// - E2E_REWARD_AMOUNT (uint) + /// - E2E_REWARDS_START_TIMESTAMP (uint) // defaults to previous CALCULATION_INTERVAL_SECONDS boundary + /// - E2E_REWARDS_DURATION_SECONDS (uint) // defaults to CALCULATION_INTERVAL_SECONDS + /// - E2E_REWARDS_MODE (string): "avs" (default) | "operatorDirectedOperatorSet" + /// - E2E_REQUIRE_NONZERO_REDISTRIBUTION (bool) // default false; if true, require slashing redistributes > 0 + /// - E2E_REQUIRE_NOW_IN_REWARDS_WINDOW (bool) // default true; if true, require now in [start, start+duration) + /// - E2E_PHASE (string): "all" (default) | "setup" | "slash" | "rewards" + /// - E2E_STATE_FILE (string): file to write/read persisted state (default "script/operations/e2e/e2e-state.json") + + /// @notice Run using addresses from environment variables. + /// @dev Use `--sig "run()"` (default) and set ALLOCATION_MANAGER/DELEGATION_MANAGER/... + function run() public { + ( + AllocationManager allocationManager, + DelegationManager delegationManager, + StrategyManager strategyManager, + StrategyFactory strategyFactory, + RewardsCoordinator rewardsCoordinator + ) = _loadCoreFromEnv(); + _runWithCore(allocationManager, delegationManager, strategyManager, strategyFactory, rewardsCoordinator); + } + + /// @notice Run using addresses passed as arguments. + /// @dev Use: + /// forge script ... --sig "runWithCore(address,address,address,address,address)" -- + function runWithCore( + address allocationManager, + address delegationManager, + address strategyManager, + address strategyFactory, + address rewardsCoordinator + ) public { + _runWithCore( + AllocationManager(allocationManager), + DelegationManager(delegationManager), + StrategyManager(strategyManager), + StrategyFactory(strategyFactory), + RewardsCoordinator(rewardsCoordinator) + ); + } + + function _runWithCore( + AllocationManager allocationManager, + DelegationManager delegationManager, + StrategyManager strategyManager, + StrategyFactory strategyFactory, + RewardsCoordinator rewardsCoordinator + ) internal { + // If you want fork-only simulation without passing a private key, you can set E2E_SENDER=
. + address sender = vm.envOr("E2E_SENDER", address(0)); + if (sender != address(0)) vm.startBroadcast(sender); + else vm.startBroadcast(); + + string memory phase = vm.envOr("E2E_PHASE", string("all")); + bytes32 phaseHash = keccak256(bytes(phase)); + + if (phaseHash == keccak256("setup")) { + E2EContext memory ctx = _initContext( + allocationManager, delegationManager, strategyManager, strategyFactory, rewardsCoordinator + ); + _setupAVS(ctx); + _createOperatorSet(ctx); + _deployVault(ctx); + _depositAndLock(ctx); + _persistState(ctx); + } else if (phaseHash == keccak256("slash")) { + E2EContext memory ctx = _initContext( + allocationManager, delegationManager, strategyManager, strategyFactory, rewardsCoordinator + ); + PersistedState memory st = _loadState(); + require(st.avs == ctx.avs, "state avs != broadcaster"); + require(st.operatorSetId == ctx.operatorSetId, "state operatorSetId mismatch"); + + ctx.insuranceRecipient = st.insuranceRecipient; + ctx.opSet = OperatorSet({avs: ctx.avs, id: st.operatorSetId}); + ctx.asset = ERC20PresetFixedSupply(st.asset); + ctx.vault = IDurationVaultStrategy(st.vault); + ctx.strategies = new IStrategy[](1); + ctx.strategies[0] = IStrategy(st.vault); + + // Ensure the allocation has become effective, otherwise slashing can legitimately produce 0. + IAllocationManagerTypes.Allocation memory alloc = + ctx.allocationManager.getAllocation(address(ctx.vault), ctx.opSet, IStrategy(address(ctx.vault))); + if (alloc.currentMagnitude == 0 && block.number < alloc.effectBlock) { + console2.log("Allocation not effective yet. Wait until block >=", uint256(alloc.effectBlock)); + console2.log("Current block:", block.number); + revert("allocation not effective yet"); + } + + _slashAndRedistribute(ctx); + _createRewardsSubmission(ctx); + } else if (phaseHash == keccak256("rewards")) { + // Rewards-only: reuse the previously deployed vault and just create a new rewards submission in a window + // that includes "now" (the script enforces this). + E2EContext memory ctx = _initContext( + allocationManager, delegationManager, strategyManager, strategyFactory, rewardsCoordinator + ); + PersistedState memory st = _loadState(); + require(st.avs == ctx.avs, "state avs != broadcaster"); + require(st.operatorSetId == ctx.operatorSetId, "state operatorSetId mismatch"); + + ctx.insuranceRecipient = st.insuranceRecipient; + ctx.opSet = OperatorSet({avs: ctx.avs, id: st.operatorSetId}); + ctx.asset = ERC20PresetFixedSupply(st.asset); + ctx.vault = IDurationVaultStrategy(st.vault); + ctx.strategies = new IStrategy[](1); + ctx.strategies[0] = IStrategy(st.vault); + + _createRewardsSubmission(ctx); + } else { + // "all" (default): run everything in one shot; slashing may produce 0 if allocation isn't effective yet. + E2EContext memory ctx = _initContext( + allocationManager, delegationManager, strategyManager, strategyFactory, rewardsCoordinator + ); + _setupAVS(ctx); + _createOperatorSet(ctx); + _deployVault(ctx); + _depositAndLock(ctx); + _slashAndRedistribute(ctx); + _createRewardsSubmission(ctx); + } + vm.stopBroadcast(); + } + + function _loadCoreFromEnv() + internal + view + returns (AllocationManager, DelegationManager, StrategyManager, StrategyFactory, RewardsCoordinator) + { + address allocationManager = vm.envAddress("ALLOCATION_MANAGER"); + address delegationManager = vm.envAddress("DELEGATION_MANAGER"); + address strategyManager = vm.envAddress("STRATEGY_MANAGER"); + address strategyFactory = vm.envAddress("STRATEGY_FACTORY"); + address rewardsCoordinator = vm.envAddress("REWARDS_COORDINATOR"); + return ( + AllocationManager(allocationManager), + DelegationManager(delegationManager), + StrategyManager(strategyManager), + StrategyFactory(strategyFactory), + RewardsCoordinator(rewardsCoordinator) + ); + } + + function _initContext( + AllocationManager allocationManager, + DelegationManager delegationManager, + StrategyManager strategyManager, + StrategyFactory strategyFactory, + RewardsCoordinator rewardsCoordinator + ) internal view returns (E2EContext memory ctx) { + ctx.allocationManager = allocationManager; + ctx.delegationManager = delegationManager; + ctx.strategyManager = strategyManager; + ctx.strategyFactory = strategyFactory; + ctx.rewardsCoordinator = rewardsCoordinator; + + // In forge scripts, `msg.sender` is the script contract; use tx.origin as the broadcaster address. + ctx.eoa = tx.origin; + ctx.avs = ctx.eoa; + + ctx.operatorSetId = uint32(vm.envOr("E2E_OPERATOR_SET_ID", uint256(1))); + ctx.insuranceRecipient = vm.envOr("E2E_INSURANCE_RECIPIENT", address(ctx.eoa)); + + ctx.maxPerDeposit = vm.envOr("E2E_MAX_PER_DEPOSIT", uint256(200 ether)); + ctx.stakeCap = vm.envOr("E2E_STAKE_CAP", uint256(1000 ether)); + ctx.durationSeconds = uint32(vm.envOr("E2E_VAULT_DURATION_SECONDS", uint256(120))); // 2 minutes default + ctx.depositAmount = vm.envOr("E2E_DEPOSIT_AMOUNT", uint256(100 ether)); + ctx.slashWad = vm.envOr("E2E_SLASH_WAD", uint256(0.25e18)); + + ctx.opSet = OperatorSet({avs: ctx.avs, id: ctx.operatorSetId}); + } + + function _setupAVS( + E2EContext memory ctx + ) internal returns (E2EContext memory) { + ctx.allocationManager.updateAVSMetadataURI(ctx.avs, "ipfs://e2e-duration-vault/avs-metadata"); + ctx.registrar = new MockAVSRegistrar(ctx.avs); + ctx.allocationManager.setAVSRegistrar(ctx.avs, ctx.registrar); + require(address(ctx.allocationManager.getAVSRegistrar(ctx.avs)) == address(ctx.registrar), "registrar not set"); + return ctx; + } + + function _createOperatorSet( + E2EContext memory ctx + ) internal returns (E2EContext memory) { + IAllocationManagerTypes.CreateSetParamsV2[] memory sets = new IAllocationManagerTypes.CreateSetParamsV2[](1); + sets[0] = IAllocationManagerTypes.CreateSetParamsV2({ + operatorSetId: ctx.operatorSetId, + strategies: new IStrategy[](0), + slasher: ctx.eoa + }); + address[] memory recipients = new address[](1); + recipients[0] = ctx.insuranceRecipient; + ctx.allocationManager.createRedistributingOperatorSets(ctx.avs, sets, recipients); + require(ctx.allocationManager.isOperatorSet(ctx.opSet), "operator set not created"); + require(ctx.allocationManager.isRedistributingOperatorSet(ctx.opSet), "operator set not redistributing"); + require( + ctx.allocationManager.getRedistributionRecipient(ctx.opSet) == ctx.insuranceRecipient, + "bad redistribution recipient" + ); + require(ctx.allocationManager.getSlasher(ctx.opSet) == ctx.eoa, "bad slasher"); + return ctx; + } + + function _deployVault( + E2EContext memory ctx + ) internal returns (E2EContext memory) { + ctx.asset = new ERC20PresetFixedSupply("Hoodi Duration Vault Asset", "HDVA", 1_000_000 ether, ctx.eoa); + + IDurationVaultStrategyTypes.VaultConfig memory cfg; + cfg.underlyingToken = ctx.asset; + cfg.vaultAdmin = ctx.eoa; + cfg.arbitrator = ctx.eoa; + cfg.duration = ctx.durationSeconds; + cfg.maxPerDeposit = ctx.maxPerDeposit; + cfg.stakeCap = ctx.stakeCap; + cfg.metadataURI = "ipfs://e2e-duration-vault/metadata"; + cfg.operatorSet = ctx.opSet; + cfg.operatorSetRegistrationData = ""; + cfg.delegationApprover = address(0); + cfg.operatorMetadataURI = "ipfs://e2e-duration-vault/operator-metadata"; + + ctx.vault = ctx.strategyFactory.deployDurationVaultStrategy(cfg); + + ctx.strategies = new IStrategy[](1); + ctx.strategies[0] = IStrategy(address(ctx.vault)); + ctx.allocationManager.addStrategiesToOperatorSet(ctx.avs, ctx.operatorSetId, ctx.strategies); + require( + _operatorSetContainsStrategy(ctx.allocationManager, ctx.opSet, IStrategy(address(ctx.vault))), + "vault not in operator set" + ); + return ctx; + } + + function _depositAndLock( + E2EContext memory ctx + ) internal returns (E2EContext memory) { + require(ctx.depositAmount <= ctx.maxPerDeposit, "deposit > maxPerDeposit"); + + IDelegationManager.SignatureWithExpiry memory emptySig; + ctx.delegationManager.delegateTo(address(ctx.vault), emptySig, bytes32(0)); + + ctx.asset.approve(address(ctx.strategyManager), ctx.depositAmount); + uint256 depositShares = + ctx.strategyManager.depositIntoStrategy(IStrategy(address(ctx.vault)), ctx.asset, ctx.depositAmount); + require(depositShares != 0, "deposit shares = 0"); + require( + _getDepositedShares(ctx.strategyManager, ctx.eoa, IStrategy(address(ctx.vault))) == depositShares, + "unexpected shares" + ); + + ctx.vault.lock(); + require(ctx.vault.allocationsActive(), "allocations not active after lock"); + require(ctx.allocationManager.isOperatorSlashable(address(ctx.vault), ctx.opSet), "vault not slashable"); + return ctx; + } + + function _slashAndRedistribute( + E2EContext memory ctx + ) internal returns (E2EContext memory) { + require(ctx.slashWad > 0 && ctx.slashWad <= 1e18, "invalid slash wad"); + + uint256 slashCountBefore = ctx.allocationManager.getSlashCount(ctx.opSet); + + IAllocationManagerTypes.SlashingParams memory slashParams; + slashParams.operator = address(ctx.vault); + slashParams.operatorSetId = ctx.operatorSetId; + slashParams.strategies = ctx.strategies; + slashParams.wadsToSlash = new uint256[](1); + slashParams.wadsToSlash[0] = ctx.slashWad; + slashParams.description = "e2e-duration-vault-slash"; + + (uint256 slashId,) = ctx.allocationManager.slashOperator(ctx.avs, slashParams); + require(ctx.allocationManager.getSlashCount(ctx.opSet) == slashCountBefore + 1, "slash count not incremented"); + require(slashId == slashCountBefore + 1, "unexpected slashId"); + + uint256 sharesToRedistribute = + ctx.strategyManager.getBurnOrRedistributableShares(ctx.opSet, slashId, IStrategy(address(ctx.vault))); + + uint256 insuranceBefore = ctx.asset.balanceOf(ctx.insuranceRecipient); + uint256 redistributed = ctx.strategyManager + .clearBurnOrRedistributableSharesByStrategy(ctx.opSet, slashId, IStrategy(address(ctx.vault))); + uint256 insuranceAfter = ctx.asset.balanceOf(ctx.insuranceRecipient); + + require(insuranceAfter >= insuranceBefore, "insurance balance decreased"); + require(insuranceAfter - insuranceBefore == redistributed, "redistributed != insurance delta"); + + // In some live-network conditions (notably allocation delay / effectBlock timing), the slash can legitimately + // result in zero burn/redistributable shares. That still validates that the slashing plumbing works. + // + // For fork confidence, you can require a nonzero redistribution: + // export E2E_REQUIRE_NONZERO_REDISTRIBUTION=true + bool requireNonzero = vm.envOr("E2E_REQUIRE_NONZERO_REDISTRIBUTION", false); + if (requireNonzero) { + require(sharesToRedistribute != 0, "no shares to redistribute"); + require(redistributed != 0, "no tokens redistributed"); + } + return ctx; + } + + function _createRewardsSubmission( + E2EContext memory ctx + ) internal { + string memory mode = vm.envOr("E2E_REWARDS_MODE", string("avs")); + bytes32 modeHash = keccak256(bytes(mode)); + + ERC20PresetFixedSupply rewardToken = + new ERC20PresetFixedSupply("Hoodi Rewards Token", "HDRW", 1_000_000 ether, ctx.eoa); + uint256 rewardAmount = vm.envOr("E2E_REWARD_AMOUNT", uint256(10 ether)); + + IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory sams = + new IRewardsCoordinatorTypes.StrategyAndMultiplier[](1); + sams[0] = + IRewardsCoordinatorTypes.StrategyAndMultiplier({strategy: IStrategy(address(ctx.vault)), multiplier: 1e18}); + + uint32 interval = ctx.rewardsCoordinator.CALCULATION_INTERVAL_SECONDS(); + + // RewardsCoordinator constraints (enforced again on-chain): + // - startTimestamp % CALCULATION_INTERVAL_SECONDS == 0 + // - duration % CALCULATION_INTERVAL_SECONDS == 0 + uint32 defaultDuration = interval; + uint32 rewardsDuration = uint32(vm.envOr("E2E_REWARDS_DURATION_SECONDS", uint256(defaultDuration))); + require(rewardsDuration % interval == 0, "rewardsDuration must be multiple of CALCULATION_INTERVAL_SECONDS"); + + // Mode A: Standard AVS rewards submission (works for protocol; may not be indexed by Sidecar if it only + // understands legacy AVSDirectory registrations). + if (modeHash == keccak256("avs")) { + // Defaults are aligned for live networks like preprod-hoodi (interval = 1 day). + uint32 defaultStartTs = _defaultAlignedRewardsStartTimestamp(interval); + uint32 startTs = uint32(vm.envOr("E2E_REWARDS_START_TIMESTAMP", uint256(defaultStartTs))); + + require(startTs % interval == 0, "startTs must be multiple of CALCULATION_INTERVAL_SECONDS"); + + // Ensure the submission window overlaps the time we actually have stake (this script deposits immediately + // before creating the rewards submission in the "all" / "slash" flows). If you set a window entirely in the + // past, Sidecar will correctly omit this earner and you'll see "earner index not found" when generating a + // claim. + // + // For live-network runs, it can be convenient to schedule a future-aligned submission window (e.g. next UTC + // day) without waiting for the window to start. Set E2E_REQUIRE_NOW_IN_REWARDS_WINDOW=false to skip this. + bool requireNowInWindow = vm.envOr("E2E_REQUIRE_NOW_IN_REWARDS_WINDOW", true); + if (requireNowInWindow) { + require(block.timestamp >= startTs, "now before rewards start"); + require(block.timestamp < uint256(startTs) + uint256(rewardsDuration), "now after rewards end"); + } + + IRewardsCoordinatorTypes.RewardsSubmission memory sub = IRewardsCoordinatorTypes.RewardsSubmission({ + strategiesAndMultipliers: sams, + token: rewardToken, + amount: rewardAmount, + startTimestamp: startTs, + duration: rewardsDuration + }); + _submitAndVerifyRewards(ctx, rewardToken, rewardAmount, sub); + return; + } + + // Mode B: Operator-directed rewards submission for the operator set (strictly retroactive). + // Sidecar can choose to index this path differently. This will revert unless the window is strictly in the past. + if (modeHash == keccak256("operatorDirectedOperatorSet")) { + uint256 nowTs = block.timestamp; + uint256 endTs = (interval == 0) ? nowTs : (nowTs / interval) * interval; + // Must be strictly retroactive: end < now. + if (endTs >= nowTs) endTs = endTs - interval; + + uint32 defaultStartTs = uint32(endTs - uint256(rewardsDuration)); + uint32 startTs = uint32(vm.envOr("E2E_REWARDS_START_TIMESTAMP", uint256(defaultStartTs))); + require(startTs % interval == 0, "startTs must be multiple of CALCULATION_INTERVAL_SECONDS"); + require(uint256(startTs) + uint256(rewardsDuration) < block.timestamp, "operator-directed must be retro"); + + _submitAndVerifyOperatorDirectedOperatorSetRewards( + ctx, rewardToken, rewardAmount, sams, startTs, rewardsDuration + ); + return; + } + + revert("unknown E2E_REWARDS_MODE"); + } + + function _submitAndVerifyOperatorDirectedOperatorSetRewards( + E2EContext memory ctx, + ERC20PresetFixedSupply rewardToken, + uint256 rewardAmount, + IRewardsCoordinatorTypes.StrategyAndMultiplier[] memory sams, + uint32 startTs, + uint32 rewardsDuration + ) internal { + IRewardsCoordinatorTypes.OperatorReward[] memory ors = new IRewardsCoordinatorTypes.OperatorReward[](1); + ors[0] = IRewardsCoordinatorTypes.OperatorReward({operator: address(ctx.vault), amount: rewardAmount}); + + IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission memory od = + IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission({ + strategiesAndMultipliers: sams, + token: rewardToken, + operatorRewards: ors, + startTimestamp: startTs, + duration: rewardsDuration, + description: "e2e-duration-vault-operator-directed" + }); + + IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission[] memory ods = + new IRewardsCoordinatorTypes.OperatorDirectedRewardsSubmission[](1); + ods[0] = od; + + uint256 nonceBefore = ctx.rewardsCoordinator.submissionNonce(ctx.avs); + uint256 rcBalBefore = rewardToken.balanceOf(address(ctx.rewardsCoordinator)); + bytes32 expectedHash = keccak256(abi.encode(ctx.avs, nonceBefore, od)); + + rewardToken.approve(address(ctx.rewardsCoordinator), rewardAmount); + ctx.rewardsCoordinator.createOperatorDirectedOperatorSetRewardsSubmission(ctx.opSet, ods); + + require(ctx.rewardsCoordinator.submissionNonce(ctx.avs) == nonceBefore + 1, "submission nonce not incremented"); + require( + ctx.rewardsCoordinator.isOperatorDirectedOperatorSetRewardsSubmissionHash(ctx.avs, expectedHash), + "operator-directed hash not recorded" + ); + require( + rewardToken.balanceOf(address(ctx.rewardsCoordinator)) == rcBalBefore + rewardAmount, + "reward token not transferred" + ); + } + + function _submitAndVerifyRewards( + E2EContext memory ctx, + ERC20PresetFixedSupply rewardToken, + uint256 rewardAmount, + IRewardsCoordinatorTypes.RewardsSubmission memory sub + ) internal { + IRewardsCoordinatorTypes.RewardsSubmission[] memory subs = new IRewardsCoordinatorTypes.RewardsSubmission[](1); + subs[0] = sub; + + uint256 nonceBefore = ctx.rewardsCoordinator.submissionNonce(ctx.avs); + uint256 rcBalBefore = rewardToken.balanceOf(address(ctx.rewardsCoordinator)); + bytes32 expectedHash = keccak256(abi.encode(ctx.avs, nonceBefore, sub)); + + rewardToken.approve(address(ctx.rewardsCoordinator), rewardAmount); + ctx.rewardsCoordinator.createAVSRewardsSubmission(subs); + + require(ctx.rewardsCoordinator.submissionNonce(ctx.avs) == nonceBefore + 1, "submission nonce not incremented"); + require( + ctx.rewardsCoordinator.isAVSRewardsSubmissionHash(ctx.avs, expectedHash), "submission hash not recorded" + ); + require( + rewardToken.balanceOf(address(ctx.rewardsCoordinator)) == rcBalBefore + rewardAmount, + "reward token not transferred" + ); + } + + function _defaultAlignedRewardsStartTimestamp( + uint32 interval + ) internal view returns (uint32) { + // Use the current interval boundary so the window includes "now". + // Example: interval=86400, now=10:15 UTC => start at today 00:00 UTC. + uint256 nowTs = block.timestamp; + if (interval == 0) return uint32(nowTs); + uint256 floored = (nowTs / interval) * interval; + return uint32(floored); + } + + function _operatorSetContainsStrategy( + AllocationManager allocationManager, + OperatorSet memory operatorSet, + IStrategy strategy + ) internal view returns (bool) { + IStrategy[] memory strategies = allocationManager.getStrategiesInOperatorSet(operatorSet); + for (uint256 i = 0; i < strategies.length; i++) { + if (address(strategies[i]) == address(strategy)) return true; + } + return false; + } + + function _getDepositedShares( + StrategyManager strategyManager, + address staker, + IStrategy strategy + ) internal view returns (uint256) { + (IStrategy[] memory strategies, uint256[] memory shares) = strategyManager.getDeposits(staker); + for (uint256 i = 0; i < strategies.length; i++) { + if (address(strategies[i]) == address(strategy)) return shares[i]; + } + return 0; + } + + function _stateFile() internal view returns (string memory) { + return vm.envOr("E2E_STATE_FILE", string("script/operations/e2e/e2e-state.json")); + } + + function _persistState( + E2EContext memory ctx + ) internal { + // Capture the allocation effect block so the user knows how long to wait before running the slash phase. + IAllocationManagerTypes.Allocation memory alloc = + ctx.allocationManager.getAllocation(address(ctx.vault), ctx.opSet, IStrategy(address(ctx.vault))); + + string memory json = string.concat( + "{", + "\"avs\":\"", + vm.toString(ctx.avs), + "\",", + "\"operatorSetId\":", + vm.toString(uint256(ctx.operatorSetId)), + ",", + "\"insuranceRecipient\":\"", + vm.toString(ctx.insuranceRecipient), + "\",", + "\"registrar\":\"", + vm.toString(address(ctx.registrar)), + "\",", + "\"asset\":\"", + vm.toString(address(ctx.asset)), + "\",", + "\"vault\":\"", + vm.toString(address(ctx.vault)), + "\",", + "\"allocationEffectBlock\":", + vm.toString(uint256(alloc.effectBlock)), + "}" + ); + + string memory fullPath = string.concat(vm.projectRoot(), "/", _stateFile()); + vm.writeFile(fullPath, json); + console2.log("Wrote E2E state to", fullPath); + console2.log("Allocation effectBlock:", uint256(alloc.effectBlock)); + console2.log("Current block:", block.number); + } + + function _loadState() internal view returns (PersistedState memory st) { + string memory fullPath = string.concat(vm.projectRoot(), "/", _stateFile()); + string memory json = vm.readFile(fullPath); + + st.avs = abi.decode(vm.parseJson(json, ".avs"), (address)); + st.operatorSetId = uint32(abi.decode(vm.parseJson(json, ".operatorSetId"), (uint256))); + st.insuranceRecipient = abi.decode(vm.parseJson(json, ".insuranceRecipient"), (address)); + st.registrar = abi.decode(vm.parseJson(json, ".registrar"), (address)); + st.asset = abi.decode(vm.parseJson(json, ".asset"), (address)); + st.vault = abi.decode(vm.parseJson(json, ".vault"), (address)); + st.allocationEffectBlock = uint32(abi.decode(vm.parseJson(json, ".allocationEffectBlock"), (uint256))); + } +} + diff --git a/script/operations/e2e/MockAVSRegistrar.sol b/script/operations/e2e/MockAVSRegistrar.sol new file mode 100644 index 0000000000..76258edf49 --- /dev/null +++ b/script/operations/e2e/MockAVSRegistrar.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "src/contracts/interfaces/IAVSRegistrar.sol"; + +/// @notice Minimal AVS registrar for E2E testing. +/// @dev AllocationManager will call `registerOperator`/`deregisterOperator` during operator set +/// registration/deregistration. This registrar simply records membership and emits events. +contract MockAVSRegistrar is IAVSRegistrar { + error UnsupportedAVS(); + + event OperatorRegistered(address indexed operator, address indexed avs, uint32[] operatorSetIds, bytes data); + event OperatorDeregistered(address indexed operator, address indexed avs, uint32[] operatorSetIds); + + address public immutable supportedAVS; + + /// operator => operatorSetId => registered + mapping(address => mapping(uint32 => bool)) public isRegistered; + + constructor( + address _supportedAVS + ) { + supportedAVS = _supportedAVS; + } + + function supportsAVS( + address avs + ) external view returns (bool) { + return avs == supportedAVS; + } + + function registerOperator( + address operator, + address avs, + uint32[] calldata operatorSetIds, + bytes calldata data + ) external { + if (avs != supportedAVS) revert UnsupportedAVS(); + for (uint256 i = 0; i < operatorSetIds.length; i++) { + isRegistered[operator][operatorSetIds[i]] = true; + } + emit OperatorRegistered(operator, avs, operatorSetIds, data); + } + + function deregisterOperator( + address operator, + address avs, + uint32[] calldata operatorSetIds + ) external { + if (avs != supportedAVS) revert UnsupportedAVS(); + for (uint256 i = 0; i < operatorSetIds.length; i++) { + isRegistered[operator][operatorSetIds[i]] = false; + } + emit OperatorDeregistered(operator, avs, operatorSetIds); + } +} + diff --git a/script/operations/e2e/README.md b/script/operations/e2e/README.md new file mode 100644 index 0000000000..70f3fc73c4 --- /dev/null +++ b/script/operations/e2e/README.md @@ -0,0 +1,136 @@ +# Duration Vault E2E (Hoodi) + +This folder contains a minimal end-to-end smoke test script intended to be run against live environments (e.g. `preprod-hoodi`). + +## What it tests + +- **Vault lifecycle**: deploy → delegate+deposit → lock +- **Slashing**: slash the vault (as the AVS slasher) and confirm redistributed funds reach the insurance recipient +- **Rewards plumbing**: create an on-chain AVS rewards submission so the Sidecar can pick it up and later submit a distribution root + +## Prereqs + +- A Hoodi RPC URL (export `RPC_HOODI=...`) +- A funded EOA private key that will be used as: + - **vault admin** + - **AVS address** + - **operator-set slasher** + - **staker** (delegates + deposits) + +## Run (preprod-hoodi) + +### 0) Get core contract addresses + +These scripts are normal Foundry scripts; you just need the 5 core addresses for the environment you’re targeting: + +- `ALLOCATION_MANAGER` +- `DELEGATION_MANAGER` +- `STRATEGY_MANAGER` +- `STRATEGY_FACTORY` +- `REWARDS_COORDINATOR` + +Convenient way to grab them (copy/paste) is still Zeus: + +```bash +zeus env show preprod-hoodi +``` + +Then export the 5 addresses in your shell (or pass them as args via `--sig`). + +### 1) Fork/simulate first + +Run the script on a fork first (no `--broadcast`). +Tip: pass a funded sender (your EOA) so forked execution doesn’t revert due to `insufficient funds for gas`: + +```bash +forge script script/operations/e2e/DurationVaultHoodiE2E.s.sol \ + --fork-url "$RPC_HOODI" \ + --sender 0xYourEOA \ + -vvvv +``` + +### 2) Broadcast on Hoodi + +Then broadcast on Hoodi: + +```bash +forge script script/operations/e2e/DurationVaultHoodiE2E.s.sol \ + --rpc-url "$RPC_HOODI" \ + --private-key "$PRIVATE_KEY" \ + --broadcast \ + -vvvv +``` + +### Re-running without a new EOA + +- If you want to re-run **everything from scratch**, keep the same EOA but set a fresh `E2E_OPERATOR_SET_ID` (e.g. `2`, `3`, …) so `createRedistributingOperatorSets` doesn’t revert. +- If you only need a **fresh rewards submission** (e.g. to fix a bad time window), you can reuse the existing deployed vault from `e2e-state.json`: + +```bash +export E2E_PHASE=rewards +forge script script/operations/e2e/DurationVaultHoodiE2E.s.sol \ + --rpc-url "$RPC_HOODI" \ + --private-key "$PRIVATE_KEY" \ + --broadcast \ + -vvvv +``` + +## Optional env overrides + +You can override parameters using env vars: + +- `E2E_OPERATOR_SET_ID` (uint, default `1`) +- `E2E_INSURANCE_RECIPIENT` (address, default = your EOA) +- `E2E_MAX_PER_DEPOSIT` (uint, default `200 ether`) +- `E2E_STAKE_CAP` (uint, default `1000 ether`) +- `E2E_VAULT_DURATION_SECONDS` (uint, default `120`) +- `E2E_DEPOSIT_AMOUNT` (uint, default `100 ether`) +- `E2E_SLASH_WAD` (uint, default `0.25e18`) +- `E2E_REWARD_AMOUNT` (uint, default `10 ether`) +- `E2E_REWARDS_START_TIMESTAMP` (uint, default = **current** `RewardsCoordinator.CALCULATION_INTERVAL_SECONDS` boundary) +- `E2E_REWARDS_DURATION_SECONDS` (uint, default = `RewardsCoordinator.CALCULATION_INTERVAL_SECONDS` (1 day on `preprod-hoodi`)) + +## Validating rewards end-to-end (with Sidecar) + +This script only creates a `createAVSRewardsSubmission()` entry. To complete the “real” rewards E2E: + +- **Sidecar** should index the new submission and compute a distribution root for the relevant time window. +- The configured **RewardsUpdater** (often a Sidecar-controlled key) should call `RewardsCoordinator.submitRoot(...)`. +- Once the root is activated, the earner can call `RewardsCoordinator.processClaim(...)` with the Sidecar-produced proof. + +If you tell me which Sidecar instance / pipeline you’re using on `preprod-hoodi`, I can add a short checklist of the exact on-chain events + fields to confirm (submission hash, root index, activation timestamp, claim balance deltas). + +## Claiming rewards (optional, after Sidecar posts a root) + +Once Sidecar has posted a **claimable** distribution root and you have a claim JSON from your coworker, run the claim-only script: + +```bash +forge script script/operations/e2e/ClaimRewards.s.sol \ + --rpc-url "$RPC_HOODI" --private-key "$PRIVATE_KEY" --broadcast \ + --sig "run(string,address,address)" \ + -- "path/to/claim.json" 0xEarner 0xRecipient +``` + +### Claim JSON format + +The claim file must be parseable by Foundry `vm.parseJson` and match the `RewardsMerkleClaim` structure: + +```json +{ + "rootIndex": 0, + "earnerIndex": 0, + "earnerTreeProof": "0x", + "earnerLeaf": { "earner": "0x0000000000000000000000000000000000000000", "earnerTokenRoot": "0x..." }, + "tokenIndices": [0], + "tokenTreeProofs": ["0x"], + "tokenLeaves": [ + { "token": "0x0000000000000000000000000000000000000000", "cumulativeEarnings": "10000000000000000000" } + ] +} +``` + +Notes: +- The script will **revert** unless the root at `rootIndex` exists and `block.timestamp >= activatedAt`. +- The script will **revert** if `earnerLeaf.earner != ` to avoid accidental mismatched claims. In practice you should pass the same address as the EOA you’re broadcasting from. + + diff --git a/script/releases/v1.10.0-rewards-v2.2/1-deployRewardsCoordinatorImpl.s.sol b/script/releases/v1.10.0-rewards-v2.2/1-deployRewardsCoordinatorImpl.s.sol deleted file mode 100644 index b8a2357dec..0000000000 --- a/script/releases/v1.10.0-rewards-v2.2/1-deployRewardsCoordinatorImpl.s.sol +++ /dev/null @@ -1,223 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.12; - -import {EOADeployer} from "zeus-templates/templates/EOADeployer.sol"; -import "../Env.sol"; -import "../TestUtils.sol"; - -/// @title DeployRewardsCoordinatorImpl -/// @notice Deploy new RewardsCoordinator implementation with Rewards v2.2 support. -/// This adds support for: -/// - Unique stake rewards submissions (rewards linear to allocated unique stake) -/// - Total stake rewards submissions (rewards linear to total stake) -/// - Updated MAX_REWARDS_DURATION to 730 days (63072000 seconds) -contract DeployRewardsCoordinatorImpl is EOADeployer { - using Env for *; - using TestUtils for *; - - /// forgefmt: disable-next-item - function _runAsEOA() internal override { - // Only execute on source chains with version 1.9.0 - if (!(Env.isSourceChain() && Env._strEq(Env.envVersion(), "1.9.0"))) { - return; - } - - vm.startBroadcast(); - - // Update the MAX_REWARDS_DURATION environment variable before deployment - // 63072000s = 730 days = 2 years - zUpdateUint32("REWARDS_COORDINATOR_MAX_REWARDS_DURATION", 63072000); - - // Deploy RewardsCoordinator implementation with the updated MAX_REWARDS_DURATION - deployImpl({ - name: type(RewardsCoordinator).name, - deployedTo: address( - new RewardsCoordinator( - IRewardsCoordinatorTypes.RewardsCoordinatorConstructorParams({ - delegationManager: Env.proxy.delegationManager(), - strategyManager: Env.proxy.strategyManager(), - allocationManager: Env.proxy.allocationManager(), - pauserRegistry: Env.impl.pauserRegistry(), - permissionController: Env.proxy.permissionController(), - CALCULATION_INTERVAL_SECONDS: Env.CALCULATION_INTERVAL_SECONDS(), - MAX_REWARDS_DURATION: Env.MAX_REWARDS_DURATION(), // Using updated env value - MAX_RETROACTIVE_LENGTH: Env.MAX_RETROACTIVE_LENGTH(), - MAX_FUTURE_LENGTH: Env.MAX_FUTURE_LENGTH(), - GENESIS_REWARDS_TIMESTAMP: Env.GENESIS_REWARDS_TIMESTAMP() - }) - ) - ) - }); - - vm.stopBroadcast(); - } - - function testScript() public virtual { - if (!(Env.isSourceChain() && Env._strEq(Env.envVersion(), "1.9.0"))) { - return; - } - - // Deploy the new RewardsCoordinator implementation - runAsEOA(); - - _validateNewImplAddress(); - _validateProxyAdmin(); - _validateImplConstructor(); - _validateZeusEnvUpdated(); - _validateImplInitialized(); - _validateNewFunctionality(); - _validateStorageLayout(); - } - - /// @dev Validate that the new RewardsCoordinator impl address is distinct from the current one - function _validateNewImplAddress() internal view { - address currentImpl = TestUtils._getProxyImpl(address(Env.proxy.rewardsCoordinator())); - address newImpl = address(Env.impl.rewardsCoordinator()); - - assertFalse(currentImpl == newImpl, "RewardsCoordinator impl should be different from current implementation"); - } - - /// @dev Validate that the RewardsCoordinator proxy is still owned by the correct ProxyAdmin - function _validateProxyAdmin() internal view { - address pa = Env.proxyAdmin(); - - assertTrue( - TestUtils._getProxyAdmin(address(Env.proxy.rewardsCoordinator())) == pa, - "RewardsCoordinator proxyAdmin incorrect" - ); - } - - /// @dev Validate the immutables set in the new RewardsCoordinator implementation constructor - function _validateImplConstructor() internal view { - RewardsCoordinator rewardsCoordinatorImpl = Env.impl.rewardsCoordinator(); - - // Validate core dependencies - assertTrue( - address(rewardsCoordinatorImpl.delegationManager()) == address(Env.proxy.delegationManager()), - "RewardsCoordinator delegationManager mismatch" - ); - assertTrue( - address(rewardsCoordinatorImpl.strategyManager()) == address(Env.proxy.strategyManager()), - "RewardsCoordinator strategyManager mismatch" - ); - assertTrue( - address(rewardsCoordinatorImpl.allocationManager()) == address(Env.proxy.allocationManager()), - "RewardsCoordinator allocationManager mismatch" - ); - assertTrue( - address(rewardsCoordinatorImpl.pauserRegistry()) == address(Env.impl.pauserRegistry()), - "RewardsCoordinator pauserRegistry mismatch" - ); - assertTrue( - address(rewardsCoordinatorImpl.permissionController()) == address(Env.proxy.permissionController()), - "RewardsCoordinator permissionController mismatch" - ); - - // Validate reward parameters - assertEq( - rewardsCoordinatorImpl.CALCULATION_INTERVAL_SECONDS(), - Env.CALCULATION_INTERVAL_SECONDS(), - "CALCULATION_INTERVAL_SECONDS mismatch" - ); - assertEq( - rewardsCoordinatorImpl.MAX_REWARDS_DURATION(), - 63_072_000, - "MAX_REWARDS_DURATION should be updated to 730 days (63072000 seconds)" - ); - assertEq( - rewardsCoordinatorImpl.MAX_RETROACTIVE_LENGTH(), - Env.MAX_RETROACTIVE_LENGTH(), - "MAX_RETROACTIVE_LENGTH mismatch" - ); - assertEq(rewardsCoordinatorImpl.MAX_FUTURE_LENGTH(), Env.MAX_FUTURE_LENGTH(), "MAX_FUTURE_LENGTH mismatch"); - assertEq( - rewardsCoordinatorImpl.GENESIS_REWARDS_TIMESTAMP(), - Env.GENESIS_REWARDS_TIMESTAMP(), - "GENESIS_REWARDS_TIMESTAMP mismatch" - ); - } - - /// @dev Validate that the zeus environment variable has been updated correctly - function _validateZeusEnvUpdated() internal view { - RewardsCoordinator rewardsCoordinatorImpl = Env.impl.rewardsCoordinator(); - - // Validate that the zeus env MAX_REWARDS_DURATION matches what was deployed - assertEq( - rewardsCoordinatorImpl.MAX_REWARDS_DURATION(), - Env.MAX_REWARDS_DURATION(), - "Deployed MAX_REWARDS_DURATION should match zeus env value" - ); - - // Also validate it equals the expected value - assertEq( - Env.MAX_REWARDS_DURATION(), - 63_072_000, - "Zeus env MAX_REWARDS_DURATION should be updated to 730 days (63072000 seconds)" - ); - } - - /// @dev Validate that the new implementation cannot be initialized (should revert) - function _validateImplInitialized() internal { - bytes memory errInit = "Initializable: contract is already initialized"; - - RewardsCoordinator rewardsCoordinatorImpl = Env.impl.rewardsCoordinator(); - - vm.expectRevert(errInit); - rewardsCoordinatorImpl.initialize( - address(0), // initialOwner - 0, // initialPausedStatus - address(0), // rewardsUpdater - 0, // activationDelay - 0 // defaultSplitBips - ); - } - - /// @dev Validate new Rewards v2.2 functionality - function _validateNewFunctionality() internal view { - RewardsCoordinator rewardsCoordinatorImpl = Env.impl.rewardsCoordinator(); - - // The new functions exist (this will fail to compile if they don't exist) - // Just checking that the contract has the expected interface - bytes4 createUniqueStakeSelector = rewardsCoordinatorImpl.createUniqueStakeRewardsSubmission.selector; - bytes4 createTotalStakeSelector = rewardsCoordinatorImpl.createTotalStakeRewardsSubmission.selector; - - // Verify the selectors are non-zero (functions exist) - assertTrue(createUniqueStakeSelector != bytes4(0), "createUniqueStakeRewardsSubmission function should exist"); - assertTrue(createTotalStakeSelector != bytes4(0), "createTotalStakeRewardsSubmission function should exist"); - } - - /// @dev Validate storage layout changes - function _validateStorageLayout() internal view { - // The storage gap was reduced from 35 to 33 slots to accommodate the new mappings: - // - isUniqueStakeRewardsSubmissionHash (1 slot) - // - isTotalStakeRewardsSubmissionHash (1 slot) - // This validation ensures the contract is compiled with the expected storage layout - - // We can't directly access the storage gap, but we can ensure the contract - // compiles and deploys successfully, which validates the storage layout is correct - RewardsCoordinator rewardsCoordinatorImpl = Env.impl.rewardsCoordinator(); - - // Verify we can access the existing public mappings - // This validates that storage layout hasn't been corrupted - - // Check that we can call view functions that access storage - address testAvs = address(0x1234); - bytes32 testHash = keccak256("test"); - - // These calls should not revert, validating storage is accessible - bool isAVS = rewardsCoordinatorImpl.isAVSRewardsSubmissionHash(testAvs, testHash); - bool isOperatorDirectedAVS = - rewardsCoordinatorImpl.isOperatorDirectedAVSRewardsSubmissionHash(testAvs, testHash); - bool isOperatorDirectedOperatorSet = - rewardsCoordinatorImpl.isOperatorDirectedOperatorSetRewardsSubmissionHash(testAvs, testHash); - bool isUniqueStake = rewardsCoordinatorImpl.isUniqueStakeRewardsSubmissionHash(testAvs, testHash); - bool isTotalStake = rewardsCoordinatorImpl.isTotalStakeRewardsSubmissionHash(testAvs, testHash); - - // All should be false for a random hash - assertFalse(isAVS, "Random hash should not be a rewards submission"); - assertFalse(isOperatorDirectedAVS, "Random hash should not be operator directed"); - assertFalse(isOperatorDirectedOperatorSet, "Random hash should not be operator set"); - assertFalse(isUniqueStake, "Random hash should not be unique stake"); - assertFalse(isTotalStake, "Random hash should not be total stake"); - } -} diff --git a/script/releases/v1.10.0-rewards-v2.2/2-queueRewardsCoordinatorUpgrade.s.sol b/script/releases/v1.10.0-rewards-v2.2/2-queueRewardsCoordinatorUpgrade.s.sol deleted file mode 100644 index 058bd5918a..0000000000 --- a/script/releases/v1.10.0-rewards-v2.2/2-queueRewardsCoordinatorUpgrade.s.sol +++ /dev/null @@ -1,121 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.12; - -import {DeployRewardsCoordinatorImpl} from "./1-deployRewardsCoordinatorImpl.s.sol"; -import "../Env.sol"; -import "../TestUtils.sol"; - -import {MultisigBuilder} from "zeus-templates/templates/MultisigBuilder.sol"; -import {MultisigCall, Encode} from "zeus-templates/utils/Encode.sol"; - -import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; - -/// @title QueueRewardsCoordinatorUpgrade -/// @notice Queue the RewardsCoordinator upgrade transaction in the Timelock via the Operations Multisig. -/// This queues the upgrade to add Rewards v2.2 support: -/// - Unique stake rewards (linear to allocated unique stake) -/// - Total stake rewards (linear to total stake) -/// - Updated MAX_REWARDS_DURATION to 730 days (63072000 seconds) -contract QueueRewardsCoordinatorUpgrade is MultisigBuilder, DeployRewardsCoordinatorImpl { - using Env for *; - using Encode for *; - using TestUtils for *; - - function _runAsMultisig() internal virtual override prank(Env.opsMultisig()) { - if (!(Env.isSourceChain() && Env._strEq(Env.envVersion(), "1.9.0"))) { - return; - } - - bytes memory calldata_to_executor = _getCalldataToExecutor(); - - TimelockController timelock = Env.timelockController(); - timelock.schedule({ - target: Env.executorMultisig(), - value: 0, - data: calldata_to_executor, - predecessor: 0, - salt: 0, - delay: timelock.getMinDelay() - }); - } - - /// @dev Get the calldata to be sent from the timelock to the executor - function _getCalldataToExecutor() internal returns (bytes memory) { - /// forgefmt: disable-next-item - MultisigCall[] storage executorCalls = Encode.newMultisigCalls().append({ - to: Env.proxyAdmin(), - data: Encode.proxyAdmin.upgrade({ - proxy: address(Env.proxy.rewardsCoordinator()), - impl: address(Env.impl.rewardsCoordinator()) - }) - }); - - return Encode.gnosisSafe - .execTransaction({ - from: address(Env.timelockController()), - to: Env.multiSendCallOnly(), - op: Encode.Operation.DelegateCall, - data: Encode.multiSend(executorCalls) - }); - } - - function testScript() public virtual override { - if (!(Env.isSourceChain() && Env._strEq(Env.envVersion(), "1.9.0"))) { - return; - } - - // 1 - Deploy. The new RewardsCoordinator implementation has been deployed - runAsEOA(); - - TimelockController timelock = Env.timelockController(); - bytes memory calldata_to_executor = _getCalldataToExecutor(); - bytes32 txHash = timelock.hashOperation({ - target: Env.executorMultisig(), - value: 0, - data: calldata_to_executor, - predecessor: 0, - salt: 0 - }); - - // Ensure transaction is not already queued - assertFalse(timelock.isOperationPending(txHash), "Transaction should not be queued yet"); - assertFalse(timelock.isOperationReady(txHash), "Transaction should not be ready"); - assertFalse(timelock.isOperationDone(txHash), "Transaction should not be complete"); - - // 2 - Queue the transaction - _runAsMultisig(); - _unsafeResetHasPranked(); // reset hasPranked so we can use it again - - // Verify transaction is queued - assertTrue(timelock.isOperationPending(txHash), "Transaction should be queued"); - assertFalse(timelock.isOperationReady(txHash), "Transaction should NOT be ready immediately"); - assertFalse(timelock.isOperationDone(txHash), "Transaction should NOT be complete"); - - // Validate that timelock delay is properly configured - uint256 minDelay = timelock.getMinDelay(); - assertTrue(minDelay > 0, "Timelock delay should be greater than 0"); - - // Validate proxy state before upgrade - _validatePreUpgradeState(); - } - - /// @dev Validate the state before the upgrade - function _validatePreUpgradeState() internal view { - RewardsCoordinator rewardsCoordinator = Env.proxy.rewardsCoordinator(); - - // Validate current implementation is different from new implementation - address currentImpl = TestUtils._getProxyImpl(address(rewardsCoordinator)); - address newImpl = address(Env.impl.rewardsCoordinator()); - assertTrue(currentImpl != newImpl, "Current and new implementations should be different"); - - // Validate current MAX_REWARDS_DURATION is different from new value - // Note: We access this through the proxy, which still points to the old implementation - uint32 currentMaxRewardsDuration = rewardsCoordinator.MAX_REWARDS_DURATION(); - assertTrue(currentMaxRewardsDuration != 63_072_000, "Current MAX_REWARDS_DURATION should not be 730 days yet"); - - // Validate that we're upgrading from the correct version - // We can't directly call them since they don't exist, but we can verify the upgrade is needed - // by checking that we're indeed on the right version - assertEq(Env.envVersion(), "1.9.0", "Should be on version 1.9.0 before upgrade"); - } -} diff --git a/script/releases/v1.10.0-rewards-v2.2/3-executeRewardsCoordinatorUpgrade.s.sol b/script/releases/v1.10.0-rewards-v2.2/3-executeRewardsCoordinatorUpgrade.s.sol deleted file mode 100644 index 212d0e35eb..0000000000 --- a/script/releases/v1.10.0-rewards-v2.2/3-executeRewardsCoordinatorUpgrade.s.sol +++ /dev/null @@ -1,194 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.12; - -import "../Env.sol"; -import "../TestUtils.sol"; -import {QueueRewardsCoordinatorUpgrade} from "./2-queueRewardsCoordinatorUpgrade.s.sol"; -import {Encode} from "zeus-templates/utils/Encode.sol"; - -/// @title ExecuteRewardsCoordinatorUpgrade -/// @notice Execute the queued RewardsCoordinator upgrade after the timelock delay. -/// This completes the upgrade to add Rewards v2.2 support: -/// - Unique stake rewards (linear to allocated unique stake) -/// - Total stake rewards (linear to total stake) -/// - Updated MAX_REWARDS_DURATION to 730 days (63072000 seconds) -contract ExecuteRewardsCoordinatorUpgrade is QueueRewardsCoordinatorUpgrade { - using Env for *; - using Encode for *; - using TestUtils for *; - - function _runAsMultisig() internal override prank(Env.protocolCouncilMultisig()) { - if (!(Env.isSourceChain() && Env._strEq(Env.envVersion(), "1.9.0"))) { - return; - } - - bytes memory calldata_to_executor = _getCalldataToExecutor(); - TimelockController timelock = Env.timelockController(); - - timelock.execute({ - target: Env.executorMultisig(), - value: 0, - payload: calldata_to_executor, - predecessor: 0, - salt: 0 - }); - } - - function testScript() public virtual override { - if (!(Env.isSourceChain() && Env._strEq(Env.envVersion(), "1.9.0"))) { - return; - } - - // 1 - Deploy. The new RewardsCoordinator implementation has been deployed - runAsEOA(); - - TimelockController timelock = Env.timelockController(); - bytes memory calldata_to_executor = _getCalldataToExecutor(); - bytes32 txHash = timelock.hashOperation({ - target: Env.executorMultisig(), - value: 0, - data: calldata_to_executor, - predecessor: 0, - salt: 0 - }); - - // 2 - Queue. Check that the operation IS ready - QueueRewardsCoordinatorUpgrade._runAsMultisig(); - _unsafeResetHasPranked(); // reset hasPranked so we can use it again - - assertTrue(timelock.isOperationPending(txHash), "Transaction should be queued"); - assertFalse(timelock.isOperationReady(txHash), "Transaction should NOT be ready immediately"); - assertFalse(timelock.isOperationDone(txHash), "Transaction should NOT be complete"); - - // 3 - Warp past the timelock delay - vm.warp(block.timestamp + timelock.getMinDelay()); - assertTrue(timelock.isOperationReady(txHash), "Transaction should be ready for execution"); - - // 4 - Execute the upgrade - execute(); - assertTrue(timelock.isOperationDone(txHash), "v1.10.0 RewardsCoordinator upgrade should be complete"); - - // 5 - Validate the upgrade was successful - _validateUpgradeComplete(); - _validateProxyAdmin(); - _validateProxyConstructor(); - _validateProxyInitialized(); - _validateNewFunctionalityThroughProxy(); - } - - /// @dev Validate that the RewardsCoordinator proxy now points to the new implementation - function _validateUpgradeComplete() internal view { - address currentImpl = TestUtils._getProxyImpl(address(Env.proxy.rewardsCoordinator())); - address expectedImpl = address(Env.impl.rewardsCoordinator()); - - assertTrue(currentImpl == expectedImpl, "RewardsCoordinator proxy should point to new implementation"); - } - - /// @dev Validate the proxy's constructor values through the proxy - function _validateProxyConstructor() internal view { - RewardsCoordinator rewardsCoordinator = Env.proxy.rewardsCoordinator(); - - // Validate core dependencies - assertTrue( - address(rewardsCoordinator.delegationManager()) == address(Env.proxy.delegationManager()), - "RewardsCoordinator delegationManager mismatch" - ); - assertTrue( - address(rewardsCoordinator.strategyManager()) == address(Env.proxy.strategyManager()), - "RewardsCoordinator strategyManager mismatch" - ); - assertTrue( - address(rewardsCoordinator.allocationManager()) == address(Env.proxy.allocationManager()), - "RewardsCoordinator allocationManager mismatch" - ); - assertTrue( - address(rewardsCoordinator.pauserRegistry()) == address(Env.impl.pauserRegistry()), - "RewardsCoordinator pauserRegistry mismatch" - ); - assertTrue( - address(rewardsCoordinator.permissionController()) == address(Env.proxy.permissionController()), - "RewardsCoordinator permissionController mismatch" - ); - - // Validate reward parameters - assertEq( - rewardsCoordinator.CALCULATION_INTERVAL_SECONDS(), - Env.CALCULATION_INTERVAL_SECONDS(), - "CALCULATION_INTERVAL_SECONDS mismatch" - ); - - // Validate the updated MAX_REWARDS_DURATION - assertEq( - rewardsCoordinator.MAX_REWARDS_DURATION(), - 63_072_000, - "MAX_REWARDS_DURATION should be updated to 730 days (63072000 seconds)" - ); - - assertEq( - rewardsCoordinator.MAX_RETROACTIVE_LENGTH(), Env.MAX_RETROACTIVE_LENGTH(), "MAX_RETROACTIVE_LENGTH mismatch" - ); - assertEq(rewardsCoordinator.MAX_FUTURE_LENGTH(), Env.MAX_FUTURE_LENGTH(), "MAX_FUTURE_LENGTH mismatch"); - - assertEq( - rewardsCoordinator.GENESIS_REWARDS_TIMESTAMP(), - Env.GENESIS_REWARDS_TIMESTAMP(), - "GENESIS_REWARDS_TIMESTAMP mismatch" - ); - } - - /// @dev Validate that the proxy is still initialized and cannot be re-initialized - function _validateProxyInitialized() internal { - RewardsCoordinator rewardsCoordinator = Env.proxy.rewardsCoordinator(); - - // Validate the existing initializable state variables are still set - assertTrue(rewardsCoordinator.paused() == Env.REWARDS_PAUSE_STATUS(), "Paused status should still be set"); - assertTrue(rewardsCoordinator.owner() == Env.opsMultisig(), "Owner should still be set"); - assertTrue(rewardsCoordinator.rewardsUpdater() == Env.REWARDS_UPDATER(), "RewardsUpdater should still be set"); - assertTrue( - rewardsCoordinator.activationDelay() == Env.ACTIVATION_DELAY(), "Activation delay should still be set" - ); - assertTrue( - rewardsCoordinator.defaultOperatorSplitBips() == Env.DEFAULT_SPLIT_BIPS(), - "Default split bips should still be set" - ); - - // Attempt to re-initialize should fail - bytes memory errInit = "Initializable: contract is already initialized"; - vm.expectRevert(errInit); - rewardsCoordinator.initialize( - address(0x1234), // initialOwner - 0, // initialPausedStatus - address(0x9ABC), // rewardsUpdater - 0, // activationDelay - 0 // defaultSplitBips - ); - } - - /// @dev Validate new Rewards v2.2 functionality through the proxy - function _validateNewFunctionalityThroughProxy() internal view { - RewardsCoordinator rewardsCoordinator = Env.proxy.rewardsCoordinator(); - - // The new functions should be accessible through the proxy - bytes4 createUniqueStakeSelector = rewardsCoordinator.createUniqueStakeRewardsSubmission.selector; - bytes4 createTotalStakeSelector = rewardsCoordinator.createTotalStakeRewardsSubmission.selector; - - // Verify the selectors are non-zero (functions exist) - assertTrue( - createUniqueStakeSelector != bytes4(0), "createUniqueStakeRewardsSubmission function should exist on proxy" - ); - assertTrue( - createTotalStakeSelector != bytes4(0), "createTotalStakeRewardsSubmission function should exist on proxy" - ); - - // Test that we can access the new storage mappings - address testAvs = address(0xDEAD); - bytes32 testHash = keccak256("test_rewards_v2.2"); - - // These should all return false for test values, but accessing them validates the storage layout - bool isUniqueStake = rewardsCoordinator.isUniqueStakeRewardsSubmissionHash(testAvs, testHash); - bool isTotalStake = rewardsCoordinator.isTotalStakeRewardsSubmissionHash(testAvs, testHash); - - assertFalse(isUniqueStake, "Test hash should not be a unique stake submission"); - assertFalse(isTotalStake, "Test hash should not be a total stake submission"); - } -} diff --git a/script/releases/v1.10.0-rewards-v2.2/upgrade.json b/script/releases/v1.10.0-rewards-v2.2/upgrade.json deleted file mode 100644 index 05d1f68665..0000000000 --- a/script/releases/v1.10.0-rewards-v2.2/upgrade.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "rewards-v2.2-v1.10.0", - "from": "1.9.0", - "to": "1.10.0", - "phases": [ - { - "type": "eoa", - "filename": "1-deployRewardsCoordinatorImpl.s.sol" - }, - { - "type": "multisig", - "filename": "2-queueRewardsCoordinatorUpgrade.s.sol" - }, - { - "type": "multisig", - "filename": "3-executeRewardsCoordinatorUpgrade.s.sol" - } - ] -} \ No newline at end of file diff --git a/script/releases/v1.11.0-duration-vault/1-deployContracts.s.sol b/script/releases/v1.11.0-duration-vault/1-deployContracts.s.sol index 569f138ef1..6e47c71487 100644 --- a/script/releases/v1.11.0-duration-vault/1-deployContracts.s.sol +++ b/script/releases/v1.11.0-duration-vault/1-deployContracts.s.sol @@ -7,9 +7,10 @@ import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import "../Env.sol"; import "../TestUtils.sol"; -/// Purpose: use an EOA to deploy all of the new/updated contracts for the Duration Vault feature. +/// Purpose: use an EOA to deploy all of the new/updated contracts for Rewards v2.2 and Duration Vault features. /// Contracts deployed: /// /// Core +/// - RewardsCoordinator (updated with unique/total stake rewards, MAX_REWARDS_DURATION 730 days) /// - StrategyManager (updated with beforeAddShares/beforeRemoveShares hooks) /// /// Strategies /// - EigenStrategy (inherits from updated StrategyBase) @@ -25,6 +26,10 @@ contract DeployContracts is CoreContractsDeployer { vm.startBroadcast(); /// core/ + // Update the MAX_REWARDS_DURATION environment variable before deploying RewardsCoordinator + // 63072000s = 730 days = 2 years + zUpdateUint32("REWARDS_COORDINATOR_MAX_REWARDS_DURATION", 63_072_000); + deployRewardsCoordinator(); deployStrategyManager(); /// strategies/ @@ -57,7 +62,7 @@ contract DeployContracts is CoreContractsDeployer { } function testScript() public virtual { - if (!Env.isCoreProtocolDeployed() || !Env.isSource() || !Env._versionGte(Env.envVersion(), "1.10.0")) { + if (!Env.isCoreProtocolDeployed() || !Env.isSource() || !Env._strEq(Env.envVersion(), "1.9.0")) { return; } @@ -70,7 +75,25 @@ contract DeployContracts is CoreContractsDeployer { function _validateDeployedContracts() internal view { // Verify expected number of deployments - assertEq(deploys().length, 7, "Expected 7 deployed contracts"); + assertEq(deploys().length, 8, "Expected 8 deployed contracts"); + + // Validate RewardsCoordinator + assertTrue( + address(Env.impl.rewardsCoordinator()) != address(0), "RewardsCoordinator implementation should be deployed" + ); + assertTrue( + address(Env.impl.rewardsCoordinator().delegationManager()) == address(Env.proxy.delegationManager()), + "RewardsCoordinator: delegationManager mismatch" + ); + assertTrue( + address(Env.impl.rewardsCoordinator().strategyManager()) == address(Env.proxy.strategyManager()), + "RewardsCoordinator: strategyManager mismatch" + ); + assertEq( + Env.impl.rewardsCoordinator().MAX_REWARDS_DURATION(), + 63_072_000, + "RewardsCoordinator: MAX_REWARDS_DURATION should be 730 days (63072000 seconds)" + ); // Validate StrategyManager assertTrue( diff --git a/script/releases/v1.11.0-duration-vault/2-queueUpgrade.s.sol b/script/releases/v1.11.0-duration-vault/2-queueUpgrade.s.sol index da98bd89fd..bfa7f92d98 100644 --- a/script/releases/v1.11.0-duration-vault/2-queueUpgrade.s.sol +++ b/script/releases/v1.11.0-duration-vault/2-queueUpgrade.s.sol @@ -9,8 +9,9 @@ import {IProtocolRegistry, IProtocolRegistryTypes} from "src/contracts/interface import "../Env.sol"; import "../TestUtils.sol"; -/// Purpose: Queue the upgrade for Duration Vault feature. +/// Purpose: Queue the upgrade for Rewards v2.2 and Duration Vault features. /// This script queues upgrades to: +/// - RewardsCoordinator proxy (Rewards v2.2: unique/total stake rewards, updated MAX_REWARDS_DURATION) /// - StrategyManager proxy /// - EigenStrategy proxy /// - StrategyBase beacon @@ -41,6 +42,7 @@ contract QueueUpgrade is DeployContracts, MultisigBuilder { MultisigCall[] storage executorCalls = Encode.newMultisigCalls(); /// core/ + executorCalls.upgradeRewardsCoordinator(); executorCalls.upgradeStrategyManager(); /// strategies/ @@ -88,7 +90,7 @@ contract QueueUpgrade is DeployContracts, MultisigBuilder { } function testScript() public virtual override { - if (!Env.isCoreProtocolDeployed() || !Env.isSource() || !Env._versionGte(Env.envVersion(), "1.10.0")) { + if (!Env.isCoreProtocolDeployed() || !Env.isSource() || !Env._strEq(Env.envVersion(), "1.9.0")) { return; } diff --git a/script/releases/v1.11.0-duration-vault/3-completeUpgrade.s.sol b/script/releases/v1.11.0-duration-vault/3-completeUpgrade.s.sol index a203b94eff..3abe3e1aec 100644 --- a/script/releases/v1.11.0-duration-vault/3-completeUpgrade.s.sol +++ b/script/releases/v1.11.0-duration-vault/3-completeUpgrade.s.sol @@ -7,7 +7,7 @@ import {Encode, MultisigCall} from "zeus-templates/utils/Encode.sol"; import "../Env.sol"; import "../TestUtils.sol"; -/// Purpose: Execute the queued upgrade for Duration Vault feature. +/// Purpose: Execute the queued upgrade for Rewards v2.2 and Duration Vault features. contract ExecuteUpgrade is QueueUpgrade { using Env for *; @@ -25,7 +25,7 @@ contract ExecuteUpgrade is QueueUpgrade { } function testScript() public virtual override { - if (!Env.isCoreProtocolDeployed() || !Env.isSource() || !Env._versionGte(Env.envVersion(), "1.10.0")) { + if (!Env.isCoreProtocolDeployed() || !Env.isSource() || !Env._strEq(Env.envVersion(), "1.9.0")) { return; } diff --git a/script/releases/v1.11.0-duration-vault/upgrade.json b/script/releases/v1.11.0-duration-vault/upgrade.json index 3eccc2524a..aad80e301d 100644 --- a/script/releases/v1.11.0-duration-vault/upgrade.json +++ b/script/releases/v1.11.0-duration-vault/upgrade.json @@ -1,6 +1,6 @@ { - "name": "duration-vault-strategy", - "from": ">=1.10.0", + "name": "rewards-v2.2-duration-vault-strategy", + "from": "1.9.0", "to": "1.11.0", "phases": [ {