diff --git a/.env.hoodi.local b/.env.hoodi.local new file mode 100644 index 0000000..0febb77 --- /dev/null +++ b/.env.hoodi.local @@ -0,0 +1,10 @@ +FACTORY_PARAMS_JSON=config/hoodi-factory.json +RPC_URL=http://localhost:9123 + +CORE_LOCATOR_ADDRESS="0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8" +STETH=0x3508A952176b3c15387C97BE809eaffB1982176a +WSTETH=0x7E99eE3C66636DE415D2d7C880938F2f40f94De4 + +# Default unlocked accounts +DEPLOYER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 diff --git a/.github/workflows/integration-tests-hoodi.yml b/.github/workflows/integration-tests-hoodi.yml index c135fe5..4fe2e54 100644 --- a/.github/workflows/integration-tests-hoodi.yml +++ b/.github/workflows/integration-tests-hoodi.yml @@ -20,11 +20,5 @@ jobs: - name: Common setup uses: ./.github/workflows/setup - - name: Run integration tests for StvPool - run: make test-integration-a - - - name: Run integration tests for StvStETHPool - run: make test-integration-b - - - name: Run integration tests for GGV - run: make test-integration-ggv + - name: Run integration tests + run: make test-integration diff --git a/.github/workflows/integration-tests-scratch.yml b/.github/workflows/integration-tests-scratch.yml index 6912175..3551f18 100644 --- a/.github/workflows/integration-tests-scratch.yml +++ b/.github/workflows/integration-tests-scratch.yml @@ -36,11 +36,5 @@ jobs: env: CORE_RPC_PORT: 9123 - - name: Run integration tests for StvPool - run: make test-integration-a - - - name: Run integration tests for StvStETHPool - run: make test-integration-b - - - name: Run integration tests for GGV - run: make test-integration-ggv + - name: Run integration tests + run: make test-integration diff --git a/.gitignore b/.gitignore index 5afc056..8689c84 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ lido-core/ .code/ .vscode/ .wake/ +.zed/ # LLM files **/CLAUDE.md @@ -28,7 +29,9 @@ docs/ # Dotenv file .env +.env.hoodi +.env.mainnet # MacOS .DS_Store -artifacts/ \ No newline at end of file +artifacts/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 03d9549..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 11228a4..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml deleted file mode 100644 index f324872..0000000 --- a/.idea/php.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml deleted file mode 100644 index b0c1c68..0000000 --- a/.idea/prettier.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/vaults-wrapper.iml b/.idea/vaults-wrapper.iml deleted file mode 100644 index c956989..0000000 --- a/.idea/vaults-wrapper.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 67ed933..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/Makefile b/Makefile index 4a458cc..0238e5b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ .ONESHELL: +# TODO: get this deprecated in favour of just + CORE_RPC_PORT ?= 9123 CORE_BRANCH ?= feat/vaults CORE_SUBDIR ?= lido-core @@ -26,6 +28,14 @@ test-integration-b: -$(VERBOSITY) \ --fork-url "$$RPC_URL" +test-integration-dashboard: + [ -f .env ] && . .env; \ + FOUNDRY_PROFILE=test \ + CORE_LOCATOR_ADDRESS="$$CORE_LOCATOR_ADDRESS" \ + forge test \ + "test/integration/dashboard*.test.sol" \ + -$(VERBOSITY) \ + --fork-url "$$RPC_URL" test-integration-ggv: [ -f .env ] && . .env; \ @@ -288,4 +298,4 @@ deploy-all: fi; \ done -# WRAPPER_CONFIGS=$${WRAPPER_CONFIGS:-"script/stv-pool-deploy-config-hoodi.json script/stv-steth-pool-deploy-config-hoodi.json script/stv-ggv-pool-deploy-config-hoodi.json"}; \ \ No newline at end of file +# WRAPPER_CONFIGS=$${WRAPPER_CONFIGS:-"script/stv-pool-deploy-config-hoodi.json script/stv-steth-pool-deploy-config-hoodi.json script/stv-ggv-pool-deploy-config-hoodi.json"}; \ diff --git a/README.md b/README.md index 63d5528..6bd6668 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,11 @@ The Factory orchestrates deployment of the entire pool system (Vault, Dashboard, - **Implementation factories**: `StvPoolFactory`, `StvStETHPoolFactory`, `StvStETHPoolFactory`, `WithdrawalQueueFactory`, `LoopStrategyFactory`, `GGVStrategyFactory` - **Proxy stub**: `DummyImplementation` (for `OssifiableProxy` bootstrap) -- Deploy `Factory` (either deploy the factories yourself or use `script/DeployWrapperFactory.s.sol`): - - `DeployWrapperFactory` now requires `CORE_LOCATOR_ADDRESS` and `FACTORY_PARAMS_JSON`; it derives all core addresses from the Locator. -- Constructor shape for reference: `new Factory(locator, SubFactories{ ... }, TimelockConfig{ ... }, StrategyParameters{ ggvTeller, ggvBoringOnChainQueue })` +- Reusing an existing deployment: export `FACTORY_ADDRESS` (alongside `CORE_LOCATOR_ADDRESS`) before running scripts or integration tests. When the variable is set, the harness logs `Using predeployed factory from FACTORY_ADDRESS ...` and skips deploying a fresh `Factory` instance. + +- Deploy `Factory` (either deploy the factories yourself or use `script/DeployFactory.s.sol`): + - `DeployFactory` requires `CORE_LOCATOR_ADDRESS` and `FACTORY_PARAMS_JSON`; it derives all core addresses from the Locator and wires the `GGVStrategyFactory` inputs. +- Constructor shape for reference: `new Factory(locator, SubFactories{ ... })` - Create a complete pool system using one of the specialized entrypoints (send `msg.value == VaultHub.CONNECT_DEPOSIT`): - `createVaultWithNoMintingNoStrategy(nodeOperator, nodeOperatorManager, nodeOperatorFeeBP, confirmExpiry, allowlistEnabled)` @@ -154,6 +156,14 @@ Local deployment (quickstart) - `deployments/pool-.json`: deployed `Factory` and implementation factory addresses - `deployments/pool-instance.json`: deployed Vault, Dashboard, Wrapper proxy, Withdrawal Queue, and Strategy (if applicable) +- Quick verification using the test harness: + ```bash + FACTORY_ADDRESS= \ + CORE_LOCATOR_ADDRESS= \ + RPC_URL=http://localhost:9123 \ + make -s test-integration + ``` + ### Cast ```shell diff --git a/config/hoodi-factory.json b/config/hoodi-factory.json new file mode 100644 index 0000000..e9d5fc4 --- /dev/null +++ b/config/hoodi-factory.json @@ -0,0 +1,8 @@ +{ + "strategies": { + "ggv": { + "teller": "0x71A2d32343ae5DDeB0A9917358687D0Ed7Ce6Bf6", + "boringOnChainQueue": "0x501bF497fcB98e912F3c8CFfe871De4580B2Ca20" + } + } +} diff --git a/config/hoodi-stv-ggv.json b/config/hoodi-stv-ggv.json new file mode 100644 index 0000000..de99808 --- /dev/null +++ b/config/hoodi-stv-ggv.json @@ -0,0 +1,25 @@ +{ + "vaultConfig": { + "nodeOperator": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", + "nodeOperatorManager": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", + "nodeOperatorFeeBP": 10, + "confirmExpiry": 3600 + }, + "commonPoolConfig": { + "minWithdrawalDelayTime": 3600, + "name": "Staked ETH GGV Pool", + "symbol": "STV" + }, + "auxiliaryPoolConfig": { + "allowlistEnabled": true, + "mintingEnabled": true, + "reserveRatioGapBP": 1000 + }, + "timelockConfig": { + "minDelaySeconds": 60, + "proposer": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", + "executor": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2" + }, + "strategyFactory": "0x0000000000000000000000000000000000000000", + "connectDepositWei": 1000000000000000000 +} diff --git a/config/hoodi-stv-steth.json b/config/hoodi-stv-steth.json new file mode 100644 index 0000000..03ef8fd --- /dev/null +++ b/config/hoodi-stv-steth.json @@ -0,0 +1,25 @@ +{ + "vaultConfig": { + "nodeOperator": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", + "nodeOperatorManager": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", + "nodeOperatorFeeBP": 10, + "confirmExpiry": 3600 + }, + "commonPoolConfig": { + "minWithdrawalDelayTime": 3600, + "name": "Staked ETH StETH Pool", + "symbol": "STV" + }, + "auxiliaryPoolConfig": { + "allowlistEnabled": false, + "mintingEnabled": true, + "reserveRatioGapBP": 250 + }, + "timelockConfig": { + "minDelaySeconds": 60, + "proposer": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", + "executor": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2" + }, + "strategyFactory": "0x0000000000000000000000000000000000000000", + "connectDepositWei": 1000000000000000000 +} diff --git a/config/hoodi-stv.json b/config/hoodi-stv.json new file mode 100644 index 0000000..134770f --- /dev/null +++ b/config/hoodi-stv.json @@ -0,0 +1,25 @@ +{ + "vaultConfig": { + "nodeOperator": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", + "nodeOperatorManager": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", + "nodeOperatorFeeBP": 10, + "confirmExpiry": 3600 + }, + "commonPoolConfig": { + "minWithdrawalDelayTime": 3600, + "name": "Staked ETH Pool", + "symbol": "STV" + }, + "auxiliaryPoolConfig": { + "allowlistEnabled": false, + "mintingEnabled": false, + "reserveRatioGapBP": 0 + }, + "timelockConfig": { + "minDelaySeconds": 60, + "proposer": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", + "executor": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2" + }, + "strategyFactory": "0x0000000000000000000000000000000000000000", + "connectDepositWei": 1000000000000000000 +} diff --git a/deployments/pool-factory-latest.json b/deployments/pool-factory-latest.json new file mode 100644 index 0000000..95b76df --- /dev/null +++ b/deployments/pool-factory-latest.json @@ -0,0 +1,14 @@ +{ + "deployment": { + "factory": "0xc3EfA8DD49404abd747a4e4995ea622E260E07D4", + "network": "560048" + }, + "factories": { + "ggvStrategyFactory": "0x1FD72F10e96e77962e2D0f4C7d316eAd9e33648E", + "loopStrategyFactory": "0x85a1FBcfDDE7A760D65021f6530c5F3bB22C2ae6", + "stvPoolFactory": "0x5467d7FeE87A4786da9f6658A4f9815b36e41827", + "stvStETHPoolFactory": "0x1444d1ce0637bDc64bDdeD76Dc98f8E3eBfA12a4", + "timelockFactory": "0x2BEE5f2107cDC31C132c2CbdCc436254AB2e22D9", + "withdrawalQueueFactory": "0x48C84E008FcAe8C807600Ff8C5883A3304fB910A" + } +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 41df078..999b307 100644 --- a/foundry.toml +++ b/foundry.toml @@ -13,7 +13,7 @@ via_ir = true # although it's slower it helps with stack too deep errors in test dynamic_test_linking = true # to improve compilation speed optimizer = true -optimizer_runs = 1 +optimizer_runs = 200 # Remappings remappings = [ @@ -45,3 +45,6 @@ ignore = [ "test/**", "script/**", ] + +[lint] +lint_on_build = false # TODO: enable later diff --git a/justfile b/justfile new file mode 100644 index 0000000..e0a2656 --- /dev/null +++ b/justfile @@ -0,0 +1,90 @@ +set dotenv-load := true +set unstable := true + +fusaka_tx_gas_limit := '16777216' +verify_flags := if env('PUBLISH_SOURCES', '') != '' { + '--verify --verifier etherscan --retries 20 --delay 15' +} else { + '' +} +common_script_flags := "--rpc-url " + env('RPC_URL') + " --broadcast --sender " + env('DEPLOYER') + " --private-key " + env('PRIVATE_KEY') + " --slow " + verify_flags + " --non-interactive" + +default: + @just --list + +deploy-factory: + forge script script/DeployFactory.s.sol:DeployFactory \ + {{common_script_flags}} \ + --sig 'run()' + +deploy-pool FACTORY_ADDRESS POOL_PARAMS_JSON: + POOL_PARAMS_JSON={{POOL_PARAMS_JSON}} \ + FACTORY_ADDRESS={{FACTORY_ADDRESS}} \ + forge script script/DeployPool.s.sol:DeployPool \ + {{common_script_flags}} \ + -vvvv \ + --sig 'run()' + +deploy-pool-start FACTORY_ADDRESS POOL_PARAMS_JSON: + DEPLOY_MODE=start \ + POOL_PARAMS_JSON={{POOL_PARAMS_JSON}} \ + FACTORY_ADDRESS={{FACTORY_ADDRESS}} \ + forge script script/DeployPool.s.sol:DeployPool \ + {{common_script_flags}} \ + --gas-estimate-multiplier 100 \ + --sig 'run()' + +deploy-pool-finish FACTORY_ADDRESS INTERMEDIATE_JSON: + DEPLOY_MODE=finish \ + INTERMEDIATE_JSON={{INTERMEDIATE_JSON}} \ + FACTORY_ADDRESS={{FACTORY_ADDRESS}} \ + forge script script/DeployPool.s.sol:DeployPool \ + {{common_script_flags}} \ + --gas-estimate-multiplier 110 \ + --sig 'run()' + +deploy-all env_file: + #!/usr/bin/env bash + set -euxo pipefail + source {{env_file}} + just deploy-factory + export FACTORY=$(jq '.deployment.factory' deployments/pool-factory-latest.json) + export NOW=$(date -Iseconds | sed 's/+.*//') + + export POOL_CONFIG_NAME="hoodi-stv.json" + export INTERMEDIATE_JSON="deployments/intermediate-${NOW}-${POOL_CONFIG_NAME}" + just deploy-pool-start $FACTORY "config/${POOL_CONFIG_NAME}" + just deploy-pool-finish $FACTORY $INTERMEDIATE_JSON + + export POOL_CONFIG_NAME="hoodi-stv-steth.json" + export INTERMEDIATE_JSON="deployments/intermediate-${NOW}-${POOL_CONFIG_NAME}" + just deploy-pool-start $FACTORY "config/${POOL_CONFIG_NAME}" + just deploy-pool-finish $FACTORY $INTERMEDIATE_JSON + + export POOL_CONFIG_NAME="hoodi-stv-ggv.json" + export INTERMEDIATE_JSON="deployments/intermediate-${NOW}-${POOL_CONFIG_NAME}" + just deploy-pool-start $FACTORY "config/${POOL_CONFIG_NAME}" + just deploy-pool-finish $FACTORY $INTERMEDIATE_JSON + + +deploy-ggv-mocks: + STETH={{env('STETH')}} \ + WSTETH={{env('WSTETH')}} \ + forge script script/DeployGGVMocks.s.sol:DeployGGVMocks \ + {{common_script_flags}} \ + --gas-limit {{fusaka_tx_gas_limit}} \ + --sig 'run()' + +publish-sources address contract_path constructor_args: + forge verify-contract {{address}} \ + --verifier etherscan \ + --rpc-url {{env('RPC_URL')}} \ + --constructor-args {{constructor_args}} \ + --watch \ + -vvvv + +test-integration path='**/*.test.sol': + forge test 'test/integration/{{path}}' --fork-url {{env('RPC_URL')}} + +test-unit: + FOUNDRY_PROFILE=test forge test --no-match-path 'test/integration/*' test diff --git a/script/DeployFactory.s.sol b/script/DeployFactory.s.sol index 4205c14..80ba582 100644 --- a/script/DeployFactory.s.sol +++ b/script/DeployFactory.s.sol @@ -1,94 +1,70 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import "forge-std/Script.sol"; import {Factory} from "src/Factory.sol"; import {DistributorFactory} from "src/factories/DistributorFactory.sol"; import {GGVStrategyFactory} from "src/factories/GGVStrategyFactory.sol"; -import {LoopStrategyFactory} from "src/factories/LoopStrategyFactory.sol"; import {StvPoolFactory} from "src/factories/StvPoolFactory.sol"; import {StvStETHPoolFactory} from "src/factories/StvStETHPoolFactory.sol"; import {TimelockFactory} from "src/factories/TimelockFactory.sol"; import {WithdrawalQueueFactory} from "src/factories/WithdrawalQueueFactory.sol"; -import {DummyImplementation} from "src/proxy/DummyImplementation.sol"; - -import {ILidoLocator} from "src/interfaces/ILidoLocator.sol"; +import {ILidoLocator} from "src/interfaces/core/ILidoLocator.sol"; contract DeployFactory is Script { - // function _readCore(address locatorAddress) internal view returns (CoreRefs memory c) { - // ILidoLocator locator = ILidoLocator(locatorAddress); - // c.vaultFactory = locator.vaultFactory(); - // c.steth = address(locator.lido()); - // c.wsteth = address(locator.wstETH()); - // c.lazyOracle = locator.lazyOracle(); - // } - - function _deployImplFactories() internal returns (Factory.SubFactories memory f) { + function _deployImplFactories(address _ggvTeller, address _ggvBoringQueue, address _steth, address _wsteth) + internal + returns (Factory.SubFactories memory f) + { f.stvPoolFactory = address(new StvPoolFactory()); f.stvStETHPoolFactory = address(new StvStETHPoolFactory()); f.withdrawalQueueFactory = address(new WithdrawalQueueFactory()); f.distributorFactory = address(new DistributorFactory()); - f.loopStrategyFactory = address(new LoopStrategyFactory()); - f.ggvStrategyFactory = address(new GGVStrategyFactory()); + f.ggvStrategyFactory = address(new GGVStrategyFactory(_ggvTeller, _ggvBoringQueue)); f.timelockFactory = address(new TimelockFactory()); } function _writeArtifacts( - Factory.SubFactories memory subFactories, - address factoryAddr, - string memory outputJsonPath + Factory.SubFactories memory _subFactories, + address _factoryAddr, + string memory _outputJsonPath ) internal { string memory factoriesSection = ""; - factoriesSection = vm.serializeAddress("factories", "stvPoolFactory", subFactories.stvPoolFactory); - factoriesSection = vm.serializeAddress("factories", "stvStETHPoolFactory", subFactories.stvStETHPoolFactory); + factoriesSection = vm.serializeAddress("factories", "stvPoolFactory", _subFactories.stvPoolFactory); + factoriesSection = vm.serializeAddress("factories", "stvStETHPoolFactory", _subFactories.stvStETHPoolFactory); factoriesSection = - vm.serializeAddress("factories", "withdrawalQueueFactory", subFactories.withdrawalQueueFactory); - factoriesSection = vm.serializeAddress("factories", "loopStrategyFactory", subFactories.loopStrategyFactory); - factoriesSection = vm.serializeAddress("factories", "ggvStrategyFactory", subFactories.ggvStrategyFactory); - factoriesSection = vm.serializeAddress("factories", "timelockFactory", subFactories.timelockFactory); + vm.serializeAddress("factories", "withdrawalQueueFactory", _subFactories.withdrawalQueueFactory); + factoriesSection = vm.serializeAddress("factories", "ggvStrategyFactory", _subFactories.ggvStrategyFactory); + factoriesSection = vm.serializeAddress("factories", "timelockFactory", _subFactories.timelockFactory); string memory out = ""; - out = vm.serializeAddress("deployment", "factory", factoryAddr); + out = vm.serializeAddress("deployment", "factory", _factoryAddr); out = vm.serializeString("deployment", "network", vm.toString(block.chainid)); string memory json = vm.serializeString("_root", "factories", factoriesSection); json = vm.serializeString("_root", "deployment", out); - vm.writeJson(json, outputJsonPath); + vm.writeJson(json, _outputJsonPath); vm.writeJson(json, "deployments/pool-factory-latest.json"); } - function _readTimelockFromJson(string memory paramsPath) - internal - view - returns (Factory.TimelockConfig memory timelockConfig) - { - require( - vm.isFile(paramsPath), string(abi.encodePacked("FACTORY_PARAMS_JSON file does not exist at: ", paramsPath)) - ); - string memory json = vm.readFile(paramsPath); - - timelockConfig.minDelaySeconds = vm.parseJsonUint(json, "$.timelock.minDelaySeconds"); - } - - function _readStrategyParamsFromJson(string memory paramsPath) + function _readGGVStrategyAddresses(string memory _paramsPath) internal view - returns (Factory.StrategyParameters memory strategyParams) + returns (address teller, address boringQueue) { require( - vm.isFile(paramsPath), string(abi.encodePacked("FACTORY_PARAMS_JSON file does not exist at: ", paramsPath)) + vm.isFile(_paramsPath), + string(abi.encodePacked("FACTORY_PARAMS_JSON file does not exist at: ", _paramsPath)) ); - string memory json = vm.readFile(paramsPath); + string memory json = vm.readFile(_paramsPath); - try vm.parseJsonAddress(json, "$.strategies.ggv.teller") returns (address teller) { - strategyParams.ggvTeller = teller; - } catch {} + teller = vm.parseJsonAddress(json, "$.strategies.ggv.teller"); + boringQueue = vm.parseJsonAddress(json, "$.strategies.ggv.boringOnChainQueue"); - try vm.parseJsonAddress(json, "$.strategies.ggv.boringOnChainQueue") returns (address queue) { - strategyParams.ggvBoringOnChainQueue = queue; - } catch {} + require(teller != address(0), "strategies.ggv.teller missing"); + require(boringQueue != address(0), "strategies.ggv.boringOnChainQueue missing"); } function run() external { @@ -96,22 +72,30 @@ contract DeployFactory is Script { // REQUIRED: CORE_LOCATOR_ADDRESS (address of Lido Locator proxy) // REQUIRED: FACTORY_PARAMS_JSON (path to config with timelock params) string memory locatorAddressStr = vm.envString("CORE_LOCATOR_ADDRESS"); + string memory paramsJsonPath = vm.envString("FACTORY_PARAMS_JSON"); + require(bytes(locatorAddressStr).length != 0, "CORE_LOCATOR_ADDRESS env var must be set and non-empty"); + require(bytes(paramsJsonPath).length != 0, "FACTORY_PARAMS_JSON env var must be set and non-empty"); + string memory outputJsonPath = string( abi.encodePacked( "deployments/pool-factory-", vm.toString(block.chainid), "-", vm.toString(block.timestamp), ".json" ) ); - string memory paramsJsonPath = vm.envString("FACTORY_PARAMS_JSON"); + address locatorAddress = vm.parseAddress(locatorAddressStr); - Factory.TimelockConfig memory timelockConfig = _readTimelockFromJson(paramsJsonPath); - Factory.StrategyParameters memory strategyParams = _readStrategyParamsFromJson(paramsJsonPath); + + (address ggvTeller, address ggvBoringQueue) = _readGGVStrategyAddresses(paramsJsonPath); + + ILidoLocator locator = ILidoLocator(locatorAddress); + address steth = address(locator.lido()); + address wsteth = address(locator.wstETH()); vm.startBroadcast(); // Deploy implementation factories and proxy stub - Factory.SubFactories memory subFactories = _deployImplFactories(); + Factory.SubFactories memory subFactories = _deployImplFactories(ggvTeller, ggvBoringQueue, steth, wsteth); - Factory factory = new Factory(locatorAddress, subFactories, timelockConfig, strategyParams); + Factory factory = new Factory(locatorAddress, subFactories); vm.stopBroadcast(); @@ -122,52 +106,4 @@ contract DeployFactory is Script { console2.log("Output written to", outputJsonPath); console2.log("Also updated", "deployments/pool-factory-latest.json"); } - - // // Optional overload to allow passing a pre-built PoolConfig and skipping internal factory deploys - // function run(string memory locatorAddressStr, Factory.SubFactories memory subFactories, Factory.TimelockConfig memory timelockConfig) external { - // address locatorAddress = vm.parseAddress(locatorAddressStr); - - // vm.startBroadcast(); - // Factory factory = new Factory(locatorAddress, subFactories, timelockConfig); - // vm.stopBroadcast(); - - // string memory outputJsonPath = string( - // abi.encodePacked( - // "deployments/pool-factory-", - // vm.toString(block.chainid), - // "-", - // vm.toString(block.timestamp), - // ".json" - // ) - // ); - // string memory out = vm.serializeAddress("deployment", "factory", address(factory)); - // vm.writeJson(out, outputJsonPath); - // vm.writeJson(out, "deployments/pool-factory-latest.json"); - // } - - // // Overload with explicit timelock configuration - // function run(string memory locatorAddressStr, Factory.PoolConfig memory cfg, Factory.TimelockConfig memory tcfg) - // external - // { - // address locatorAddress = vm.parseAddress(locatorAddressStr); - // ILidoLocator locator = ILidoLocator(locatorAddress); - // require(locator.vaultFactory() == cfg.vaultFactory, "vaultFactory mismatch"); - // require(address(locator.lido()) == cfg.steth, "stETH mismatch"); - - // vm.startBroadcast(); - // Factory factory = new Factory(cfg, tcfg); - // vm.stopBroadcast(); - - // string memory outputJsonPath = string( - // abi.encodePacked( - // "deployments/pool-factory-", - // vm.toString(block.chainid), - // "-", - // vm.toString(block.timestamp), - // ".json" - // ) - // ); - // string memory out = vm.serializeAddress("deployment", "factory", address(factory)); - // vm.writeJson(out, outputJsonPath); - // } } diff --git a/script/DeployGGVMocks.s.sol b/script/DeployGGVMocks.s.sol index 0786c4c..a33f066 100644 --- a/script/DeployGGVMocks.s.sol +++ b/script/DeployGGVMocks.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import "forge-std/Script.sol"; diff --git a/script/DeployPool.s.sol b/script/DeployPool.s.sol index 98fac80..fbc8f3b 100644 --- a/script/DeployPool.s.sol +++ b/script/DeployPool.s.sol @@ -1,213 +1,334 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import "forge-std/Script.sol"; import "forge-std/console2.sol"; import {Factory} from "src/Factory.sol"; +import {StvPool} from "src/StvPool.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; -import {IOssifiableProxy} from "src/interfaces/IOssifiableProxy.sol"; -import {IStETH} from "src/interfaces/IStETH.sol"; +import {IOssifiableProxy} from "src/interfaces/core/IOssifiableProxy.sol"; +import {IStETH} from "src/interfaces/core/IStETH.sol"; +import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; -contract DeployWrapper is Script { +contract DeployPool is Script { struct PoolParams { - address nodeOperator; - address nodeOperatorManager; - uint256 nodeOperatorFeeBP; - uint256 confirmExpiry; - uint256 maxFinalizationTime; - uint256 minWithdrawalDelayTime; - bool allowlistEnabled; - bool mintingEnabled; // required by Factory.StvPoolConfig - uint256 reserveRatioGapBP; // optional; used when minting is enabled / strategy present - address strategyFactory; // optional; if set => strategy pool - uint256 value; // msg.value to send (CONNECT_DEPOSIT) - address timelockExecutor; // optional (not used by new Factory) - string name; - string symbol; - } - - // struct DeploymentResult { - // address vault; - // address dashboard; - // address payable poolProxy; - // address withdrawalQueueProxy; - // address poolImpl; - // address withdrawalQueueImpl; - // address strategy; - // address timelockAdmin; - // address stethAddr; - // address wstethAddr; - // } - - // function _writePoolArtifact( - // Factory factoryView, - // PoolParams memory p, - // Factory.StvPoolIntermediate memory intermediate, - // string memory outputJsonPath - // ) internal { - // string memory out = vm.serializeAddress("pool", "factory", address(factoryView)); - // out = vm.serializeAddress("pool", "vault", r.vault); - // out = vm.serializeAddress("pool", "dashboard", r.dashboard); - // out = vm.serializeAddress("pool", "poolProxy", r.poolProxy); - // out = vm.serializeAddress("pool", "poolImpl", r.poolImpl); - // out = vm.serializeAddress("pool", "withdrawalQueue", r.withdrawalQueueProxy); - // out = vm.serializeAddress("pool", "withdrawalQueueImpl", r.withdrawalQueueImpl); - // out = vm.serializeUint("pool", "poolType", p.poolType); - // out = vm.serializeAddress("pool", "strategy", r.strategy); - // out = vm.serializeAddress("pool", "timelock", r.timelockAdmin); - - // // Proxy constructor args - // bytes memory poolProxyCtorArgs = abi.encode(factoryView.DUMMY_IMPLEMENTATION(), address(factoryView), bytes("")); - // out = vm.serializeBytes("pool", "poolProxyCtorArgs", poolProxyCtorArgs); - - // // WQ proxy constructor args - // bytes memory wqInitData = abi.encodeCall(WithdrawalQueue.initialize, (p.nodeOperator, p.nodeOperator)); - // bytes memory withdrawalQueueProxyCtorArgs = abi.encode(r.withdrawalQueueImpl, address(factoryView), wqInitData); - // out = vm.serializeBytes("pool", "withdrawalQueueProxyCtorArgs", withdrawalQueueProxyCtorArgs); - - // // Pool implementation constructor args - // bytes memory poolImplCtorArgs; - // if (p.poolType == uint256(Factory.PoolType.NO_MINTING_NO_STRATEGY)) { - // poolImplCtorArgs = abi.encode(r.dashboard, p.allowlistEnabled, r.withdrawalQueueProxy); - // } else if (p.poolType == uint256(Factory.PoolType.MINTING_NO_STRATEGY)) { - // poolImplCtorArgs = abi.encode(r.dashboard, r.stethAddr, p.allowlistEnabled, p.reserveRatioGapBP, r.withdrawalQueueProxy); - // } else if (p.poolType == uint256(Factory.PoolType.LOOP_STRATEGY)) { - // poolImplCtorArgs = abi.encode(r.dashboard, r.stethAddr, p.allowlistEnabled, r.strategy, p.reserveRatioGapBP, r.withdrawalQueueProxy); - // } else { - // // GGV - // poolImplCtorArgs = abi.encode(r.dashboard, r.stethAddr, p.allowlistEnabled, r.strategy, p.reserveRatioGapBP, r.withdrawalQueueProxy); - // } - // out = vm.serializeBytes("pool", "poolImplCtorArgs", poolImplCtorArgs); - - // // WQ implementation constructor args - // bytes memory withdrawalQueueImplCtorArgs = abi.encode(r.poolProxy, factoryView.LAZY_ORACLE(), p.maxFinalizationTime, p.minWithdrawalDelayTime); - // out = vm.serializeBytes("pool", "withdrawalQueueImplCtorArgs", withdrawalQueueImplCtorArgs); - - // // Strategy constructor args (if any) - // if (r.strategy != address(0)) { - // address strategyProxyImpl = address(0); - // (bool okSpi, bytes memory retSpi) = r.strategy.staticcall(abi.encodeWithSignature("STRATEGY_PROXY_IMPL()")); - // if (okSpi && retSpi.length >= 32) { - // strategyProxyImpl = abi.decode(retSpi, (address)); - // } - // bytes memory strategyCtorArgs = abi.encode(strategyProxyImpl, r.poolProxy, r.stethAddr, r.wstethAddr, p.teller, p.boringQueue); - // out = vm.serializeBytes("pool", "strategyCtorArgs", strategyCtorArgs); - // } - - // vm.writeJson(out, outputJsonPath); - // } - - function _readFactoryAddress(string memory path) internal view returns (address factory) { - string memory json = vm.readFile(path); - // The deployment artifact should contain { deployment: { factory: "0x..." } } - factory = vm.parseJsonAddress(json, "$.deployment.factory"); - require(factory != address(0), "factory not found"); - } - - function _readPoolParams(string memory path) internal view returns (PoolParams memory p) { - string memory json = vm.readFile(path); - p.nodeOperator = vm.parseJsonAddress(json, "$.nodeOperator"); - p.nodeOperatorManager = vm.parseJsonAddress(json, "$.nodeOperatorManager"); - p.nodeOperatorFeeBP = vm.parseJsonUint(json, "$.nodeOperatorFeeBP"); - p.confirmExpiry = vm.parseJsonUint(json, "$.confirmExpiry"); - p.maxFinalizationTime = vm.parseJsonUint(json, "$.maxFinalizationTime"); - p.minWithdrawalDelayTime = vm.parseJsonUint(json, "$.minWithdrawalDelayTime"); - p.allowlistEnabled = vm.parseJsonBool(json, "$.allowlistEnabled"); - p.value = vm.parseJsonUint(json, "$.connectDepositWei"); - - // Parse only fields relevant to the pool type - // Optional: explicit mintingEnabled in JSON, else derive later - try vm.parseJsonBool(json, "$.mintingEnabled") returns (bool me) { - p.mintingEnabled = me; - } catch {} - - // Reserve ratio gap (optional); if set or strategy present, Factory will treat as minting-enabled - try vm.parseJsonUint(json, "$.reserveRatioGapBP") returns (uint256 rr) { - p.reserveRatioGapBP = rr; - } catch {} - - // Strategy-specific params (optional) - try vm.parseJsonAddress(json, "$.strategy.factory") returns (address sf) { - p.strategyFactory = sf; - } catch {} - - // Optional legacy field - try vm.parseJsonAddress(json, "$.timelock.executor") returns (address ex) { - p.timelockExecutor = ex; - } catch {} - - try vm.parseJsonString(json, "$.token.name") returns (string memory tokenName) { - p.name = tokenName; - } catch {} - - try vm.parseJsonString(json, "$.token.symbol") returns (string memory tokenSymbol) { - p.symbol = tokenSymbol; - } catch {} + Factory.VaultConfig vaultConfig; + Factory.CommonPoolConfig commonPoolConfig; + Factory.AuxiliaryPoolConfig auxiliaryPoolConfig; + Factory.TimelockConfig timelockConfig; + address strategyFactory; + uint256 connectDepositWei; } - function run() external { - string memory factoryJsonPath = "deployments/pool-factory-latest.json"; - string memory paramsJsonPath = vm.envString("POOL_PARAMS_JSON"); + function _readPoolParams(string memory _path) internal view returns (PoolParams memory p) { + string memory json = vm.readFile(_path); + p.vaultConfig = Factory.VaultConfig({ + nodeOperator: vm.parseJsonAddress(json, "$.vaultConfig.nodeOperator"), + nodeOperatorManager: vm.parseJsonAddress(json, "$.vaultConfig.nodeOperatorManager"), + nodeOperatorFeeBP: vm.parseJsonUint(json, "$.vaultConfig.nodeOperatorFeeBP"), + confirmExpiry: vm.parseJsonUint(json, "$.vaultConfig.confirmExpiry") + }); + + p.commonPoolConfig = Factory.CommonPoolConfig({ + minWithdrawalDelayTime: vm.parseJsonUint(json, "$.commonPoolConfig.minWithdrawalDelayTime"), + name: vm.parseJsonString(json, "$.commonPoolConfig.name"), + symbol: vm.parseJsonString(json, "$.commonPoolConfig.symbol") + }); + + p.auxiliaryPoolConfig = Factory.AuxiliaryPoolConfig({ + allowlistEnabled: vm.parseJsonBool(json, "$.auxiliaryPoolConfig.allowlistEnabled"), + mintingEnabled: vm.parseJsonBool(json, "$.auxiliaryPoolConfig.mintingEnabled"), + reserveRatioGapBP: vm.parseJsonUint(json, "$.auxiliaryPoolConfig.reserveRatioGapBP") + }); + + p.timelockConfig = Factory.TimelockConfig({ + minDelaySeconds: vm.parseJsonUint(json, "$.timelockConfig.minDelaySeconds"), + proposer: vm.parseJsonAddress(json, "$.timelockConfig.proposer"), + executor: vm.parseJsonAddress(json, "$.timelockConfig.executor") + }); + + p.connectDepositWei = vm.parseJsonUint(json, "$.connectDepositWei"); + + try vm.parseJsonAddress(json, "$.strategyFactory") returns (address addr) { + p.strategyFactory = addr; + } catch { + // Leave p.strategyFactory as default (address(0)) + } + } - // string memory outputJsonPath = vm.envString("WRAPPER_DEPLOYED_JSON"); - string memory outputJsonPath = string( + function _buildOutputPath() internal view returns (string memory) { + return string( abi.encodePacked( "deployments/pool-", vm.toString(block.chainid), "-", vm.toString(block.timestamp), ".json" ) ); + } + + function _serializeVaultConfig(Factory.VaultConfig memory _cfg) internal returns (string memory json) { + json = vm.serializeAddress("_vaultConfig", "nodeOperator", _cfg.nodeOperator); + json = vm.serializeAddress("_vaultConfig", "nodeOperatorManager", _cfg.nodeOperatorManager); + json = vm.serializeUint("_vaultConfig", "nodeOperatorFeeBP", _cfg.nodeOperatorFeeBP); + json = vm.serializeUint("_vaultConfig", "confirmExpiry", _cfg.confirmExpiry); + } + + function _serializeCommonPoolConfig(Factory.CommonPoolConfig memory _cfg) internal returns (string memory json) { + json = vm.serializeUint("_commonPoolConfig", "minWithdrawalDelayTime", _cfg.minWithdrawalDelayTime); + json = vm.serializeString("_commonPoolConfig", "name", _cfg.name); + json = vm.serializeString("_commonPoolConfig", "symbol", _cfg.symbol); + } + + function _serializeAuxiliaryPoolConfig(Factory.AuxiliaryPoolConfig memory _cfg) + internal + returns (string memory json) + { + json = vm.serializeBool("_auxiliaryPoolConfig", "allowlistEnabled", _cfg.allowlistEnabled); + json = vm.serializeBool("_auxiliaryPoolConfig", "mintingEnabled", _cfg.mintingEnabled); + json = vm.serializeUint("_auxiliaryPoolConfig", "reserveRatioGapBP", _cfg.reserveRatioGapBP); + } + + function _serializeTimelockConfig(Factory.TimelockConfig memory _cfg) internal returns (string memory json) { + json = vm.serializeUint("_timelockConfig", "minDelaySeconds", _cfg.minDelaySeconds); + json = vm.serializeAddress("_timelockConfig", "proposer", _cfg.proposer); + json = vm.serializeAddress("_timelockConfig", "executor", _cfg.executor); + } + + function _serializeConfig(PoolParams memory _p) internal returns (string memory json) { + string memory vaultJson = _serializeVaultConfig(_p.vaultConfig); + string memory commonJson = _serializeCommonPoolConfig(_p.commonPoolConfig); + string memory auxiliaryJson = _serializeAuxiliaryPoolConfig(_p.auxiliaryPoolConfig); + string memory timelockJson = _serializeTimelockConfig(_p.timelockConfig); + + json = vm.serializeString("_deployConfig", "vaultConfig", vaultJson); + json = vm.serializeString("_deployConfig", "commonPoolConfig", commonJson); + json = vm.serializeString("_deployConfig", "auxiliaryPoolConfig", auxiliaryJson); + json = vm.serializeString("_deployConfig", "timelockConfig", timelockJson); + json = vm.serializeAddress("_deployConfig", "strategyFactory", _p.strategyFactory); + json = vm.serializeUint("_deployConfig", "connectDepositWei", _p.connectDepositWei); + } + + function _serializeIntermediate(Factory.PoolIntermediate memory _intermediate) + internal + returns (string memory json) + { + json = vm.serializeAddress("_intermediate", "pool", _intermediate.pool); + json = vm.serializeAddress("_intermediate", "timelock", _intermediate.timelock); + json = vm.serializeAddress("_intermediate", "strategyFactory", _intermediate.strategyFactory); + json = vm.serializeBytes("_intermediate", "strategyDeployBytes", _intermediate.strategyDeployBytes); + } + + function _serializeDeployment(Factory.PoolDeployment memory _deployment) internal returns (string memory json) { + json = vm.serializeAddress("_deployment", "vault", _deployment.vault); + json = vm.serializeAddress("_deployment", "dashboard", _deployment.dashboard); + json = vm.serializeAddress("_deployment", "pool", _deployment.pool); + json = vm.serializeAddress("_deployment", "withdrawalQueue", _deployment.withdrawalQueue); + json = vm.serializeAddress("_deployment", "distributor", _deployment.distributor); + json = vm.serializeAddress("_deployment", "timelock", _deployment.timelock); + json = vm.serializeAddress("_deployment", "strategy", _deployment.strategy); + } + + function _serializeCtorBytecode( + Factory _factory, + Factory.PoolIntermediate memory _intermediate, + Factory.VaultConfig memory _vaultConfig, + Factory.AuxiliaryPoolConfig memory _auxiliaryConfig, + bytes32 _poolType + ) internal returns (string memory json) { + StvPool pool = StvPool(payable(_intermediate.pool)); + address dashboard = address(pool.DASHBOARD()); + address withdrawalQueue = address(pool.WITHDRAWAL_QUEUE()); + address distributor = address(pool.DISTRIBUTOR()); + + bytes memory poolCtorBytecode = abi.encodePacked( + type(OssifiableProxy).creationCode, + abi.encode(_factory.DUMMY_IMPLEMENTATION(), address(_factory), bytes("")) + ); + + bytes memory poolImplementationCtorBytecode; + if (_poolType == _factory.STV_POOL_TYPE()) { + poolImplementationCtorBytecode = abi.encodePacked( + type(StvPool).creationCode, + abi.encode(dashboard, _auxiliaryConfig.allowlistEnabled, withdrawalQueue, distributor) + ); + } else { + poolImplementationCtorBytecode = abi.encodePacked( + type(StvStETHPool).creationCode, + abi.encode( + dashboard, + _auxiliaryConfig.allowlistEnabled, + _auxiliaryConfig.reserveRatioGapBP, + withdrawalQueue, + distributor, + _poolType + ) + ); + } + + address withdrawalImpl = IOssifiableProxy(withdrawalQueue).proxy__getImplementation(); + bytes memory withdrawalInitData = + abi.encodeCall(WithdrawalQueue.initialize, (_vaultConfig.nodeOperatorManager, _vaultConfig.nodeOperator)); + bytes memory withdrawalCtorBytecode = abi.encodePacked( + type(OssifiableProxy).creationCode, abi.encode(withdrawalImpl, _intermediate.timelock, withdrawalInitData) + ); + + json = vm.serializeBytes("_ctorBytecode", "poolProxy", poolCtorBytecode); + json = vm.serializeBytes("_ctorBytecode", "poolImplementation", poolImplementationCtorBytecode); + json = vm.serializeBytes("_ctorBytecode", "withdrawalQueueProxy", withdrawalCtorBytecode); + } + + function _loadIntermediate(string memory _path) internal view returns (Factory.PoolIntermediate memory) { + string memory json = vm.readFile(_path); + return Factory.PoolIntermediate({ + pool: vm.parseJsonAddress(json, "$.intermediate.pool"), + timelock: vm.parseJsonAddress(json, "$.intermediate.timelock"), + strategyFactory: vm.parseJsonAddress(json, "$.intermediate.strategyFactory"), + strategyDeployBytes: vm.parseJsonBytes(json, "$.intermediate.strategyDeployBytes") + }); + } + + function _loadPoolParams(string memory _path) internal view returns (PoolParams memory) { + string memory json = vm.readFile(_path); + return PoolParams({ + vaultConfig: Factory.VaultConfig({ + nodeOperator: vm.parseJsonAddress(json, "$.config.vaultConfig.nodeOperator"), + nodeOperatorManager: vm.parseJsonAddress(json, "$.config.vaultConfig.nodeOperatorManager"), + nodeOperatorFeeBP: vm.parseJsonUint(json, "$.config.vaultConfig.nodeOperatorFeeBP"), + confirmExpiry: vm.parseJsonUint(json, "$.config.vaultConfig.confirmExpiry") + }), + commonPoolConfig: Factory.CommonPoolConfig({ + minWithdrawalDelayTime: vm.parseJsonUint(json, "$.config.commonPoolConfig.minWithdrawalDelayTime"), + name: vm.parseJsonString(json, "$.config.commonPoolConfig.name"), + symbol: vm.parseJsonString(json, "$.config.commonPoolConfig.symbol") + }), + auxiliaryPoolConfig: Factory.AuxiliaryPoolConfig({ + allowlistEnabled: vm.parseJsonBool(json, "$.config.auxiliaryPoolConfig.allowlistEnabled"), + mintingEnabled: vm.parseJsonBool(json, "$.config.auxiliaryPoolConfig.mintingEnabled"), + reserveRatioGapBP: vm.parseJsonUint(json, "$.config.auxiliaryPoolConfig.reserveRatioGapBP") + }), + timelockConfig: Factory.TimelockConfig({ + minDelaySeconds: vm.parseJsonUint(json, "$.config.timelockConfig.minDelaySeconds"), + proposer: vm.parseJsonAddress(json, "$.config.timelockConfig.proposer"), + executor: vm.parseJsonAddress(json, "$.config.timelockConfig.executor") + }), + strategyFactory: vm.parseJsonAddress(json, "$.config.strategyFactory"), + connectDepositWei: vm.parseJsonUint(json, "$.config.connectDepositWei") + }); + } + + function run() external { + string memory factoryAddress = vm.envString("FACTORY_ADDRESS"); + string memory deployMode = vm.envOr("DEPLOY_MODE", string("")); - require(bytes(paramsJsonPath).length != 0, "WRAPPER_PARAMS_JSON env var must be set and non-empty"); - require(bytes(outputJsonPath).length != 0, "WRAPPER_DEPLOYED_JSON env var must be set and non-empty"); + require(bytes(factoryAddress).length != 0, "FACTORY_ADDRESS env var must be set and non-empty"); + Factory factory = Factory(vm.parseAddress(factoryAddress)); - require(vm.isFile(factoryJsonPath), "deployments/pool-factory-latest.json file not found"); + string memory intermediateJsonPath = vm.envOr("INTERMEDIATE_JSON", _buildOutputPath()); + + if (keccak256(bytes(deployMode)) == keccak256(bytes("start"))) { + _runStart(factory, intermediateJsonPath); + } else if (keccak256(bytes(deployMode)) == keccak256(bytes("finish"))) { + _runFinish(factory, intermediateJsonPath); + } else { + _runStart(factory, intermediateJsonPath); + _runFinish(factory, intermediateJsonPath); + } + } + + function _runStart(Factory _factory, string memory _intermediateJsonPath) internal { + require( + !vm.isFile(_intermediateJsonPath), + string(abi.encodePacked("Intermediate JSON file already exists at: ", _intermediateJsonPath)) + ); + + string memory paramsJsonPath = vm.envString("POOL_PARAMS_JSON"); + require(bytes(paramsJsonPath).length != 0, "POOL_PARAMS_JSON env var must be set and non-empty"); if (!vm.isFile(paramsJsonPath)) { - revert(string(abi.encodePacked("WRAPPER_PARAMS_JSON file does not exist at: ", paramsJsonPath))); + revert(string(abi.encodePacked("POOL_PARAMS_JSON file does not exist at: ", paramsJsonPath))); } - Factory factory = Factory(_readFactoryAddress(factoryJsonPath)); - PoolParams memory p = _readPoolParams(paramsJsonPath); + require(msg.sender.balance > 1 ether, "msg.sender balance must be above 1 ether"); - require(bytes(p.name).length != 0, "token.name missing"); - require(bytes(p.symbol).length != 0, "token.symbol missing"); + PoolParams memory p = _readPoolParams(paramsJsonPath); - // Check Lido total shares before broadcasting - // uint256 totalShares = IStETH(factory.STETH()).getTotalShares(); - // console2.log("Lido getTotalShares:", totalShares); - // require(totalShares > 100000, "Lido totalShares must be > 100000"); + require(bytes(p.commonPoolConfig.name).length != 0, "commonPoolConfig.name missing"); + require(bytes(p.commonPoolConfig.symbol).length != 0, "commonPoolConfig.symbol missing"); + require(p.connectDepositWei > 0, "connectDepositWei missing"); vm.startBroadcast(); - Factory.StrategyConfig memory strategyConfig = Factory.StrategyConfig({factory: p.strategyFactory}); - - Factory.StvPoolIntermediate memory intermediate = factory.createPoolStart{value: p.value}( - Factory.PoolFullConfig({ - allowlistEnabled: p.allowlistEnabled, - mintingEnabled: p.mintingEnabled, - owner: p.nodeOperator, - nodeOperator: p.nodeOperator, - nodeOperatorManager: p.nodeOperatorManager, - nodeOperatorFeeBP: p.nodeOperatorFeeBP, - confirmExpiry: p.confirmExpiry, - maxFinalizationTime: p.maxFinalizationTime, - minWithdrawalDelayTime: p.minWithdrawalDelayTime, - reserveRatioGapBP: p.reserveRatioGapBP, - name: p.name, - symbol: p.symbol - }), - strategyConfig + Factory.PoolIntermediate memory intermediate = _factory.createPoolStart{value: p.connectDepositWei}( + p.vaultConfig, p.commonPoolConfig, p.auxiliaryPoolConfig, p.timelockConfig, p.strategyFactory, "" ); - Factory.StvPoolDeployment memory deployment = factory.createPoolFinish(intermediate, strategyConfig); + vm.stopBroadcast(); + + console2.log("Intermediate:"); + console2.log(" pool:", intermediate.pool); + console2.log(" timelock:", intermediate.timelock); + console2.log(" strategyFactory:", intermediate.strategyFactory); + + // Save config and intermediate to output file + string memory configJson = _serializeConfig(p); + string memory intermediateJson = _serializeIntermediate(intermediate); + + string memory rootJson = vm.serializeString("_deploy", "config", configJson); + rootJson = vm.serializeString("_deploy", "intermediate", intermediateJson); + + vm.writeJson(rootJson, _intermediateJsonPath); + console2.log("\nDeployment intermediate saved to:", _intermediateJsonPath); + } + + function _runFinish(Factory _factory, string memory _intermediateJsonPath) internal { + require(bytes(_intermediateJsonPath).length != 0, "INTERMEDIATE_JSON env var must be set and non-empty"); + if (!vm.isFile(_intermediateJsonPath)) { + revert(string(abi.encodePacked("INTERMEDIATE_JSON file does not exist at: ", _intermediateJsonPath))); + } - console2.log("Deployment Vault", deployment.vault); - console2.log("Deployment Dashboard", deployment.dashboard); - console2.log("Deployment Pool", deployment.pool); - console2.log("Deployment WithdrawalQueue", deployment.withdrawalQueue); - console2.log("Deployment Distributor", deployment.distributor); - console2.log("Deployment Timelock", deployment.timelock); - console2.log("Deployment PoolType", uint256(deployment.poolType)); - console2.log("Strategy", deployment.strategy); + Factory.PoolIntermediate memory intermediate = _loadIntermediate(_intermediateJsonPath); + PoolParams memory p = _loadPoolParams(_intermediateJsonPath); + + vm.startBroadcast(); + + _factory.createPoolFinish(intermediate); vm.stopBroadcast(); + + console2.log("Deploy config:"); + console2.log(" name:", p.commonPoolConfig.name); + console2.log(" symbol:", p.commonPoolConfig.symbol); + console2.log(" allowlistEnabled:", p.auxiliaryPoolConfig.allowlistEnabled); + console2.log(" mintingEnabled:", p.auxiliaryPoolConfig.mintingEnabled); + console2.log(" owner:", p.vaultConfig.nodeOperator); + console2.log(" nodeOperator:", p.vaultConfig.nodeOperator); + console2.log(" nodeOperatorManager:", p.vaultConfig.nodeOperatorManager); + console2.log(" nodeOperatorFeeBP:", p.vaultConfig.nodeOperatorFeeBP); + console2.log(" confirmExpiry:", p.vaultConfig.confirmExpiry); + console2.log(" minWithdrawalDelayTime:", p.commonPoolConfig.minWithdrawalDelayTime); + console2.log(" reserveRatioGapBP:", p.auxiliaryPoolConfig.reserveRatioGapBP); + console2.log(" strategyFactory:", p.strategyFactory); + console2.log(" connectDepositWei:", p.connectDepositWei); + + // console2.log("\nDeployment addresses:"); + // console2.log(" Vault:", deployment.vault); + // console2.log(" Dashboard:", deployment.dashboard); + // console2.log(" Pool:", deployment.pool); + // console2.log(" WithdrawalQueue:", deployment.withdrawalQueue); + // console2.log(" Distributor:", deployment.distributor); + // console2.log(" Timelock:", deployment.timelock); + // console2.log(" Strategy:", deployment.strategy); + + // // Read existing intermediate file and update with deployment + // string memory configJson = _serializeConfig(p); + // string memory intermediateJson = _serializeIntermediate(intermediate); + // string memory deploymentJson = _serializeDeployment(deployment); + // string memory ctorJson = _serializeCtorBytecode(_factory, intermediate, p.vaultConfig, p.auxiliaryPoolConfig, poolType); + + // string memory rootJson = vm.serializeString("_deploy", "config", configJson); + // rootJson = vm.serializeString("_deploy", "intermediate", intermediateJson); + // rootJson = vm.serializeString("_deploy", "deployment", deploymentJson); + // rootJson = vm.serializeString("_deploy", "ctorBytecode", ctorJson); + + // vm.writeJson(rootJson, _intermediateJsonPath); + // console2.log("\nDeployment completed and saved to:", _intermediateJsonPath); } } diff --git a/script/HarnessCore.s.sol b/script/HarnessCore.s.sol index 341656b..0af77dd 100644 --- a/script/HarnessCore.s.sol +++ b/script/HarnessCore.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import "forge-std/Script.sol"; @@ -168,14 +168,11 @@ contract HarnessCore is Script { return (true, abi.decode(ret, (address))); } - function _arr6( - string memory a, - string memory b, - string memory c, - string memory d, - string memory e, - string memory f - ) private pure returns (string[] memory r) { + function _arr6(string memory a, string memory b, string memory c, string memory d, string memory e, string memory f) + private + pure + returns (string[] memory r) + { r = new string[](6); r[0] = a; r[1] = b; diff --git a/script/factory-deploy-config-hoodi.json b/script/factory-deploy-config-hoodi.json deleted file mode 100644 index 5f9dd70..0000000 --- a/script/factory-deploy-config-hoodi.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "timelock": { - "minDelaySeconds": 300 - }, - "strategies": { - "ggv": { - "teller": "0x0Da4DF9e9C8227964d862e772F6ba409dd52964d", - "boringOnChainQueue": "0x0909EB1dc30ee60CFa9AbCE83673AB995110ac38" - } - } -} diff --git a/script/generate-interfaces.sh b/script/generate-interfaces.sh index a3694ae..34e6ce2 100755 --- a/script/generate-interfaces.sh +++ b/script/generate-interfaces.sh @@ -117,7 +117,7 @@ errors = [render_error(i) for i in abi if i.get('type') == 'error'] lines = [] lines.append('// SPDX-License-Identifier: MIT') -lines.append('pragma solidity >=0.8.25;') +lines.append('pragma solidity 0.8.30;') lines.append('') lines.append(f'interface I{name} {{') for e in events: diff --git a/script/stv-ggv-pool-deploy-config-hoodi.json b/script/stv-ggv-pool-deploy-config-hoodi.json deleted file mode 100644 index e6cdf49..0000000 --- a/script/stv-ggv-pool-deploy-config-hoodi.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "token": { - "name": "Staked ETH GGV Pool", - "symbol": "STV" - }, - "nodeOperator": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", - "nodeOperatorManager": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", - "nodeOperatorFeeBP": 10, - "confirmExpiry": 3600, - "maxFinalizationTime": 2592000, - "minWithdrawalDelayTime": 180, - "allowlistEnabled": true, - "reserveRatioGapBP": 1000, - "strategy": { - "factory": "0x0000000000000000000000000000000000000000" - }, - "connectDepositWei": 1000000000000000000, - "timelock": { - "executor": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2" - } -} diff --git a/script/stv-pool-deploy-config-hoodi.json b/script/stv-pool-deploy-config-hoodi.json deleted file mode 100644 index 2fc5bbd..0000000 --- a/script/stv-pool-deploy-config-hoodi.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "token": { - "name": "Staked ETH Pool", - "symbol": "STV" - }, - "nodeOperator": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", - "nodeOperatorManager": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", - "nodeOperatorFeeBP": 10, - "confirmExpiry": 3600, - "maxFinalizationTime": 2592000, - "minWithdrawalDelayTime": 180, - "allowlistEnabled": false, - "connectDepositWei": 1000000000000000000, - "reserveRatioGapBP": 0, - "timelock": { - "executor": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2" - } -} diff --git a/script/stv-steth-pool-deploy-config-hoodi.json b/script/stv-steth-pool-deploy-config-hoodi.json deleted file mode 100644 index 2747520..0000000 --- a/script/stv-steth-pool-deploy-config-hoodi.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "token": { - "name": "Staked ETH StETH Pool", - "symbol": "STV" - }, - "nodeOperator": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", - "nodeOperatorManager": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2", - "nodeOperatorFeeBP": 10, - "confirmExpiry": 3601, - "maxFinalizationTime": 2592000, - "minWithdrawalDelayTime": 180, - "allowlistEnabled": false, - "reserveRatioGapBP": 1000, - "connectDepositWei": 1000000000000000000, - "timelock": { - "executor": "0xcc95b0Dbe1519AfaC58d954444839A80700380a2" - } -} diff --git a/script/utils/Json.sol b/script/utils/Json.sol index d46c580..00f1e58 100644 --- a/script/utils/Json.sol +++ b/script/utils/Json.sol @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Lido // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Vm} from "forge-std/Vm.sol"; diff --git a/src/AllowList.sol b/src/AllowList.sol index d8dda88..646ec1d 100644 --- a/src/AllowList.sol +++ b/src/AllowList.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import { AccessControlEnumerableUpgradeable diff --git a/src/Distributor.sol b/src/Distributor.sol index 2b3c558..0fb98c7 100644 --- a/src/Distributor.sol +++ b/src/Distributor.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {AccessControlEnumerable} from "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -32,7 +32,7 @@ contract Distributor is AccessControlEnumerable { event TokenAdded(address indexed token); event Claimed(address indexed recipient, address indexed token, uint256 amount); event MerkleRootUpdated( - bytes32 oldRoot, bytes32 newRoot, string oldCid, string newCid, uint256 oldBlock, uint256 newBlock + bytes32 oldRoot, bytes32 indexed newRoot, string oldCid, string newCid, uint256 oldBlock, uint256 newBlock ); // ==================== Errors ==================== @@ -42,9 +42,13 @@ contract Distributor is AccessControlEnumerable { error RootNotSet(); error TokenAlreadyAdded(address token); error ZeroAddress(); + error TokenNotSupported(address token); - /// @param _owner The address of the owner (admin) - /// @param _manager The address of the manager (MANAGER_ROLE) + /** + * @notice Constructor + * @param _owner The address of the owner (admin) + * @param _manager The address of the manager (MANAGER_ROLE) + */ constructor(address _owner, address _manager) { lastProcessedBlock = block.number; @@ -52,8 +56,10 @@ contract Distributor is AccessControlEnumerable { _grantRole(MANAGER_ROLE, _manager); } - /// @notice Add a token to the list of supported tokens - /// @param token The address of the token to add + /** + * @notice Add a token to the list of supported tokens + * @param token The address of the token to add + */ function addToken(address token) external { _checkRole(MANAGER_ROLE, msg.sender); if (token == address(0)) revert ZeroAddress(); @@ -64,18 +70,22 @@ contract Distributor is AccessControlEnumerable { emit TokenAdded(token); } - /// @notice Get the list of supported tokens - /// @return tokens The list of supported tokens + /** + * @notice Get the list of supported tokens + * @return tokens The list of supported tokens + */ function getTokens() external view returns (address[] memory) { return tokens.values(); } - /// @notice Sets the Merkle root and CID - /// @param _root The new Merkle root - /// @param _cid The new CID + /** + * @notice Sets the Merkle root and CID + * @param _root The new Merkle root + * @param _cid The new CID + */ function setMerkleRoot(bytes32 _root, string calldata _cid) external { _checkRole(MANAGER_ROLE, msg.sender); - if (_root == root || keccak256(bytes(_cid)) == keccak256(bytes(cid))) revert AlreadyProcessed(); + if (_root == root && keccak256(bytes(_cid)) == keccak256(bytes(cid))) revert AlreadyProcessed(); emit MerkleRootUpdated(root, _root, cid, _cid, lastProcessedBlock, block.number); @@ -84,30 +94,53 @@ contract Distributor is AccessControlEnumerable { lastProcessedBlock = block.number; } - /// @notice Claims rewards. - /// @param _recipient The address to claim rewards for. - /// @param _token The address of the reward token. - /// @param _amount The overall claimable amount of token rewards. - /// @param _proof The merkle proof that validates this claim. - /// @return claimedAmount The amount of reward token claimed. - /// @dev Anyone can claim rewards on behalf of an account. - function claim(address _recipient, address _token, uint256 _amount, bytes32[] calldata _proof) - external - returns (uint256 claimedAmount) + /** + * @notice Preview the amount of tokens that can be claimed + * @param _recipient The address to claim rewards for. + * @param _token The address of the reward token. + * @param _cumulativeAmount The overall claimable amount of token rewards. + * @param _proof The merkle proof that validates this claim. + * @return claimable The amount of tokens that can be claimed. + */ + function previewClaim(address _recipient, address _token, uint256 _cumulativeAmount, bytes32[] calldata _proof) + public + view + returns (uint256 claimable) { if (root == bytes32(0)) revert RootNotSet(); + if (!tokens.contains(_token)) revert TokenNotSupported(_token); + if (!MerkleProof.verifyCalldata( - _proof, root, keccak256(bytes.concat(keccak256(abi.encode(_recipient, _token, _amount)))) + _proof, root, keccak256(bytes.concat(keccak256(abi.encode(_recipient, _token, _cumulativeAmount)))) )) revert InvalidProof(); - if (_amount <= claimed[_recipient][_token]) revert ClaimableTooLow(); + uint256 alreadyClaimed = claimed[_recipient][_token]; + if (_cumulativeAmount <= alreadyClaimed) return 0; - claimedAmount = _amount - claimed[_recipient][_token]; + unchecked { + claimable = _cumulativeAmount - alreadyClaimed; + } + } - claimed[_recipient][_token] = _amount; + /** + * @notice Claims rewards. + * @dev Anyone can claim rewards on behalf of an account. + * + * @param _recipient The address to claim rewards for. + * @param _token The address of the reward token. + * @param _cumulativeAmount The overall claimable amount of token rewards. + * @param _proof The merkle proof that validates this claim. + * @return claimedAmount The amount of reward token claimed. + */ + function claim(address _recipient, address _token, uint256 _cumulativeAmount, bytes32[] calldata _proof) + external + returns (uint256 claimedAmount) + { + claimedAmount = previewClaim(_recipient, _token, _cumulativeAmount, _proof); + if (claimedAmount == 0) revert ClaimableTooLow(); + claimed[_recipient][_token] = _cumulativeAmount; IERC20(_token).safeTransfer(_recipient, claimedAmount); - emit Claimed(_recipient, _token, claimedAmount); } } diff --git a/src/Factory.sol b/src/Factory.sol index c9c9625..311cc78 100644 --- a/src/Factory.sol +++ b/src/Factory.sol @@ -1,158 +1,122 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Distributor} from "./Distributor.sol"; import {StvPool} from "./StvPool.sol"; import {WithdrawalQueue} from "./WithdrawalQueue.sol"; import {DistributorFactory} from "./factories/DistributorFactory.sol"; import {GGVStrategyFactory} from "./factories/GGVStrategyFactory.sol"; -import {LoopStrategyFactory} from "./factories/LoopStrategyFactory.sol"; import {StvPoolFactory} from "./factories/StvPoolFactory.sol"; import {StvStETHPoolFactory} from "./factories/StvStETHPoolFactory.sol"; import {TimelockFactory} from "./factories/TimelockFactory.sol"; import {WithdrawalQueueFactory} from "./factories/WithdrawalQueueFactory.sol"; -import {ILidoLocator} from "./interfaces/ILidoLocator.sol"; +import {IStrategy} from "./interfaces/IStrategy.sol"; import {IStrategyFactory} from "./interfaces/IStrategyFactory.sol"; -import {IVaultHub} from "./interfaces/IVaultHub.sol"; +import {ILidoLocator} from "./interfaces/core/ILidoLocator.sol"; +import {IVaultHub} from "./interfaces/core/IVaultHub.sol"; import {DummyImplementation} from "./proxy/DummyImplementation.sol"; import {OssifiableProxy} from "./proxy/OssifiableProxy.sol"; import {WithdrawalQueue} from "./WithdrawalQueue.sol"; -import {IDashboard} from "./interfaces/IDashboard.sol"; -import {IVaultFactory} from "./interfaces/IVaultFactory.sol"; - -error InvalidConfiguration(); -error InsufficientConnectDeposit(uint256 required, uint256 provided); +import {IDashboard} from "./interfaces/core/IDashboard.sol"; +import {IVaultFactory} from "./interfaces/core/IVaultFactory.sol"; +/// @title Factory +/// @notice Main factory contract for deploying complete pool ecosystems with vaults, withdrawal queues, distributors, etc +/// @dev Implements a two-phase deployment process (start/finish) to ensure robust setup of all components and roles contract Factory { + // + // Structs + // + + /// @notice Addresses of all sub-factory contracts used for deploying components + /// @param stvPoolFactory Factory for deploying StvPool implementations + /// @param stvStETHPoolFactory Factory for deploying StvStETHPool implementations + /// @param withdrawalQueueFactory Factory for deploying WithdrawalQueue implementations + /// @param distributorFactory Factory for deploying Distributor implementations + /// @param ggvStrategyFactory Factory for deploying GGV strategy implementations + /// @param timelockFactory Factory for deploying Timelock controllers struct SubFactories { address stvPoolFactory; address stvStETHPoolFactory; address withdrawalQueueFactory; address distributorFactory; - address loopStrategyFactory; address ggvStrategyFactory; address timelockFactory; } + /// @notice Configuration parameters for vault creation + /// @param nodeOperator Address of the node operator managing the vault + /// @param nodeOperatorManager Address authorized to manage node operator settings + /// @param nodeOperatorFeeBP Node operator fee in basis points (1 BP = 0.01%) + /// @param confirmExpiry Time period for confirmation expiry + struct VaultConfig { + address nodeOperator; + address nodeOperatorManager; + uint256 nodeOperatorFeeBP; + uint256 confirmExpiry; + } + + /// @notice Configuration for timelock controller deployment + /// @param minDelaySeconds Minimum delay before executing queued operations + /// @param proposer Address authorized to propose operations + /// @param executor Address authorized to execute operations struct TimelockConfig { uint256 minDelaySeconds; + address proposer; address executor; } - struct StrategyParameters { - address ggvTeller; - address ggvBoringOnChainQueue; - } - - enum PoolType { - STV, - STV_STETH, - STRATEGY - } - - IVaultFactory public immutable VAULT_FACTORY; - IVaultHub public immutable VAULT_HUB; - address public immutable STETH; - address public immutable WSTETH; - address public immutable LAZY_ORACLE; - - bytes32 public immutable STV_POOL_TYPE = keccak256("StvPool"); - bytes32 public immutable STV_STETH_POOL_TYPE = keccak256("StvStETHPool"); - bytes32 public immutable STRATEGY_POOL_TYPE = keccak256("StvStrategyPool"); - - StvPoolFactory public immutable STV_POOL_FACTORY; - StvStETHPoolFactory public immutable STV_STETH_POOL_FACTORY; - WithdrawalQueueFactory public immutable WITHDRAWAL_QUEUE_FACTORY; - DistributorFactory public immutable DISTRIBUTOR_FACTORY; - LoopStrategyFactory public immutable LOOP_STRATEGY_FACTORY; - GGVStrategyFactory public immutable GGV_STRATEGY_FACTORY; - TimelockFactory public immutable TIMELOCK_FACTORY; - address public immutable DUMMY_IMPLEMENTATION; - - address public immutable GGV_TELLER; - address public immutable GGV_BORING_ON_CHAIN_QUEUE; - - bytes32 public immutable DEFAULT_ADMIN_ROLE = 0x00; - - uint256 public immutable TIMELOCK_MIN_DELAY; - address public immutable TIMELOCK_EXECUTOR; - uint256 public constant TOTAL_BASIS_POINTS = 100_00; - - event VaultPoolCreated( - address indexed vault, address indexed pool, address indexed withdrawalQueue, address strategy - ); - - struct StvPoolConfig { - bool allowlistEnabled; - address owner; - address nodeOperator; - address nodeOperatorManager; - uint256 nodeOperatorFeeBP; - uint256 confirmExpiry; - uint256 maxFinalizationTime; + /// @notice Common configuration shared across all pool types + /// @param minWithdrawalDelayTime Minimum delay time for processing withdrawals + /// @param name ERC20 token name for the pool shares + /// @param symbol ERC20 token symbol for the pool shares + struct CommonPoolConfig { uint256 minWithdrawalDelayTime; string name; string symbol; } + /// @notice Configuration specific to StvStETH pools (deprecated, kept for compatibility) + /// @param allowlistEnabled Whether the pool requires allowlist for deposits + /// @param reserveRatioGapBP Maximum allowed gap in reserve ratio in basis points struct StvStETHPoolConfig { bool allowlistEnabled; - address owner; - address nodeOperator; - address nodeOperatorManager; - uint256 nodeOperatorFeeBP; - uint256 confirmExpiry; - uint256 maxFinalizationTime; - uint256 minWithdrawalDelayTime; uint256 reserveRatioGapBP; - string name; - string symbol; } - struct GGVPoolConfig { - address owner; - address nodeOperator; - address nodeOperatorManager; - uint256 nodeOperatorFeeBP; - uint256 confirmExpiry; - uint256 maxFinalizationTime; - uint256 minWithdrawalDelayTime; - uint256 reserveRatioGapBP; - string name; - string symbol; - } - - struct PoolFullConfig { + /// @notice Extended configuration for pools with minting or strategy capabilities + /// @param allowlistEnabled Whether the pool requires allowlist for deposits + /// @param mintingEnabled Whether the pool can mint stETH tokens + /// @param reserveRatioGapBP Maximum allowed gap in reserve ratio in basis points + struct AuxiliaryPoolConfig { bool allowlistEnabled; bool mintingEnabled; - address owner; // TODO: owner of what? - address nodeOperator; - address nodeOperatorManager; - uint256 nodeOperatorFeeBP; - uint256 confirmExpiry; - uint256 maxFinalizationTime; - uint256 minWithdrawalDelayTime; uint256 reserveRatioGapBP; - string name; - string symbol; - } - - struct StrategyConfig { - address factory; } - struct StvPoolIntermediate { - bytes32 poolType; - address vault; - address dashboard; + /// @notice Intermediate state returned by deployment start functions + /// @param pool Address of the deployed pool proxy + /// @param timelock Address of the deployed timelock controller + /// @param strategyFactory Address of the strategy factory (zero if not using strategies) + /// @param strategyDeployBytes ABI-encoded parameters for strategy deployment + struct PoolIntermediate { address pool; - address withdrawalQueue; - address distributor; address timelock; + address strategyFactory; + bytes strategyDeployBytes; } - struct StvPoolDeployment { + /// @notice Complete deployment result returned by finish function + /// @param poolType Type identifier for the pool (StvPool, StvStETHPool, or StvStrategyPool) + /// @param vault Address of the deployed vault + /// @param dashboard Address of the deployed dashboard + /// @param pool Address of the deployed pool + /// @param withdrawalQueue Address of the deployed withdrawal queue + /// @param distributor Address of the deployed distributor + /// @param timelock Address of the deployed timelock controller + /// @param strategy Address of the deployed strategy (zero if not using strategies) + struct PoolDeployment { bytes32 poolType; address vault; address dashboard; @@ -163,148 +127,272 @@ contract Factory { address strategy; } - constructor( - address locatorAddress, - SubFactories memory subFactories, - TimelockConfig memory timelockConfig, - StrategyParameters memory strategyParameters - ) { - ILidoLocator locator = ILidoLocator(locatorAddress); + // + // Events + // + + /// @notice Emitted when pool deployment is initiated in the start phase + /// @param intermediate Contains addresses of deployed components needed for finish phase + /// @param finishDeadline Timestamp by which createPoolFinish must be called (inclusive) + event PoolCreationStarted(PoolIntermediate intermediate, uint256 finishDeadline); + + /// @notice Emitted when pool deployment is completed in the finish phase + /// @param vault Address of the deployed vault + /// @param pool Address of the deployed pool + /// @param poolType Type identifier for the pool + /// @param withdrawalQueue Address of the deployed withdrawal queue + /// @param strategyFactory Address of the strategy factory used (zero if none) + /// @param strategyDeployBytes ABI-encoded parameters used for strategy deployment + /// @param strategy Address of the deployed strategy (zero if not using strategies) + event PoolCreated( + address vault, + address pool, + bytes32 indexed poolType, + address withdrawalQueue, + address indexed strategyFactory, + bytes strategyDeployBytes, + address strategy + ); + + // + // Custom errors + // + + /// @notice Thrown when configuration parameters are invalid or inconsistent + /// @param reason Human-readable description of the configuration error + error InvalidConfiguration(string reason); + + /// @notice Thrown when insufficient ETH is sent for the vault connection deposit + /// @param provided Amount of ETH provided in msg.value + /// @param required Required amount for VAULT_HUB.CONNECT_DEPOSIT() + error InsufficientConnectDeposit(uint256 provided, uint256 required); + + /// @notice Thrown when a string exceeds the maximum length for encoding to bytes32 + /// @param str The string that is too long + error StringTooLong(string str); + + // + // Constants and immutables + // + + /// @notice Lido vault factory for creating vaults and dashboards + IVaultFactory public immutable VAULT_FACTORY; + + /// @notice Lido V3 VaultHub (cached from LidoLocator for gas cost reduction) + IVaultHub public immutable VAULT_HUB; + + /// @notice Lido stETH token address (cached from LidoLocator for gas cost reduction) + address public immutable STETH; + + /// @notice Lido wstETH token address (cached from LidoLocator for gas cost reduction) + address public immutable WSTETH; + + /// @notice Lido V3 LazyOracle (cached from LidoLocator for gas cost reduction) + address public immutable LAZY_ORACLE; + + /// @notice Pool type identifier for basic StvPool + bytes32 public immutable STV_POOL_TYPE; + + /// @notice Pool type identifier for StvStETHPool with minting capabilities + bytes32 public immutable STV_STETH_POOL_TYPE; + + /// @notice Pool type identifier for StvStrategyPool with strategy integration + bytes32 public immutable STRATEGY_POOL_TYPE; + + /// @notice Factory for deploying StvPool implementations + StvPoolFactory public immutable STV_POOL_FACTORY; + + /// @notice Factory for deploying StvStETHPool implementations + StvStETHPoolFactory public immutable STV_STETH_POOL_FACTORY; + + /// @notice Factory for deploying WithdrawalQueue implementations + WithdrawalQueueFactory public immutable WITHDRAWAL_QUEUE_FACTORY; + + /// @notice Factory for deploying Distributor implementations + DistributorFactory public immutable DISTRIBUTOR_FACTORY; + + /// @notice Factory for deploying GGV strategy implementations + GGVStrategyFactory public immutable GGV_STRATEGY_FACTORY; + + /// @notice Factory for deploying Timelock controllers + TimelockFactory public immutable TIMELOCK_FACTORY; + + /// @notice Dummy implementation used for temporary proxy initialization + address public immutable DUMMY_IMPLEMENTATION; + + /// @notice Default admin role identifier (keccak256("") = 0x00) + bytes32 public immutable DEFAULT_ADMIN_ROLE = 0x00; + + /// @notice Total basis points constant (100.00%) + uint256 public constant TOTAL_BASIS_POINTS = 100_00; + + /// @notice Maximum time allowed between start and finish deployment phases + uint256 public constant DEPLOY_START_FINISH_SPAN_SECONDS = 1 days; + + /// @notice Sentinel value marking a deployment as complete + uint256 public constant DEPLOY_FINISHED = type(uint256).max; + + // + // Structured storage + // + + /// @notice Tracks deployment state by hash of intermediate state and sender + /// @dev Maps deployment hash to finish deadline (0 = not started, DEPLOY_FINISHED = finished) + mapping(bytes32 => uint256) public intermediateState; + + /// @notice Initializes the factory with Lido locator and sub-factory addresses + /// @param _locatorAddress Address of the Lido locator contract containing core protocol addresses + /// @param _subFactories Struct containing addresses of all required sub-factory contracts + constructor(address _locatorAddress, SubFactories memory _subFactories) { + ILidoLocator locator = ILidoLocator(_locatorAddress); VAULT_FACTORY = IVaultFactory(locator.vaultFactory()); STETH = address(locator.lido()); WSTETH = address(locator.wstETH()); LAZY_ORACLE = locator.lazyOracle(); VAULT_HUB = IVaultHub(locator.vaultHub()); - STV_POOL_FACTORY = StvPoolFactory(subFactories.stvPoolFactory); - STV_STETH_POOL_FACTORY = StvStETHPoolFactory(subFactories.stvStETHPoolFactory); - WITHDRAWAL_QUEUE_FACTORY = WithdrawalQueueFactory(subFactories.withdrawalQueueFactory); - DISTRIBUTOR_FACTORY = DistributorFactory(subFactories.distributorFactory); - LOOP_STRATEGY_FACTORY = LoopStrategyFactory(subFactories.loopStrategyFactory); - GGV_STRATEGY_FACTORY = GGVStrategyFactory(subFactories.ggvStrategyFactory); - TIMELOCK_FACTORY = TimelockFactory(subFactories.timelockFactory); - DUMMY_IMPLEMENTATION = address(new DummyImplementation()); - - TIMELOCK_MIN_DELAY = timelockConfig.minDelaySeconds; - TIMELOCK_EXECUTOR = timelockConfig.executor; + STV_POOL_FACTORY = StvPoolFactory(_subFactories.stvPoolFactory); + STV_STETH_POOL_FACTORY = StvStETHPoolFactory(_subFactories.stvStETHPoolFactory); + WITHDRAWAL_QUEUE_FACTORY = WithdrawalQueueFactory(_subFactories.withdrawalQueueFactory); + DISTRIBUTOR_FACTORY = DistributorFactory(_subFactories.distributorFactory); + GGV_STRATEGY_FACTORY = GGVStrategyFactory(_subFactories.ggvStrategyFactory); + TIMELOCK_FACTORY = TimelockFactory(_subFactories.timelockFactory); - if (strategyParameters.ggvTeller == address(0) || strategyParameters.ggvBoringOnChainQueue == address(0)) { - revert InvalidConfiguration(); - } + DUMMY_IMPLEMENTATION = address(new DummyImplementation()); - GGV_TELLER = strategyParameters.ggvTeller; - GGV_BORING_ON_CHAIN_QUEUE = strategyParameters.ggvBoringOnChainQueue; + STV_POOL_TYPE = _toBytes32("StvPool"); + STV_STETH_POOL_TYPE = _toBytes32("StvStETHPool"); + STRATEGY_POOL_TYPE = _toBytes32("StvStrategyPool"); } - function createPoolStvStart(StvPoolConfig memory _config) - external - payable - returns (StvPoolIntermediate memory intermediate) - { + /// @notice Initiates deployment of a basic StvPool (first phase) + /// @param _vaultConfig Configuration for the vault + /// @param _timelockConfig Configuration for the timelock controller + /// @param _commonPoolConfig Common pool parameters (name, symbol, withdrawal delay) + /// @param _allowListEnabled Whether to enable allowlist for deposits + /// @return intermediate Deployment state needed for finish phase + /// @dev Requires msg.value >= VAULT_HUB.CONNECT_DEPOSIT() for vault connection + function createPoolStvStart( + VaultConfig memory _vaultConfig, + TimelockConfig memory _timelockConfig, + CommonPoolConfig memory _commonPoolConfig, + bool _allowListEnabled + ) external payable returns (PoolIntermediate memory intermediate) { intermediate = createPoolStart( - PoolFullConfig({ - allowlistEnabled: _config.allowlistEnabled, - mintingEnabled: false, - owner: _config.owner, - nodeOperator: _config.nodeOperator, - nodeOperatorManager: _config.nodeOperatorManager, - nodeOperatorFeeBP: _config.nodeOperatorFeeBP, - confirmExpiry: _config.confirmExpiry, - maxFinalizationTime: _config.maxFinalizationTime, - minWithdrawalDelayTime: _config.minWithdrawalDelayTime, - reserveRatioGapBP: 0, - name: _config.name, - symbol: _config.symbol - }), - StrategyConfig({factory: address(0)}) + _vaultConfig, + _commonPoolConfig, + AuxiliaryPoolConfig({allowlistEnabled: _allowListEnabled, mintingEnabled: false, reserveRatioGapBP: 0}), + _timelockConfig, + address(0), + "" ); } - function createPoolStvStETHStart(StvStETHPoolConfig memory _config) - external - payable - returns (StvPoolIntermediate memory intermediate) - { + /// @notice Initiates deployment of an StvStETHPool with minting capabilities (first phase) + /// @param _vaultConfig Configuration for the vault + /// @param _timelockConfig Configuration for the timelock controller + /// @param _commonPoolConfig Common pool parameters (name, symbol, withdrawal delay) + /// @param _allowListEnabled Whether to enable allowlist for deposits + /// @param _reserveRatioGapBP Maximum allowed reserve ratio gap in basis points + /// @return intermediate Deployment state needed for finish phase + /// @dev Requires msg.value >= VAULT_HUB.CONNECT_DEPOSIT() for vault connection + function createPoolStvStETHStart( + VaultConfig memory _vaultConfig, + TimelockConfig memory _timelockConfig, + CommonPoolConfig memory _commonPoolConfig, + bool _allowListEnabled, + uint256 _reserveRatioGapBP + ) external payable returns (PoolIntermediate memory intermediate) { intermediate = createPoolStart( - PoolFullConfig({ - allowlistEnabled: _config.allowlistEnabled, - mintingEnabled: true, - owner: _config.owner, - nodeOperator: _config.nodeOperator, - nodeOperatorManager: _config.nodeOperatorManager, - nodeOperatorFeeBP: _config.nodeOperatorFeeBP, - confirmExpiry: _config.confirmExpiry, - maxFinalizationTime: _config.maxFinalizationTime, - minWithdrawalDelayTime: _config.minWithdrawalDelayTime, - reserveRatioGapBP: _config.reserveRatioGapBP, - name: _config.name, - symbol: _config.symbol + _vaultConfig, + _commonPoolConfig, + AuxiliaryPoolConfig({ + allowlistEnabled: _allowListEnabled, mintingEnabled: true, reserveRatioGapBP: _reserveRatioGapBP }), - StrategyConfig({factory: address(0)}) + _timelockConfig, + address(0), + "" ); } - function createPoolGGVStart(GGVPoolConfig memory _config) - external - payable - returns (StvPoolIntermediate memory intermediate) - { + /// @notice Initiates deployment of a GGV strategy pool (first phase) + /// @param _vaultConfig Configuration for the vault + /// @param _timelockConfig Configuration for the timelock controller + /// @param _commonPoolConfig Common pool parameters (name, symbol, withdrawal delay) + /// @param _reserveRatioGapBP Maximum allowed reserve ratio gap in basis points + /// @return intermediate Deployment state needed for finish phase + /// @dev Requires msg.value >= VAULT_HUB.CONNECT_DEPOSIT() for vault connection + /// @dev Automatically enables allowlist and minting for GGV pools + function createPoolGGVStart( + VaultConfig memory _vaultConfig, + TimelockConfig memory _timelockConfig, + CommonPoolConfig memory _commonPoolConfig, + uint256 _reserveRatioGapBP + ) external payable returns (PoolIntermediate memory intermediate) { intermediate = createPoolStart( - PoolFullConfig({ - allowlistEnabled: true, - mintingEnabled: true, - owner: _config.owner, - nodeOperator: _config.nodeOperator, - nodeOperatorManager: _config.nodeOperatorManager, - nodeOperatorFeeBP: _config.nodeOperatorFeeBP, - confirmExpiry: _config.confirmExpiry, - maxFinalizationTime: _config.maxFinalizationTime, - minWithdrawalDelayTime: _config.minWithdrawalDelayTime, - reserveRatioGapBP: _config.reserveRatioGapBP, - name: _config.name, - symbol: _config.symbol - }), - StrategyConfig({factory: address(GGV_STRATEGY_FACTORY)}) + _vaultConfig, + _commonPoolConfig, + AuxiliaryPoolConfig({allowlistEnabled: true, mintingEnabled: true, reserveRatioGapBP: _reserveRatioGapBP}), + _timelockConfig, + address(GGV_STRATEGY_FACTORY), + "" ); } - function createPoolStart(PoolFullConfig memory config, StrategyConfig memory strategyConfig) - public - payable - returns (StvPoolIntermediate memory intermediate) - { - if (msg.value != VAULT_HUB.CONNECT_DEPOSIT()) { - revert InsufficientConnectDeposit(VAULT_HUB.CONNECT_DEPOSIT(), msg.value); + /// @notice Generic pool deployment start function (first phase) + /// @param _vaultConfig Configuration for the vault + /// @param _commonPoolConfig Common pool parameters (name, symbol, withdrawal delay) + /// @param _auxiliaryConfig Additional pool configuration (allowlist, minting, reserve ratio) + /// @param _timelockConfig Configuration for the timelock controller + /// @param _strategyFactory Address of strategy factory (zero for pools without strategy) + /// @param _strategyDeployBytes ABI-encoded parameters for strategy deployment + /// @return intermediate Deployment state needed for finish phase + /// @dev This is the main deployment function called by all pool-specific start functions + /// @dev Validates configuration, deploys components, and records deployment state + /// @dev Requires msg.value >= VAULT_HUB.CONNECT_DEPOSIT() for vault connection + /// @dev Must be followed by createPoolFinish within DEPLOY_START_FINISH_SPAN_SECONDS + function createPoolStart( + VaultConfig memory _vaultConfig, + CommonPoolConfig memory _commonPoolConfig, + AuxiliaryPoolConfig memory _auxiliaryConfig, + TimelockConfig memory _timelockConfig, + address _strategyFactory, + bytes memory _strategyDeployBytes + ) public payable returns (PoolIntermediate memory intermediate) { + if (msg.value < VAULT_HUB.CONNECT_DEPOSIT()) { + revert InsufficientConnectDeposit(msg.value, VAULT_HUB.CONNECT_DEPOSIT()); } bytes32 poolType = STV_POOL_TYPE; - if (strategyConfig.factory != address(0)) { + if (_strategyFactory != address(0)) { poolType = STRATEGY_POOL_TYPE; - if (!config.allowlistEnabled) { - revert InvalidConfiguration(); + if (!_auxiliaryConfig.allowlistEnabled) { + revert InvalidConfiguration("allowlistEnabled must be true if strategy factory is set"); } - } else if (config.mintingEnabled) { + } else if (_auxiliaryConfig.mintingEnabled) { poolType = STV_STETH_POOL_TYPE; } - // TODO: check if reserveRatioGapBP is valid - - if (bytes(config.name).length == 0 || bytes(config.symbol).length == 0) { - revert InvalidConfiguration(); + if (bytes(_commonPoolConfig.name).length == 0 || bytes(_commonPoolConfig.symbol).length == 0) { + revert InvalidConfiguration("name and symbol must be set"); } - address timelock = TIMELOCK_FACTORY.deploy(TIMELOCK_MIN_DELAY, config.nodeOperator, TIMELOCK_EXECUTOR); + address timelock = TIMELOCK_FACTORY.deploy( + _timelockConfig.minDelaySeconds, _timelockConfig.proposer, _timelockConfig.executor + ); + + address tempAdmin = address(this); - (address vaultAddress, address dashboardAddress) = VAULT_FACTORY.createVaultWithDashboard{value: msg.value}( - address(this), // TODO - config.nodeOperator, - config.nodeOperatorManager, - config.nodeOperatorFeeBP, - config.confirmExpiry, - new IVaultFactory.RoleAssignment[](0) + (, address dashboardAddress) = VAULT_FACTORY.createVaultWithDashboard{value: msg.value}( + tempAdmin, + _vaultConfig.nodeOperator, + _vaultConfig.nodeOperatorManager, + _vaultConfig.nodeOperatorFeeBP, + _vaultConfig.confirmExpiry, + new IVaultFactory.RoleAssignment[](0) // NB: assigned later because require pool and wq deployed ); - address poolProxy = payable(address(new OssifiableProxy(DUMMY_IMPLEMENTATION, address(this), bytes("")))); + address poolProxy = payable(address(new OssifiableProxy(DUMMY_IMPLEMENTATION, tempAdmin, bytes("")))); address wqImpl = WITHDRAWAL_QUEUE_FACTORY.deploy( poolProxy, @@ -313,30 +401,33 @@ contract Factory { STETH, address(IDashboard(payable(dashboardAddress)).stakingVault()), LAZY_ORACLE, - config.maxFinalizationTime, - config.minWithdrawalDelayTime, - config.mintingEnabled + _commonPoolConfig.minWithdrawalDelayTime, + _auxiliaryConfig.mintingEnabled ); address withdrawalQueueProxy = address( new OssifiableProxy( wqImpl, timelock, - abi.encodeCall(WithdrawalQueue.initialize, (config.owner, config.nodeOperator)) // (admin, finalizerRoleHolder)) + abi.encodeCall( + WithdrawalQueue.initialize, + (timelock, _vaultConfig.nodeOperator) // (admin, finalizerRoleHolder) + ) ) ); - address distributor = DISTRIBUTOR_FACTORY.deploy(config.nodeOperator, config.nodeOperatorManager); + address distributor = DISTRIBUTOR_FACTORY.deploy(timelock, _vaultConfig.nodeOperatorManager); address poolImpl = address(0); if (poolType == STV_POOL_TYPE) { - poolImpl = - STV_POOL_FACTORY.deploy(dashboardAddress, config.allowlistEnabled, withdrawalQueueProxy, distributor); + poolImpl = STV_POOL_FACTORY.deploy( + dashboardAddress, _auxiliaryConfig.allowlistEnabled, withdrawalQueueProxy, distributor, poolType + ); } else if (poolType == STV_STETH_POOL_TYPE || poolType == STRATEGY_POOL_TYPE) { poolImpl = STV_STETH_POOL_FACTORY.deploy( dashboardAddress, - config.allowlistEnabled, - config.reserveRatioGapBP, + _auxiliaryConfig.allowlistEnabled, + _auxiliaryConfig.reserveRatioGapBP, withdrawalQueueProxy, distributor, poolType @@ -345,34 +436,52 @@ contract Factory { OssifiableProxy(payable(poolProxy)) .proxy__upgradeToAndCall( - poolImpl, abi.encodeCall(StvPool.initialize, (address(this), config.name, config.symbol)) + poolImpl, + abi.encodeCall(StvPool.initialize, (tempAdmin, _commonPoolConfig.name, _commonPoolConfig.symbol)) ); OssifiableProxy(payable(poolProxy)).proxy__changeAdmin(timelock); - // TODO - // emit VaultPoolCreated(vault, address(pool.), withdrawalQueueProxy, strategy); - - intermediate = StvPoolIntermediate({ - poolType: poolType, - vault: vaultAddress, - dashboard: dashboardAddress, + intermediate = PoolIntermediate({ pool: poolProxy, - withdrawalQueue: withdrawalQueueProxy, - distributor: distributor, - timelock: timelock + timelock: timelock, + strategyFactory: _strategyFactory, + strategyDeployBytes: _strategyDeployBytes }); + + bytes32 deploymentHash = _hashIntermediate(intermediate, msg.sender); + uint256 finishDeadline = block.timestamp + DEPLOY_START_FINISH_SPAN_SECONDS; + intermediateState[deploymentHash] = finishDeadline; + + emit PoolCreationStarted(intermediate, finishDeadline); } - function createPoolFinish(StvPoolIntermediate memory intermediate, StrategyConfig memory strategyConfig) + /// @notice Completes pool deployment (second phase) + /// @param _intermediate Deployment state returned by createPoolStart + /// @return deployment Complete deployment information with all component addresses + /// @dev Must be called by the same address that called createPoolStart + /// @dev Must be called within DEPLOY_START_FINISH_SPAN_SECONDS of start + function createPoolFinish(PoolIntermediate calldata _intermediate) external - returns (StvPoolDeployment memory deployment) + returns (PoolDeployment memory deployment) { - IDashboard dashboard = IDashboard(payable(intermediate.dashboard)); - StvPool pool = StvPool(payable(intermediate.pool)); - WithdrawalQueue withdrawalQueue = WithdrawalQueue(payable(intermediate.withdrawalQueue)); - address timelock = intermediate.timelock; + bytes32 deploymentHash = _hashIntermediate(_intermediate, msg.sender); + uint256 finishDeadline = intermediateState[deploymentHash]; + if (finishDeadline == 0) { + revert InvalidConfiguration("deploy not started"); + } else if (finishDeadline == DEPLOY_FINISHED) { + revert InvalidConfiguration("deploy already finished"); + } + if (block.timestamp > finishDeadline) { + revert InvalidConfiguration("deploy finish deadline passed"); + } + intermediateState[deploymentHash] = DEPLOY_FINISHED; + + StvPool pool = StvPool(payable(_intermediate.pool)); + IDashboard dashboard = pool.DASHBOARD(); + WithdrawalQueue withdrawalQueue = pool.WITHDRAWAL_QUEUE(); + address timelock = _intermediate.timelock; address tempAdmin = address(this); - bytes32 poolType = intermediate.poolType; + bytes32 poolType = pool.poolType(); dashboard.grantRole(dashboard.FUND_ROLE(), address(pool)); dashboard.grantRole(dashboard.REBALANCE_ROLE(), address(pool)); @@ -383,39 +492,69 @@ contract Factory { dashboard.grantRole(dashboard.BURN_ROLE(), address(pool)); } - address strategy = address(0); - - if (strategyConfig.factory == address(GGV_STRATEGY_FACTORY)) { - strategy = IStrategyFactory(strategyConfig.factory) - .deploy(address(pool), STETH, WSTETH, GGV_TELLER, GGV_BORING_ON_CHAIN_QUEUE); - } + address strategyProxy = address(0); + if (_intermediate.strategyFactory != address(0)) { + address strategyImpl = IStrategyFactory(_intermediate.strategyFactory) + .deploy(address(pool), _intermediate.strategyDeployBytes); - if (strategy != address(0)) { - pool.grantRole(pool.ALLOW_LIST_MANAGER_ROLE(), tempAdmin); - pool.addToAllowList(strategy); - pool.revokeRole(pool.ALLOW_LIST_MANAGER_ROLE(), tempAdmin); + strategyProxy = + address(new OssifiableProxy(strategyImpl, timelock, abi.encodeCall(IStrategy.initialize, (timelock)))); - // NB: can be shortened to: - // pool.grantRole(pool.DEPOSIT_ROLE(), strategy); // effectively means + pool.addToAllowList(strategyProxy); } pool.grantRole(DEFAULT_ADMIN_ROLE, timelock); - pool.renounceRole(DEFAULT_ADMIN_ROLE, tempAdmin); + pool.revokeRole(DEFAULT_ADMIN_ROLE, tempAdmin); dashboard.grantRole(DEFAULT_ADMIN_ROLE, timelock); - dashboard.renounceRole(DEFAULT_ADMIN_ROLE, tempAdmin); + dashboard.revokeRole(DEFAULT_ADMIN_ROLE, tempAdmin); - deployment = StvPoolDeployment({ + deployment = PoolDeployment({ poolType: poolType, - vault: intermediate.vault, - dashboard: intermediate.dashboard, - pool: intermediate.pool, - withdrawalQueue: intermediate.withdrawalQueue, - distributor: intermediate.distributor, - timelock: intermediate.timelock, - strategy: strategy + vault: address(pool.VAULT()), + dashboard: address(dashboard), + pool: _intermediate.pool, + withdrawalQueue: address(withdrawalQueue), + distributor: address(pool.DISTRIBUTOR()), + timelock: _intermediate.timelock, + strategy: strategyProxy }); - // TODO: LOSS_SOCIALIZER_ROLE + emit PoolCreated( + deployment.vault, + deployment.pool, + deployment.poolType, + deployment.withdrawalQueue, + _intermediate.strategyFactory, + _intermediate.strategyDeployBytes, + deployment.strategy + ); + + // NB: The roles are not granted on purpose: + // - LOSS_SOCIALIZER_ROLE (timelock can grant it itself) + } + + /// @notice Computes a unique hash for tracking deployment state + /// @param _intermediate The intermediate deployment state + /// @param _sender Address that initiated the deployment + /// @return result Keccak256 hash of the sender and intermediate state + function _hashIntermediate(PoolIntermediate memory _intermediate, address _sender) + public + pure + returns (bytes32 result) + { + result = keccak256(abi.encode(_sender, abi.encode(_intermediate))); + } + + /// @notice Encodes a string into bytes32 format for storage efficiency + /// @param _str The string to encode (must be 31 bytes or less) + /// @return Encoded bytes32 value with length encoded in the least significant byte + /// @dev Reverts with StringTooLong if the string length exceeds 31 bytes + function _toBytes32(string memory _str) internal pure returns (bytes32) { + bytes memory bstr = bytes(_str); + if (bstr.length > 31) { + revert StringTooLong(_str); + } + return bytes32(uint256(bytes32(bstr)) | bstr.length); } } diff --git a/src/StvPool.sol b/src/StvPool.sol index e0970bd..977e1dd 100644 --- a/src/StvPool.sol +++ b/src/StvPool.sol @@ -1,90 +1,82 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {AllowList} from "./AllowList.sol"; import {Distributor} from "./Distributor.sol"; import {WithdrawalQueue} from "./WithdrawalQueue.sol"; -import {IDashboard} from "./interfaces/IDashboard.sol"; -import {IStETH} from "./interfaces/IStETH.sol"; -import {IStakingVault} from "./interfaces/IStakingVault.sol"; -import {IVaultHub} from "./interfaces/IVaultHub.sol"; +import {IDashboard} from "./interfaces/core/IDashboard.sol"; +import {IStETH} from "./interfaces/core/IStETH.sol"; +import {IStakingVault} from "./interfaces/core/IStakingVault.sol"; +import {IVaultHub} from "./interfaces/core/IVaultHub.sol"; +import {FeaturePausable} from "./utils/FeaturePausable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -contract StvPool is Initializable, ERC20Upgradeable, AllowList { +/** + * @title StvPool + * @notice ERC20 staking vault token pool that accepts ETH deposits and manages withdrawals through a queue + * @dev Implements a tokenized staking pool where users deposit ETH and receive STV tokens representing their share + */ +contract StvPool is Initializable, ERC20Upgradeable, AllowList, FeaturePausable { // Custom errors error ZeroDeposit(); - error InvalidReceiver(); - error ZeroStv(); + error InvalidRecipient(); error NotWithdrawalQueue(); - error InvalidRequestType(); error NotEnoughToRebalance(); error UnassignedLiabilityOnVault(); + error VaultInBadDebt(); + error VaultReportStale(); - bytes32 public constant REQUEST_VALIDATOR_EXIT_ROLE = keccak256("REQUEST_VALIDATOR_EXIT_ROLE"); - bytes32 public constant TRIGGER_VALIDATOR_WITHDRAWAL_ROLE = keccak256("TRIGGER_VALIDATOR_WITHDRAWAL_ROLE"); + bytes32 public constant DEPOSITS_FEATURE = keccak256("DEPOSITS_FEATURE"); + bytes32 public constant DEPOSITS_PAUSE_ROLE = keccak256("DEPOSITS_PAUSE_ROLE"); + bytes32 public constant DEPOSITS_RESUME_ROLE = keccak256("DEPOSITS_RESUME_ROLE"); uint256 public constant TOTAL_BASIS_POINTS = 100_00; uint256 private constant DECIMALS = 27; uint256 private constant ASSET_DECIMALS = 18; - uint256 private constant EXTRA_DECIMALS_BASE = 10 ** (DECIMALS - ASSET_DECIMALS); IStETH public immutable STETH; IDashboard public immutable DASHBOARD; IVaultHub public immutable VAULT_HUB; - IStakingVault public immutable STAKING_VAULT; + IStakingVault public immutable VAULT; WithdrawalQueue public immutable WITHDRAWAL_QUEUE; Distributor public immutable DISTRIBUTOR; - /// @custom:storage-location erc7201:pool.storage.StvPool - struct StvPoolStorage { - bool vaultDisconnected; - } - - // keccak256(abi.encode(uint256(keccak256("pool.storage.StvPool")) - 1)) & ~bytes32(uint256(0xff)) - bytes32 private constant STV_POOL_STORAGE_LOCATION = - 0x4ba3584e94e638ad48c84a51d04c6416f12f2677ae8479c14b06fa49535c7e00; - - function _getStvPoolStorage() internal pure returns (StvPoolStorage storage $) { - assembly { - $.slot := STV_POOL_STORAGE_LOCATION - } - } - - function vaultDisconnected() public view returns (bool) { - return _getStvPoolStorage().vaultDisconnected; - } + bytes32 private immutable POOL_TYPE; - event VaultFunded(uint256 amount); - event ValidatorExitRequested(bytes pubkeys); - event ValidatorWithdrawalsTriggered(bytes pubkeys, uint64[] amountsInGwei); event Deposit( - address indexed sender, address indexed receiver, address indexed referral, uint256 assets, uint256 stv + address indexed sender, address indexed recipient, address indexed referral, uint256 assets, uint256 stv ); - event VaultDisconnected(address indexed initiator); - event ConnectDepositClaimed(address indexed recipient, uint256 amount); - event UnassignedLiabilityRebalanced(uint256 stethShares, uint256 ethAmount); + event UnassignedLiabilityRebalanced(uint256 stethShares, uint256 ethFunded); - constructor(address _dashboard, bool _allowListEnabled, address _withdrawalQueue, address _distributor) - AllowList(_allowListEnabled) - { + constructor( + address _dashboard, + bool _allowListEnabled, + address _withdrawalQueue, + address _distributor, + bytes32 _poolType + ) AllowList(_allowListEnabled) { DASHBOARD = IDashboard(payable(_dashboard)); VAULT_HUB = IVaultHub(DASHBOARD.VAULT_HUB()); - STAKING_VAULT = IStakingVault(DASHBOARD.stakingVault()); + VAULT = IStakingVault(DASHBOARD.stakingVault()); WITHDRAWAL_QUEUE = WithdrawalQueue(payable(_withdrawalQueue)); STETH = IStETH(payable(DASHBOARD.STETH())); DISTRIBUTOR = Distributor(_distributor); + POOL_TYPE = _poolType; // Disable initializers since we only support proxy deployment _disableInitializers(); + + // Pause features in implementation + _pauseFeature(DEPOSITS_FEATURE); } function poolType() external view virtual returns (bytes32) { - return keccak256("StvPool"); + return POOL_TYPE; } function initialize(address _owner, string memory _name, string memory _symbol) public virtual initializer { @@ -100,12 +92,13 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { // Initial vault balance must include the connect deposit // Minting stv for it to have clear stv math - // The stv are withdrawable only upon vault disconnection - uint256 initialVaultBalance = address(STAKING_VAULT).balance; + uint256 initialVaultBalance = address(VAULT).balance; uint256 connectDeposit = VAULT_HUB.CONNECT_DEPOSIT(); assert(initialVaultBalance >= connectDeposit); + assert(totalSupply() == 0); - _mint(address(this), _convertToStv(connectDeposit, Math.Rounding.Floor)); + uint256 stvToMint = initialVaultBalance * 10 ** (DECIMALS - ASSET_DECIMALS); + _mint(address(this), stvToMint); } // ================================================================================= @@ -115,7 +108,6 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { /** * @notice Total nominal assets managed by the pool * @return assets Total nominal assets (18 decimals) - * @dev Don't subtract CONNECT_DEPOSIT because we mint tokens for it */ function totalNominalAssets() public view returns (uint256 assets) { assets = DASHBOARD.maxLockableValue(); @@ -154,27 +146,22 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { // CONVERSION // ================================================================================= - function _convertToStv(uint256 _assetsE18, Math.Rounding _rounding) internal view returns (uint256 stv) { - uint256 totalAssetsE18 = totalAssets(); - uint256 totalSupplyE27 = totalSupply(); - - if (totalSupplyE27 == 0) return _assetsE18 * EXTRA_DECIMALS_BASE; // 1:1 for the first deposit - if (totalAssetsE18 == 0) return 0; + function _convertToStv(uint256 _assets, Math.Rounding _rounding) internal view returns (uint256 stv) { + uint256 totalAssets_ = totalAssets(); + if (totalAssets_ == 0) return 0; - stv = Math.mulDiv(_assetsE18, totalSupplyE27, totalAssetsE18, _rounding); + stv = Math.mulDiv(_assets, totalSupply(), totalAssets_, _rounding); } function _convertToAssets(uint256 _stv) internal view returns (uint256 assets) { assets = _getAssetsShare(_stv, totalAssets()); } - function _getAssetsShare(uint256 _stv, uint256 _assetsE18) internal view returns (uint256 assets) { - uint256 supplyE27 = totalSupply(); - if (supplyE27 == 0) return 0; + function _getAssetsShare(uint256 _stv, uint256 _assets) internal view returns (uint256 assets) { + uint256 totalSupply_ = totalSupply(); + if (totalSupply_ == 0) return 0; - // TODO: review this Math.Rounding.Ceil - uint256 assetsShare = Math.mulDiv(_stv * EXTRA_DECIMALS_BASE, _assetsE18, supplyE27, Math.Rounding.Ceil); - assets = assetsShare / EXTRA_DECIMALS_BASE; + assets = Math.mulDiv(_stv, _assets, totalSupply_, Math.Rounding.Floor); } // ================================================================================= @@ -222,6 +209,7 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { * @param _recipient Address to receive the minted shares * @param _referral Address of the referral (if any) * @return stv Amount of stv minted + * @dev Requires fresh oracle report to price stv accurately */ function depositETH(address _recipient, address _referral) public payable returns (uint256 stv) { stv = _deposit(_recipient, _referral); @@ -229,8 +217,10 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { function _deposit(address _recipient, address _referral) internal returns (uint256 stv) { if (msg.value == 0) revert ZeroDeposit(); - if (_recipient == address(0)) revert InvalidReceiver(); + if (_recipient == address(0)) revert InvalidRecipient(); + _checkFeatureNotPaused(DEPOSITS_FEATURE); _checkAllowList(); + _checkFreshReport(); stv = previewDeposit(msg.value); _mint(_recipient, stv); @@ -273,7 +263,7 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { * @param _stethShares Amount of stETH shares to rebalance (18 decimals) * @dev Only unassigned liability can be rebalanced with this method, not individual liability * @dev Can be called by anyone if there is any unassigned liability - * @dev Required fresh oracle report before calling + * @dev Requires fresh oracle report before calling (check is performed in VaultHub) */ function rebalanceUnassignedLiability(uint256 _stethShares) external { _checkOnlyUnassignedLiabilityRebalance(_stethShares); @@ -287,7 +277,7 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { * @dev Only unassigned liability can be rebalanced with this method, not individual liability * @dev Can be called by anyone if there is any unassigned liability * @dev This function accepts ETH and uses it to rebalance unassigned liability - * @dev Required fresh oracle report before calling + * @dev Requires fresh oracle report before calling (check is performed in VaultHub) */ function rebalanceUnassignedLiabilityWithEther() external payable { uint256 stethShares = _getSharesByPooledEth(msg.value); @@ -301,10 +291,8 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { * @dev Checks if only unassigned liability will be rebalanced, not individual liability */ function _checkOnlyUnassignedLiabilityRebalance(uint256 _stethShares) internal view { - uint256 unassignedLiabilityShares = totalUnassignedLiabilityShares(); - if (_stethShares == 0) revert NotEnoughToRebalance(); - if (unassignedLiabilityShares < _stethShares) revert NotEnoughToRebalance(); + if (totalUnassignedLiabilityShares() < _stethShares) revert NotEnoughToRebalance(); } /** @@ -314,6 +302,14 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { if (totalUnassignedLiabilityShares() > 0) revert UnassignedLiabilityOnVault(); } + /** + * @dev Checks if the vault is not in bad debt (value < liability) + */ + function _checkNoBadDebt() internal view { + uint256 totalValueInStethShares = _getSharesByPooledEth(VAULT_HUB.totalValue(address(VAULT))); + if (totalValueInStethShares < totalLiabilityShares()) revert VaultInBadDebt(); + } + // ================================================================================= // STETH HELPERS // ================================================================================= @@ -346,9 +342,13 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { * @dev Overridden method from ERC20 to prevent updates if there are unassigned liability */ function _update(address _from, address _to, uint256 _value) internal virtual override { + // Ensure vault is not in bad debt (value < liability) before any transfer + _checkNoBadDebt(); + // In rare scenarios, the vault could have liability shares that are not assigned to any pool users // In such cases, it prevents any transfers until the unassigned liability is rebalanced _checkNoUnassignedLiability(); + super._update(_from, _to, _value); } @@ -374,6 +374,7 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { */ function burnStvForWithdrawalQueue(uint256 _stv) external { _checkOnlyWithdrawalQueue(); + _checkNoBadDebt(); _checkNoUnassignedLiability(); _burnUnsafe(address(WITHDRAWAL_QUEUE), _stv); } @@ -388,70 +389,32 @@ contract StvPool is Initializable, ERC20Upgradeable, AllowList { } // ================================================================================= - // VAULT MANAGEMENT + // PAUSE / RESUME DEPOSITS // ================================================================================= /** - * @notice Initiates voluntary vault disconnection from VaultHub - * @dev Can only be called by admin. Vault must have no outstanding stETH liabilities. + * @notice Pause deposits + * @dev Can only be called by accounts with the DEPOSITS_PAUSE_ROLE */ - function disconnectVault() external { - _checkRole(DEFAULT_ADMIN_ROLE, msg.sender); - - // Start the disconnection process - // This requires: no liabilityShares, all obligations settled - DASHBOARD.voluntaryDisconnect(); - - // Mark vault as in disconnection process - // The actual disconnect completes during next oracle report - emit VaultDisconnected(msg.sender); + function pauseDeposits() external { + _checkRole(DEPOSITS_PAUSE_ROLE, msg.sender); + _pauseFeature(DEPOSITS_FEATURE); } /** - * @notice Claims the connect deposit after vault has been disconnected - * @dev Can only be called by admin after successful disconnection - * @param _recipient Address to receive the connect deposit + * @notice Resume deposits + * @dev Can only be called by accounts with the DEPOSITS_RESUME_ROLE */ - function claimConnectDeposit(address _recipient) external { - _checkRole(DEFAULT_ADMIN_ROLE, msg.sender); - - // Check if vault has been disconnected - if (address(STAKING_VAULT) == address(DASHBOARD.stakingVault())) { - revert("Vault not disconnected yet"); - } - - _getStvPoolStorage().vaultDisconnected = true; - - // After disconnection, the connect deposit is available in the vault - uint256 vaultBalance = address(STAKING_VAULT).balance; - if (vaultBalance > 0) { - DASHBOARD.withdraw(_recipient, vaultBalance); - emit ConnectDepositClaimed(_recipient, vaultBalance); - } + function resumeDeposits() external { + _checkRole(DEPOSITS_RESUME_ROLE, msg.sender); + _resumeFeature(DEPOSITS_FEATURE); } // ================================================================================= - // EMERGENCY WITHDRAWAL FUNCTIONS + // ORACLE FRESHNESS CHECK // ================================================================================= - function triggerValidatorWithdrawals( - bytes calldata _pubkeys, - uint64[] calldata _amountsInGwei, - address _refundRecipient - ) external payable { - _checkOnlyRoleOrEmergencyExit(TRIGGER_VALIDATOR_WITHDRAWAL_ROLE); - DASHBOARD.triggerValidatorWithdrawals{value: msg.value}(_pubkeys, _amountsInGwei, _refundRecipient); - } - - function requestValidatorExit(bytes calldata _pubkeys) external { - _checkOnlyRoleOrEmergencyExit(REQUEST_VALIDATOR_EXIT_ROLE); - DASHBOARD.requestValidatorExit(_pubkeys); - } - - /// @notice Modifier to check role or Emergency Exit - function _checkOnlyRoleOrEmergencyExit(bytes32 _role) internal view { - if (!WITHDRAWAL_QUEUE.isEmergencyExitActivated()) { - _checkRole(_role, msg.sender); - } + function _checkFreshReport() internal view { + if (!VAULT_HUB.isReportFresh(address(VAULT))) revert VaultReportStale(); } } diff --git a/src/StvStETHPool.sol b/src/StvStETHPool.sol index 8472a68..0a5f442 100644 --- a/src/StvStETHPool.sol +++ b/src/StvStETHPool.sol @@ -1,23 +1,25 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {StvPool} from "./StvPool.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {IStETH} from "./interfaces/IStETH.sol"; -import {IVaultHub} from "./interfaces/IVaultHub.sol"; -import {IWstETH} from "./interfaces/IWstETH.sol"; +import {IStETH} from "./interfaces/core/IStETH.sol"; +import {IVaultHub} from "./interfaces/core/IVaultHub.sol"; +import {IWstETH} from "./interfaces/core/IWstETH.sol"; /** * @title StvStETHPool - * @notice Configuration B: Minting, no strategy - stv + maximum stETH minting for user + * @notice Extended STV pool with (w)stETH minting, liability management, and rebalancing capabilities + * @dev Allows users to mint (w)stETH against their deposits with configurable reserve ratios */ contract StvStETHPool is StvPool { event StethSharesMinted(address indexed account, uint256 stethShares); event StethSharesBurned(address indexed account, uint256 stethShares); event StethSharesRebalanced(address indexed account, uint256 stethShares, uint256 stvBurned); - event SocializedLoss(uint256 stv, uint256 assets); + event SocializedLoss(uint256 stv, uint256 assets, uint256 maxLossSocializationBP); event VaultParametersUpdated(uint256 newReserveRatioBP, uint256 newForcedRebalanceThresholdBP); + event MaxLossSocializationUpdated(uint256 newMaxLossSocializationBP); error InsufficientMintingCapacity(); error InsufficientStethShares(); @@ -27,10 +29,15 @@ contract StvStETHPool is StvPool { error InsufficientStv(); error ZeroArgument(); error ArraysLengthMismatch(uint256 firstArrayLength, uint256 secondArrayLength); - error NothingToRebalance(); - error VaultReportStale(); error UndercollateralizedAccount(); error CollateralizedAccount(); + error ExcessiveLossSocialization(); + error SameValue(); + error InvalidValue(); + + bytes32 public constant MINTING_FEATURE = keccak256("MINTING_FEATURE"); + bytes32 public constant MINTING_PAUSE_ROLE = keccak256("MINTING_PAUSE_ROLE"); + bytes32 public constant MINTING_RESUME_ROLE = keccak256("MINTING_RESUME_ROLE"); bytes32 public constant LOSS_SOCIALIZER_ROLE = keccak256("LOSS_SOCIALIZER_ROLE"); @@ -39,14 +46,13 @@ contract StvStETHPool is StvPool { IWstETH public immutable WSTETH; - bytes32 private immutable POOL_TYPE; - /// @custom:storage-location erc7201:pool.storage.StvStETHPool struct StvStETHPoolStorage { mapping(address => uint256) mintedStethShares; uint256 totalMintedStethShares; uint16 reserveRatioBP; uint16 forcedRebalanceThresholdBP; + uint16 maxLossSocializationBP; } // keccak256(abi.encode(uint256(keccak256("pool.storage.StvStETHPool")) - 1)) & ~bytes32(uint256(0xff)) @@ -66,15 +72,15 @@ contract StvStETHPool is StvPool { address _withdrawalQueue, address _distributor, bytes32 _poolType - ) StvPool(_dashboard, _allowListEnabled, _withdrawalQueue, _distributor) { + ) StvPool(_dashboard, _allowListEnabled, _withdrawalQueue, _distributor, _poolType) { + uint256 vaultRR = VAULT_HUB.vaultConnection(address(VAULT)).reserveRatioBP; + if (_reserveRatioGapBP + vaultRR >= TOTAL_BASIS_POINTS) revert InvalidValue(); + RESERVE_RATIO_GAP_BP = _reserveRatioGapBP; WSTETH = IWstETH(DASHBOARD.WSTETH()); - POOL_TYPE = _poolType; - } - - function poolType() external view override returns (bytes32) { - return POOL_TYPE; + // Pause features in implementation + _pauseFeature(MINTING_FEATURE); } function initialize(address _owner, string memory _name, string memory _symbol) public override initializer { @@ -94,46 +100,34 @@ contract StvStETHPool is StvPool { /** * @notice Deposit native ETH and receive stv, minting a specific amount of stETH shares - * @param _recipient Address to receive stv and minted steth shares * @param _referral Address of the referral (if any) * @param _stethSharesToMint Optional amount of stETH shares to mint (18 decimals) * @return stv Amount of stv minted (27 decimals) - * @dev If the recipient is different from msg.sender, checks that enough stv is deposited to lock the requested stETH shares */ - function depositETHAndMintStethShares(address _recipient, address _referral, uint256 _stethSharesToMint) + function depositETHAndMintStethShares(address _referral, uint256 _stethSharesToMint) external payable virtual returns (uint256 stv) { - stv = depositETH(_recipient, _referral); - - if (_stethSharesToMint != 0) { - if (_recipient != msg.sender) _checkMinStvToLock(stv, _stethSharesToMint); - _mintStethShares(_recipient, _stethSharesToMint); - } + stv = depositETH(msg.sender, _referral); + if (_stethSharesToMint != 0) mintStethShares(_stethSharesToMint); } /** * @notice Deposit native ETH and receive stv, minting a specific amount of wstETH - * @param _recipient Address to receive stv and minted wstETH * @param _referral Address of the referral (if any) * @param _wstethToMint Optional amount of wstETH to mint (18 decimals) * @return stv Amount of stv minted (27 decimals) - * @dev If the recipient is different from msg.sender, checks that enough stv is deposited to lock the requested wstETH */ - function depositETHAndMintWsteth(address _recipient, address _referral, uint256 _wstethToMint) + function depositETHAndMintWsteth(address _referral, uint256 _wstethToMint) external payable virtual returns (uint256 stv) { - stv = depositETH(_recipient, _referral); - - if (_wstethToMint != 0) { - if (_recipient != msg.sender) _checkMinStvToLock(stv, _wstethToMint); - _mintWsteth(_recipient, _wstethToMint); - } + stv = depositETH(msg.sender, _referral); + if (_wstethToMint != 0) mintWsteth(_wstethToMint); } // ================================================================================= @@ -257,7 +251,7 @@ contract StvStETHPool is StvPool { /// should not be returned to Staking Vault, but should be distributed among all participants /// in exchange for the withdrawn ETH. /// - /// Thus, in rare situations, Staking Vault may have two assets: ETH and stETH, which are + /// Thus, in rare situations, StvStETHPool may have two assets: ETH and stETH, which are /// distributed among all users in proportion to their shares. assets = _convertToAssets(balanceOf(_account)); } @@ -312,28 +306,24 @@ contract StvStETHPool is StvPool { * @dev Note that minted wstETH can be not enough to cover the full obligation in stETH shares because of rounding error * on WSTETH contract during unwrapping. The dust from rounding accumulates on the WSTETH contract during unwrapping */ - function mintWsteth(uint256 _wsteth) external { - _mintWsteth(msg.sender, _wsteth); - } + function mintWsteth(uint256 _wsteth) public { + _checkFeatureNotPaused(MINTING_FEATURE); + _checkRemainingMintingCapacityOf(msg.sender, _wsteth); - function _mintWsteth(address _account, uint256 _wsteth) internal { - _checkRemainingMintingCapacityOf(_account, _wsteth); - _increaseMintedStethShares(_account, _wsteth); - DASHBOARD.mintWstETH(_account, _wsteth); + _increaseMintedStethShares(msg.sender, _wsteth); + DASHBOARD.mintWstETH(msg.sender, _wsteth); } /** * @notice Mint stETH shares up to the user's minting capacity * @param _stethShares The amount of stETH shares to mint */ - function mintStethShares(uint256 _stethShares) external { - _mintStethShares(msg.sender, _stethShares); - } + function mintStethShares(uint256 _stethShares) public { + _checkFeatureNotPaused(MINTING_FEATURE); + _checkRemainingMintingCapacityOf(msg.sender, _stethShares); - function _mintStethShares(address _account, uint256 _stethShares) internal { - _checkRemainingMintingCapacityOf(_account, _stethShares); - _increaseMintedStethShares(_account, _stethShares); - DASHBOARD.mintShares(_account, _stethShares); + _increaseMintedStethShares(msg.sender, _stethShares); + DASHBOARD.mintShares(msg.sender, _stethShares); } /** @@ -343,16 +333,12 @@ contract StvStETHPool is StvPool { * on WSTETH contract during unwrapping. The dust from rounding accumulates on the WSTETH contract during unwrapping */ function burnWsteth(uint256 _wsteth) external { - _burnWsteth(msg.sender, _wsteth); - } - - function _burnWsteth(address _account, uint256 _wsteth) internal { /// @dev Simulate conversions during unwrapping to account for possible reduction due to rounding errors uint256 unwrappedSteth = _getPooledEthByShares(_wsteth); uint256 unwrappedStethShares = _getSharesByPooledEth(unwrappedSteth); - _decreaseMintedStethShares(_account, unwrappedStethShares); + _decreaseMintedStethShares(msg.sender, unwrappedStethShares); - WSTETH.transferFrom(_account, address(this), _wsteth); + WSTETH.transferFrom(msg.sender, address(this), _wsteth); DASHBOARD.burnWstETH(_wsteth); } @@ -361,12 +347,8 @@ contract StvStETHPool is StvPool { * @param _stethShares The amount of stETH shares to burn */ function burnStethShares(uint256 _stethShares) external { - _burnStethShares(msg.sender, _stethShares); - } - - function _burnStethShares(address _account, uint256 _stethShares) internal { - _decreaseMintedStethShares(_account, _stethShares); - STETH.transferSharesFrom(_account, address(this), _stethShares); + _decreaseMintedStethShares(msg.sender, _stethShares); + STETH.transferSharesFrom(msg.sender, address(this), _stethShares); DASHBOARD.burnShares(_stethShares); } @@ -454,8 +436,7 @@ contract StvStETHPool is StvPool { * @return stvToLock The min amount of stv to lock (27 decimals) */ function calcStvToLockForStethShares(uint256 _stethShares) public view returns (uint256 stvToLock) { - uint256 assetsToLock = calcAssetsToLockForStethShares(_stethShares); - stvToLock = _convertToStv(assetsToLock, Math.Rounding.Ceil); + stvToLock = _convertToStv(calcAssetsToLockForStethShares(_stethShares), Math.Rounding.Ceil); } // ================================================================================= @@ -482,31 +463,34 @@ contract StvStETHPool is StvPool { * @notice Sync reserve ratio and forced rebalance threshold from VaultHub * @dev Permissionless method to keep reserve ratio and forced rebalance threshold in sync with VaultHub * @dev Adds a gap defined by RESERVE_RATIO_GAP_BP to VaultHub's values - * @dev Reverts if the new reserve ratio or forced rebalance threshold is invalid (>= TOTAL_BASIS_POINTS) */ function syncVaultParameters() public { IVaultHub.VaultConnection memory connection = DASHBOARD.vaultConnection(); uint256 maxReserveRatioBP = TOTAL_BASIS_POINTS - 1; + uint256 maxForcedRebalanceThresholdBP = maxReserveRatioBP - 1; /// Invariants from the OperatorGrid assert(connection.reserveRatioBP > 0); assert(connection.reserveRatioBP <= maxReserveRatioBP); assert(connection.forcedRebalanceThresholdBP > 0); - assert(connection.forcedRebalanceThresholdBP <= connection.reserveRatioBP); + assert(connection.forcedRebalanceThresholdBP < connection.reserveRatioBP); uint16 newReserveRatioBP = uint16(Math.min(connection.reserveRatioBP + RESERVE_RATIO_GAP_BP, maxReserveRatioBP)); - uint16 newThresholdBP = - uint16(Math.min(connection.forcedRebalanceThresholdBP + RESERVE_RATIO_GAP_BP, maxReserveRatioBP)); + uint16 newForcedRebalanceThresholdBP = uint16( + Math.min(connection.forcedRebalanceThresholdBP + RESERVE_RATIO_GAP_BP, maxForcedRebalanceThresholdBP) + ); StvStETHPoolStorage storage $ = _getStvStETHPoolStorage(); - if (newReserveRatioBP == $.reserveRatioBP && newThresholdBP == $.forcedRebalanceThresholdBP) return; + if (newReserveRatioBP == $.reserveRatioBP && newForcedRebalanceThresholdBP == $.forcedRebalanceThresholdBP) { + return; + } $.reserveRatioBP = newReserveRatioBP; - $.forcedRebalanceThresholdBP = newThresholdBP; + $.forcedRebalanceThresholdBP = newForcedRebalanceThresholdBP; - emit VaultParametersUpdated(newReserveRatioBP, newThresholdBP); + emit VaultParametersUpdated(newReserveRatioBP, newForcedRebalanceThresholdBP); } // ================================================================================= @@ -558,7 +542,7 @@ contract StvStETHPool is StvPool { * @dev Second, if there are remaining liability shares, rebalances Staking Vault * @dev Requires fresh oracle report, which is checked in the Withdrawal Queue */ - function rebalanceMintedStethShares(uint256 _stethShares, uint256 _maxStvToBurn) + function rebalanceMintedStethSharesForWithdrawalQueue(uint256 _stethShares, uint256 _maxStvToBurn) public returns (uint256 stvBurned) { @@ -574,9 +558,9 @@ contract StvStETHPool is StvPool { * @dev Requires fresh oracle report to price stv accurately */ function forceRebalance(address _account) public returns (uint256 stvBurned) { - (uint256 stethShares, uint256 stv, bool isUndercollateralized) = previewForceRebalance(_account); + _checkFreshReport(); - if (stethShares == 0) revert NothingToRebalance(); + (uint256 stethShares, uint256 stv, bool isUndercollateralized) = previewForceRebalance(_account); if (isUndercollateralized) revert UndercollateralizedAccount(); stvBurned = _rebalanceMintedStethShares(_account, stethShares, stv); @@ -590,6 +574,7 @@ contract StvStETHPool is StvPool { */ function forceRebalanceAndSocializeLoss(address _account) public returns (uint256 stvBurned) { _checkRole(LOSS_SOCIALIZER_ROLE, msg.sender); + _checkFreshReport(); (uint256 stethShares, uint256 stv, bool isUndercollateralized) = previewForceRebalance(_account); if (!isUndercollateralized) revert CollateralizedAccount(); @@ -603,15 +588,13 @@ contract StvStETHPool is StvPool { * @return stethShares The amount of stETH shares to rebalance, limited by available assets * @return stv The amount of stv needed to burn in exchange for the stETH shares, limited by user's stv balance * @return isUndercollateralized True if the user's assets are insufficient to cover the liability - * @dev Requires fresh oracle report to price stv accurately + * @dev Requires fresh oracle report to price stv accurately (not enforced in this method, so caller must ensure it) */ function previewForceRebalance(address _account) public view returns (uint256 stethShares, uint256 stv, bool isUndercollateralized) { - _checkFreshReport(); - uint256 stethSharesLiability = mintedStethSharesOf(_account); uint256 stvBalance = balanceOf(_account); uint256 assets = assetsOf(_account); @@ -644,12 +627,12 @@ contract StvStETHPool is StvPool { /// /// First, the rebalancing will use exceeding minted steth, bringing the vault closer to minted steth == liability, /// then the rebalancing mechanism on the vault, which is limited by available balance in the staking vault - uint256 stethToRebalance = totalExceedingMintedSteth() + STAKING_VAULT.availableBalance(); + uint256 stethToRebalance = totalExceedingMintedSteth() + VAULT.availableBalance(); stethToRebalance = Math.min(targetStethToRebalance, stethToRebalance); uint256 stvRequired = _convertToStv(stethToRebalance, Math.Rounding.Ceil); - stethShares = _getSharesByPooledEth(stethToRebalance); // TODO: round up, can it exceed liability? + stethShares = _getSharesByPooledEth(stethToRebalance); stv = Math.min(stvRequired, stvBalance); isUndercollateralized = isUndercollateralized || stvRequired > stvBalance; } @@ -671,6 +654,7 @@ contract StvStETHPool is StvPool { returns (uint256 stvToBurn) { _checkNoUnassignedLiability(); + _checkNoBadDebt(); if (_stethShares == 0) revert ZeroArgument(); if (_stethShares > mintedStethSharesOf(_account)) revert InsufficientMintedShares(); @@ -682,9 +666,14 @@ contract StvStETHPool is StvPool { if (remainingStethShares > 0) DASHBOARD.rebalanceVaultWithShares(remainingStethShares); - // TODO: Add sanity check for loss socialization if (stvToBurn > _maxStvToBurn) { - emit SocializedLoss(stvToBurn - _maxStvToBurn, ethToRebalance - _convertToAssets(_maxStvToBurn)); + _checkAllowedLossSocializationPortion(stvToBurn, _maxStvToBurn); + + emit SocializedLoss( + stvToBurn - _maxStvToBurn, + ethToRebalance - _convertToAssets(_maxStvToBurn), + _getStvStETHPoolStorage().maxLossSocializationBP + ); stvToBurn = _maxStvToBurn; } @@ -707,8 +696,54 @@ contract StvStETHPool is StvPool { isBreached = _assets < assetsThreshold; } - function _checkFreshReport() internal view { - if (!VAULT_HUB.isReportFresh(address(STAKING_VAULT))) revert VaultReportStale(); + function _checkAllowedLossSocializationPortion(uint256 stvRequired, uint256 stvAvailable) internal view { + // It's guaranteed that stvRequired > stvAvailable here + uint256 portionToSocializeBP = + Math.mulDiv(stvRequired - stvAvailable, TOTAL_BASIS_POINTS, stvRequired, Math.Rounding.Ceil); + + if (portionToSocializeBP > _getStvStETHPoolStorage().maxLossSocializationBP) { + revert ExcessiveLossSocialization(); + } + } + + // ================================================================================= + // LOSS SOCIALIZATION LIMITER + // ================================================================================= + + // During rebalancing, it's possible that the stv available for burning is not sufficient to cover the entire liability. + // This may be due to a sharp drop in the stv price, which has resulted in an individual account or a request in Withdrawal Queue + // no longer being collateralized (assets < liability). + // + // The limiter on loss socialization is introduced to prevent excessive losses from being socialized to all pool participants. + // The limiter is defined as a maximum portion of the loss that can be socialized, expressed in basis points (BP). + // + // The default value is set to 0 BP, meaning that no loss socialization is allowed without explicit permission. + + /** + * @notice Maximum allowed loss socialization in basis points + * @return maxSocializablePortionBP The maximum allowed portion of loss to be socialized in basis points + * @dev Used to limit the portion of loss that can be socialized to all pool participants during rebalance + */ + function maxLossSocializationBP() external view returns (uint256 maxSocializablePortionBP) { + maxSocializablePortionBP = uint256(_getStvStETHPoolStorage().maxLossSocializationBP); + } + + /** + * @notice Set the maximum allowed loss socialization in basis points + * @param _maxSocializablePortionBP The new maximum allowed loss socialization in basis points + * @dev Sets the maximum portion of loss that can be socialized to all pool participants during rebalance + * @dev Can only be called by accounts with the DEFAULT_ADMIN_ROLE + */ + function setMaxLossSocializationBP(uint16 _maxSocializablePortionBP) external { + _checkRole(DEFAULT_ADMIN_ROLE, msg.sender); + + if (_maxSocializablePortionBP > TOTAL_BASIS_POINTS) revert InvalidValue(); + + StvStETHPoolStorage storage $ = _getStvStETHPoolStorage(); + if (_maxSocializablePortionBP == $.maxLossSocializationBP) revert SameValue(); + $.maxLossSocializationBP = _maxSocializablePortionBP; + + emit MaxLossSocializationUpdated(_maxSocializablePortionBP); } // ================================================================================= @@ -753,4 +788,26 @@ contract StvStETHPool is StvPool { if (balanceOf(_from) < stvToLock) revert InsufficientReservedBalance(); } + + // ================================================================================= + // PAUSE / RESUME MINTING + // ================================================================================= + + /** + * @notice Pause (w)stETH minting + * @dev Can only be called by accounts with the MINTING_PAUSE_ROLE + */ + function pauseMinting() external { + _checkRole(MINTING_PAUSE_ROLE, msg.sender); + _pauseFeature(MINTING_FEATURE); + } + + /** + * @notice Resume (w)stETH minting + * @dev Can only be called by accounts with the MINTING_RESUME_ROLE + */ + function resumeMinting() external { + _checkRole(MINTING_RESUME_ROLE, msg.sender); + _resumeFeature(MINTING_FEATURE); + } } diff --git a/src/WithdrawalQueue.sol b/src/WithdrawalQueue.sol index 5345356..c346f37 100644 --- a/src/WithdrawalQueue.sol +++ b/src/WithdrawalQueue.sol @@ -1,44 +1,65 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {IDashboard} from "./interfaces/IDashboard.sol"; -import {ILazyOracle} from "./interfaces/ILazyOracle.sol"; -import {IStETH} from "./interfaces/IStETH.sol"; -import {IStakingVault} from "./interfaces/IStakingVault.sol"; import {IStvStETHPool} from "./interfaces/IStvStETHPool.sol"; -import {IVaultHub} from "./interfaces/IVaultHub.sol"; +import {IDashboard} from "./interfaces/core/IDashboard.sol"; +import {ILazyOracle} from "./interfaces/core/ILazyOracle.sol"; +import {IStETH} from "./interfaces/core/IStETH.sol"; +import {IStakingVault} from "./interfaces/core/IStakingVault.sol"; +import {IVaultHub} from "./interfaces/core/IVaultHub.sol"; +import {FeaturePausable} from "./utils/FeaturePausable.sol"; import { AccessControlEnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; -import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -/// @title Withdrawal Queue V3 for Staking Vault Pool -/// @notice Handles withdrawal requests for stvToken holders -contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradeable { +/** + * @title WithdrawalQueue + * @notice Manages withdrawal requests from the STV Pool with queuing, finalization, and claiming + * @dev Handles the complete lifecycle of withdrawal requests including optional stETH rebalancing, + * and discount mechanisms + */ +contract WithdrawalQueue is AccessControlEnumerableUpgradeable, FeaturePausable { using EnumerableSet for EnumerableSet.UintSet; - /// @notice Max acceptable time for finalization of the withdrawal request - /// @dev This is a safeguard against excessively long withdrawal processes - uint256 public immutable MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS; - /// @notice Min delay between withdrawal request and finalization + /// @dev Contract enforces a minimum 1-hour delay to ensure the value is set within reasonable bounds uint256 public immutable MIN_WITHDRAWAL_DELAY_TIME_IN_SECONDS; // ACL - bytes32 public constant PAUSE_ROLE = keccak256("PAUSE_ROLE"); - bytes32 public constant RESUME_ROLE = keccak256("RESUME_ROLE"); + bytes32 public constant WITHDRAWALS_FEATURE = keccak256("WITHDRAWALS_FEATURE"); + bytes32 public constant WITHDRAWALS_PAUSE_ROLE = keccak256("WITHDRAWALS_PAUSE_ROLE"); + bytes32 public constant WITHDRAWALS_RESUME_ROLE = keccak256("WITHDRAWALS_RESUME_ROLE"); + + bytes32 public constant FINALIZE_FEATURE = keccak256("FINALIZE_FEATURE"); + bytes32 public constant FINALIZE_PAUSE_ROLE = keccak256("FINALIZE_PAUSE_ROLE"); + bytes32 public constant FINALIZE_RESUME_ROLE = keccak256("FINALIZE_RESUME_ROLE"); bytes32 public constant FINALIZE_ROLE = keccak256("FINALIZE_ROLE"); /// @notice Precision base for stv and steth share rates uint256 public constant E27_PRECISION_BASE = 1e27; uint256 public constant E36_PRECISION_BASE = 1e36; - /// @notice Minimal amount of assets that is possible to withdraw - /// @dev Should be big enough to prevent DoS attacks by placing many small requests - uint256 public constant MIN_WITHDRAWAL_AMOUNT = 1 * 10 ** 14; // 0.0001 ETH - uint256 public constant MAX_WITHDRAWAL_AMOUNT = 10_000 * 10 ** 18; // 10,000 ETH + /// @notice Maximum gas cost coverage that can be applied for a single request + /// @dev High enough to cover gas costs for finalization tx + /// @dev Low enough to prevent abuse by excessive gas cost coverage + /// + /// Request finalization tx for 1 request consumes ~200k gas + /// Request finalization tx for 10 requests (in batch) consumes ~300k gas + /// Thus, setting max coverage to 0.0005 ether should be sufficient to cover finalization gas costs: + /// - when gas price is up to 2.5 gwei for tx with a single request (0.0005 eth / 200k gas = 2.5 gwei per gas) + /// - when gas price is up to 16.6 gwei for batched tx of 10 requests (10 * 0.0005 eth / 300k gas = 16.6 gwei per gas) + uint256 public constant MAX_GAS_COST_COVERAGE = 0.0005 ether; + + /// @notice Minimal value (assets - stETH to rebalance) that is possible to request + /// @dev Prevents placing many small requests + uint256 public constant MIN_WITHDRAWAL_VALUE = 0.001 ether; + + /// @notice Maximum amount of assets that is possible to withdraw in a single request + /// @dev Prevents accumulating too much funds per single request fulfillment in the future + /// @dev To withdraw larger amounts, it's recommended to split it to several requests + uint256 public constant MAX_WITHDRAWAL_ASSETS = 10_000 ether; /// @dev Return value for the `findCheckpointHint` method in case of no result uint256 internal constant NOT_FOUND = 0; @@ -51,7 +72,7 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea IDashboard public immutable DASHBOARD; IStETH public immutable STETH; ILazyOracle public immutable LAZY_ORACLE; - IStakingVault public immutable STAKING_VAULT; + IStakingVault public immutable VAULT; /// @notice Structure representing a request for withdrawal struct WithdrawalRequest { @@ -75,11 +96,13 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea uint256 fromRequestId; /// @notice Stv rate at the moment of finalization (1e27 precision) uint256 stvRate; - /// @notice Steth share rate at the moment of finalization (1e27 precision) - uint256 stethShareRate; + /// @notice Steth share rate at the moment of finalization (1e18 precision) + uint128 stethShareRate; + /// @notice Gas cost coverage for the requests in this checkpoint + uint64 gasCostCoverage; } - /// @notice Output format struct for `getWithdrawalStatus()` / `getWithdrawalStatuses()` methods + /// @notice Output format struct for view methods `getWithdrawalStatus()` and `getWithdrawalStatusBatch()` struct WithdrawalRequestStatus { /// @notice Amount of stv locked for this request uint256 amountOfStv; @@ -110,16 +133,16 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea mapping(uint256 => Checkpoint) checkpoints; // ### 4th slot /// @dev last index in request queue - uint96 lastRequestId; + uint128 lastRequestId; /// @dev last index of finalized request in the queue - uint96 lastFinalizedRequestId; - /// @dev timestamp of emergency exit activation - uint40 emergencyExitActivationTimestamp; + uint128 lastFinalizedRequestId; // ### 5th slot /// @dev last index in checkpoints array uint96 lastCheckpointIndex; /// @dev amount of ETH locked on contract for further claiming uint96 totalLockedAssets; + /// @dev request finalization gas cost coverage in wei + uint64 gasCostCoverage; } // keccak256(abi.encode(uint256(keccak256("pool.storage.WithdrawalQueue")) - 1)) & ~bytes32(uint256(0xff)) @@ -144,19 +167,22 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea uint256 indexed from, uint256 indexed to, uint256 ethLocked, + uint256 ethForGasCoverage, uint256 stvBurned, uint256 stvRebalanced, uint256 stethSharesRebalanced, uint256 timestamp ); event WithdrawalClaimed( - uint256 indexed requestId, address indexed owner, address indexed receiver, uint256 amountOfETH + uint256 indexed requestId, address indexed owner, address indexed recipient, uint256 amountOfETH ); - event EmergencyExitActivated(uint256 timestamp); + event GasCostCoverageSet(uint256 newCoverage); error ZeroAddress(); - error RequestAmountTooSmall(uint256 amount); - error RequestAmountTooLarge(uint256 amount); + error RequestValueTooSmall(uint256 amount); + error RequestAssetsTooLarge(uint256 amount); + error GasCostCoverageTooLarge(uint256 amount); + error InvalidWithdrawalDelay(); error InvalidRequestId(uint256 requestId); error InvalidRange(uint256 start, uint256 end); error RequestAlreadyClaimed(uint256 requestId); @@ -166,7 +192,6 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea error VaultReportStale(); error CantSendValueRecipientMayHaveReverted(); error InvalidHint(uint256 hint); - error InvalidEmergencyExitActivation(); error NoRequestsToFinalize(); error NotOwner(address _requestor, address _owner); error RebalancingIsNotSupported(); @@ -178,10 +203,12 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea address _steth, address _vault, address _lazyOracle, - uint256 _maxAcceptableWQFinalizationTimeInSeconds, uint256 _minWithdrawalDelayTimeInSeconds, bool _isRebalancingSupported ) { + if (_minWithdrawalDelayTimeInSeconds < 1 hours) revert InvalidWithdrawalDelay(); + + MIN_WITHDRAWAL_DELAY_TIME_IN_SECONDS = _minWithdrawalDelayTimeInSeconds; IS_REBALANCING_SUPPORTED = _isRebalancingSupported; POOL = IStvStETHPool(payable(_pool)); @@ -189,13 +216,13 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea VAULT_HUB = IVaultHub(_vaultHub); STETH = IStETH(_steth); LAZY_ORACLE = ILazyOracle(_lazyOracle); - STAKING_VAULT = IStakingVault(_vault); - - MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS = _maxAcceptableWQFinalizationTimeInSeconds; - MIN_WITHDRAWAL_DELAY_TIME_IN_SECONDS = _minWithdrawalDelayTimeInSeconds; + VAULT = IStakingVault(_vault); _disableInitializers(); - _pause(); + + // Pause features in implementation + _pauseFeature(WITHDRAWALS_FEATURE); + _pauseFeature(FINALIZE_FEATURE); } /** @@ -208,7 +235,6 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea if (_admin == address(0)) revert ZeroAddress(); __AccessControlEnumerable_init(); - __Pausable_init(); _grantRole(DEFAULT_ADMIN_ROLE, _admin); _grantRole(FINALIZE_ROLE, _finalizer); @@ -230,20 +256,41 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea // ================================================================================= /** - * @notice Pause withdrawal requests placement and finalization + * @notice Pause withdrawal requests submission + * @dev Can only be called by accounts with the WITHDRAWALS_PAUSE_ROLE * @dev Does not affect claiming of already finalized requests */ - function pause() external { - _checkRole(PAUSE_ROLE, msg.sender); - _pause(); + function pauseWithdrawals() external { + _checkRole(WITHDRAWALS_PAUSE_ROLE, msg.sender); + _pauseFeature(WITHDRAWALS_FEATURE); } /** - * @notice Resume withdrawal requests placement and finalization + * @notice Resume withdrawal requests submission + * @dev Can only be called by accounts with the WITHDRAWALS_RESUME_ROLE */ - function resume() external { - _checkRole(RESUME_ROLE, msg.sender); - _unpause(); + function resumeWithdrawals() external { + _checkRole(WITHDRAWALS_RESUME_ROLE, msg.sender); + _resumeFeature(WITHDRAWALS_FEATURE); + } + + /** + * @notice Pause withdrawals finalization + * @dev Can only be called by accounts with the FINALIZE_PAUSE_ROLE + * @dev Does not affect claiming of already finalized requests + */ + function pauseFinalization() external { + _checkRole(FINALIZE_PAUSE_ROLE, msg.sender); + _pauseFeature(FINALIZE_FEATURE); + } + + /** + * @notice Resume withdrawals finalization + * @dev Can only be called by accounts with the FINALIZE_RESUME_ROLE + */ + function resumeFinalization() external { + _checkRole(FINALIZE_RESUME_ROLE, msg.sender); + _resumeFeature(FINALIZE_FEATURE); } // ================================================================================= @@ -251,53 +298,62 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea // ================================================================================= /** - * @notice Request multiple withdrawals for a user - * @param _recipient Address that will be able to claim the created request + * @notice Request multiple withdrawals from the Pool + * @param _owner Address that will be able to claim the created request * @param _stvToWithdraw Array of amounts of stv to withdraw * @param _stethSharesToRebalance Array of amounts of stETH shares to rebalance if supported by the pool, array of 0 otherwise * @return requestIds the created withdrawal request ids + * @dev Transfers stv and liability shares from the requester to the withdrawal queue + * @dev Requires fresh oracle report to price stv accurately */ function requestWithdrawalBatch( - address _recipient, + address _owner, uint256[] calldata _stvToWithdraw, uint256[] calldata _stethSharesToRebalance ) external returns (uint256[] memory requestIds) { - _checkResumedOrEmergencyExit(); + _checkFeatureNotPaused(WITHDRAWALS_FEATURE); _checkArrayLength(_stvToWithdraw.length, _stethSharesToRebalance.length); + _checkFreshReport(); requestIds = new uint256[](_stvToWithdraw.length); for (uint256 i = 0; i < _stvToWithdraw.length; ++i) { - requestIds[i] = _requestWithdrawal(_recipient, _stvToWithdraw[i], _stethSharesToRebalance[i]); + requestIds[i] = _requestWithdrawal(_owner, _stvToWithdraw[i], _stethSharesToRebalance[i]); } } /** - * @notice Request a withdrawal for a user - * @param _recipient Address that will be able to claim the created request + * @notice Request a withdrawal from the Pool + * @param _owner Address that will be able to claim the created request * @param _stvToWithdraw Amount of stv to withdraw * @param _stethSharesToRebalance Amount of steth shares to rebalance if supported by the pool, 0 otherwise * @return requestId The created withdrawal request id - * @dev Transfers stv and steth shares from the requester to the pool + * @dev Transfers stv and liability shares from the requester to the withdrawal queue + * @dev Requires fresh oracle report to price stv accurately */ - function requestWithdrawal(address _recipient, uint256 _stvToWithdraw, uint256 _stethSharesToRebalance) + function requestWithdrawal(address _owner, uint256 _stvToWithdraw, uint256 _stethSharesToRebalance) external returns (uint256 requestId) { - _checkResumedOrEmergencyExit(); - requestId = _requestWithdrawal(_recipient, _stvToWithdraw, _stethSharesToRebalance); + _checkFeatureNotPaused(WITHDRAWALS_FEATURE); + _checkFreshReport(); + + requestId = _requestWithdrawal(_owner, _stvToWithdraw, _stethSharesToRebalance); } - function _requestWithdrawal(address _recipient, uint256 _stvToWithdraw, uint256 _stethSharesToRebalance) + function _requestWithdrawal(address _owner, uint256 _stvToWithdraw, uint256 _stethSharesToRebalance) internal returns (uint256 requestId) { - if (_recipient == address(0)) revert ZeroAddress(); + if (_owner == address(0)) revert ZeroAddress(); if (_stethSharesToRebalance > 0 && !IS_REBALANCING_SUPPORTED) revert RebalancingIsNotSupported(); uint256 assets = POOL.previewRedeem(_stvToWithdraw); + uint256 value = _stethSharesToRebalance > 0 + ? Math.saturatingSub(assets, _getPooledEthBySharesRoundUp(_stethSharesToRebalance)) + : assets; - if (assets < MIN_WITHDRAWAL_AMOUNT) revert RequestAmountTooSmall(assets); - if (assets > MAX_WITHDRAWAL_AMOUNT) revert RequestAmountTooLarge(assets); + if (value < MIN_WITHDRAWAL_VALUE) revert RequestValueTooSmall(value); + if (assets > MAX_WITHDRAWAL_ASSETS) revert RequestAssetsTooLarge(assets); _transferForWithdrawalQueue(msg.sender, _stvToWithdraw, _stethSharesToRebalance); @@ -317,14 +373,14 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea cumulativeStv: cumulativeStv, cumulativeStethShares: uint128(cumulativeStethShares), cumulativeAssets: uint128(cumulativeAssets), - owner: _recipient, + owner: _owner, timestamp: uint40(block.timestamp), isClaimed: false }); - assert($.requestsByOwner[_recipient].add(requestId)); + assert($.requestsByOwner[_owner].add(requestId)); - emit WithdrawalRequested(requestId, _recipient, _stvToWithdraw, _stethSharesToRebalance, assets); + emit WithdrawalRequested(requestId, _owner, _stvToWithdraw, _stethSharesToRebalance, assets); } function _transferForWithdrawalQueue(address _from, uint256 _stv, uint256 _stethShares) internal { @@ -335,6 +391,42 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea } } + function _getPooledEthBySharesRoundUp(uint256 _stethShares) internal view returns (uint256 ethAmount) { + ethAmount = STETH.getPooledEthBySharesRoundUp(_stethShares); + } + + // ================================================================================= + // GAS COST COVERAGE + // ================================================================================= + + /** + * @notice Set the gas cost coverage that applies to each request during finalization + * @param _coverage The gas cost coverage per request in wei + * @dev Reverts if `_coverage` is greater than `MAX_GAS_COST_COVERAGE` + * @dev 0 by default. Increasing coverage discourages malicious actors from creating + * excessive requests while compensating finalizers for gas expenses + */ + function setFinalizationGasCostCoverage(uint256 _coverage) external { + _checkRole(FINALIZE_ROLE, msg.sender); + + _setFinalizationGasCostCoverage(_coverage); + } + + function _setFinalizationGasCostCoverage(uint256 _coverage) internal { + if (_coverage > MAX_GAS_COST_COVERAGE) revert GasCostCoverageTooLarge(_coverage); + + _getWithdrawalQueueStorage().gasCostCoverage = uint64(_coverage); + emit GasCostCoverageSet(_coverage); + } + + /** + * @notice Get the current gas cost coverage that applies to each request during finalization + * @return coverage The gas cost coverage per request in wei + */ + function getFinalizationGasCostCoverage() external view returns (uint256 coverage) { + coverage = _getWithdrawalQueueStorage().gasCostCoverage; + } + // ================================================================================= // FINALIZATION // ================================================================================= @@ -347,16 +439,16 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea /** * @notice Finalize withdrawal requests * @param _maxRequests The maximum number of requests to finalize + * @param _gasCostCoverageRecipient The address to receive gas cost coverage * @return finalizedRequests The number of requests that were finalized - * @dev MIN_WITHDRAWAL_AMOUNT is used to prevent DoS attacks by placing many small requests * @dev Reverts if there are no requests to finalize */ - function finalize(uint256 _maxRequests) external returns (uint256 finalizedRequests) { - if (!isEmergencyExitActivated()) { - _requireNotPaused(); - _checkRole(FINALIZE_ROLE, msg.sender); - } - + function finalize(uint256 _maxRequests, address _gasCostCoverageRecipient) + external + returns (uint256 finalizedRequests) + { + _checkFeatureNotPaused(FINALIZE_FEATURE); + _checkRole(FINALIZE_ROLE, msg.sender); _checkFreshReport(); WithdrawalQueueStorage storage $ = _getWithdrawalQueueStorage(); @@ -367,57 +459,87 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea if (firstRequestIdToFinalize > lastRequestIdToFinalize) revert NoRequestsToFinalize(); + // Collect necessary data for finalization uint256 currentStvRate = calculateCurrentStvRate(); uint256 currentStethShareRate = calculateCurrentStethShareRate(); uint256 withdrawableValue = DASHBOARD.withdrawableValue(); - uint256 availableBalance = STAKING_VAULT.availableBalance(); + uint256 availableBalance = VAULT.availableBalance(); uint256 exceedingSteth = _getExceedingMintedSteth(); uint256 latestReportTimestamp = LAZY_ORACLE.latestReportTimestamp(); uint256 totalStvToBurn; uint256 totalStethShares; uint256 totalEthToClaim; + uint256 totalGasCoverage; uint256 maxStvToRebalance; - // Finalize all requests in the range + Checkpoint memory checkpoint = Checkpoint({ + fromRequestId: firstRequestIdToFinalize, + stvRate: currentStvRate, + stethShareRate: uint128(currentStethShareRate), + gasCostCoverage: $.gasCostCoverage + }); + + // Finalize requests one by one until conditions are met for (uint256 i = firstRequestIdToFinalize; i <= lastRequestIdToFinalize; ++i) { - WithdrawalRequest memory request = $.requests[i]; + WithdrawalRequest memory currRequest = $.requests[i]; WithdrawalRequest memory prevRequest = $.requests[i - 1]; - (uint256 stv, uint256 ethToClaim, uint256 stethSharesToRebalance, uint256 stethToRebalance) = - _calcRequestStats(prevRequest, request, currentStvRate, currentStethShareRate); - - uint256 stvToRebalance = - Math.mulDiv(stethToRebalance, E36_PRECISION_BASE, currentStvRate, Math.Rounding.Ceil); - - // Cap stvToRebalance to stv in the request, the rest will be socialized to users - if (stvToRebalance > stv) { - stvToRebalance = stv; - } + // Calculate amounts for the request + // - stv: amount of stv requested to withdraw + // - ethToClaim: amount of ETH that can be claimed for this request, excluding rebalancing and fees + // - stethSharesToRebalance: amount of steth shares to rebalance for this request + // - stethToRebalance: amount of steth corresponding to stethSharesToRebalance at the current rate + // - gasCostCoverage: amount of ETH that should be subtracted as gas cost coverage for this request + ( + uint256 stv, + uint256 ethToClaim, + uint256 stethSharesToRebalance, + uint256 stethToRebalance, + uint256 gasCostCoverage + ) = _calcRequestAmounts(prevRequest, currRequest, checkpoint); + + // Handle rebalancing if applicable uint256 ethToRebalance; - - // Exceeding stETH (if any) are used to cover rebalancing need without withdrawing ETH from the vault - if (exceedingSteth > stethToRebalance) { - exceedingSteth -= stethToRebalance; - } else { - exceedingSteth = 0; - ethToRebalance = stethToRebalance - exceedingSteth; + uint256 stvToRebalance; + + if (stethToRebalance > 0) { + // Determine how much stv should be burned in exchange for the steth shares + stvToRebalance = Math.mulDiv(stethToRebalance, E36_PRECISION_BASE, currentStvRate, Math.Rounding.Ceil); + + // Cap stvToRebalance to requested stv. The rest (if any) will be socialized to users + // When creating a request, user transfers stv and liability to the withdrawal queue with the necessary reserve + // However, while waiting for finalization in the withdrawal queue, the position may become undercollateralized + // In this case, the loss is shared among all participants + if (stvToRebalance > stv) stvToRebalance = stv; + + // Exceeding minted stETH (if any) are used to cover rebalancing need without withdrawing ETH from the vault + // Thus, Exceeding minted stETH aims to be reduced to 0 + if (exceedingSteth > stethToRebalance) { + exceedingSteth -= stethToRebalance; + } else { + ethToRebalance = stethToRebalance - exceedingSteth; + exceedingSteth = 0; + } } if ( - // stop if insufficient ETH to cover this request - // stop if not enough time has passed since the request was created - // stop if the request was created after the latest report was published, at least one oracle report is required - ethToClaim > withdrawableValue || ethToClaim + ethToRebalance > availableBalance - || request.timestamp + MIN_WITHDRAWAL_DELAY_TIME_IN_SECONDS > block.timestamp - || request.timestamp > latestReportTimestamp + // Stop if insufficient withdrawable ETH to cover claimable ETH for this request + // Stop if insufficient available ETH to cover claimable and rebalancable ETH for this request + // Stop if not enough time has passed since the request was created + // Stop if the request was created after the latest report was published, at least one oracle report is required + (ethToClaim + gasCostCoverage) > withdrawableValue + || (ethToClaim + ethToRebalance + gasCostCoverage) > availableBalance + || currRequest.timestamp + MIN_WITHDRAWAL_DELAY_TIME_IN_SECONDS > block.timestamp + || currRequest.timestamp > latestReportTimestamp ) { break; } - withdrawableValue -= ethToClaim; - availableBalance -= (ethToClaim + ethToRebalance); + withdrawableValue -= (ethToClaim + gasCostCoverage); + availableBalance -= (ethToClaim + gasCostCoverage + ethToRebalance); totalEthToClaim += ethToClaim; + totalGasCoverage += gasCostCoverage; totalStvToBurn += (stv - stvToRebalance); totalStethShares += stethSharesToRebalance; maxStvToRebalance += stvToRebalance; @@ -429,7 +551,12 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea // 1. Withdraw ETH from the vault to cover finalized requests and burn associated stv // Eth to claim or stv to burn could be 0 if all requests are going to be rebalanced // Rebalance cannot be done first because it will withdraw eth without unlocking it - if (totalEthToClaim > 0) DASHBOARD.withdraw(address(this), totalEthToClaim); + uint256 totalEthToWithdraw = totalEthToClaim + totalGasCoverage; + if (totalEthToWithdraw > 0) { + uint256 balanceBefore = address(this).balance; + DASHBOARD.withdraw(address(this), totalEthToWithdraw); + assert(address(this).balance - balanceBefore == totalEthToWithdraw); + } if (totalStvToBurn > 0) POOL.burnStvForWithdrawalQueue(totalStvToBurn); // 2. Rebalance steth shares by burning corresponding amount stv. Or socialize the losses if not enough stv @@ -437,16 +564,19 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea // So it may burn less stv than maxStvToRebalance because of new stv rate uint256 totalStvRebalanced; if (totalStethShares > 0) { + assert(IS_REBALANCING_SUPPORTED); + // Stv burning is limited at this point by maxStvToRebalance calculated above // to make sure that only stv of finalized requests is used for rebalancing - totalStvRebalanced = POOL.rebalanceMintedStethShares(totalStethShares, maxStvToRebalance); + totalStvRebalanced = POOL.rebalanceMintedStethSharesForWithdrawalQueue(totalStethShares, maxStvToRebalance); } // 3. Burn any remaining stv that was not used for rebalancing // The rebalancing may burn less stv than maxStvToRebalance because of: // - the changed stv rate after the first step // - accumulated rounding errors in maxStvToRebalance - // It's guaranteed that maxStvToRebalance >= totalStvRebalanced + // + // It's guaranteed by POOL.rebalanceMintedStethSharesForWithdrawalQueue() that maxStvToRebalance >= totalStvRebalanced uint256 remainingStvForRebalance = maxStvToRebalance - totalStvRebalanced; if (remainingStvForRebalance > 0) { POOL.burnStvForWithdrawalQueue(remainingStvForRebalance); @@ -455,20 +585,28 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea lastFinalizedRequestId = lastFinalizedRequestId + finalizedRequests; - // Create checkpoint with stvRate and stethShareRate + // Store checkpoint with current stvRate, stethShareRate and gasCostCoverage uint256 lastCheckpointIndex = $.lastCheckpointIndex + 1; - $.checkpoints[lastCheckpointIndex] = Checkpoint({ - fromRequestId: firstRequestIdToFinalize, stvRate: currentStvRate, stethShareRate: currentStethShareRate - }); - + $.checkpoints[lastCheckpointIndex] = checkpoint; $.lastCheckpointIndex = uint96(lastCheckpointIndex); + $.lastFinalizedRequestId = uint96(lastFinalizedRequestId); $.totalLockedAssets += uint96(totalEthToClaim); + // Send gas coverage to the caller + if (totalGasCoverage > 0) { + // Set gas cost coverage recipient to msg.sender if not specified + if (_gasCostCoverageRecipient == address(0)) _gasCostCoverageRecipient = msg.sender; + + (bool success,) = _gasCostCoverageRecipient.call{value: totalGasCoverage}(""); + if (!success) revert CantSendValueRecipientMayHaveReverted(); + } + emit WithdrawalsFinalized( firstRequestIdToFinalize, lastFinalizedRequestId, totalEthToClaim, + totalGasCoverage, totalStvToBurn, totalStvRebalanced, totalStethShares, @@ -493,8 +631,8 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea * @return stvRate Current stv rate of the vault (1e27 precision) */ function calculateCurrentStvRate() public view returns (uint256 stvRate) { - uint256 totalStv = POOL.totalSupply(); // e27 precision - uint256 totalAssets = POOL.totalAssets(); // e18 precision + uint256 totalStv = POOL.totalSupply(); // 1e27 precision + uint256 totalAssets = POOL.totalAssets(); // 1e18 precision if (totalStv == 0) return E27_PRECISION_BASE; stvRate = (totalAssets * E36_PRECISION_BASE) / totalStv; @@ -505,7 +643,7 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea * @return stethShareRate Current stETH share rate (1e27 precision) */ function calculateCurrentStethShareRate() public view returns (uint256 stethShareRate) { - stethShareRate = STETH.getPooledEthBySharesRoundUp(E27_PRECISION_BASE); + stethShareRate = _getPooledEthBySharesRoundUp(E27_PRECISION_BASE); } // ================================================================================= @@ -513,7 +651,7 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea // ================================================================================= /** - * @notice Claim a batch of withdrawal requests + * @notice Claim a batch of withdrawal requests once finalized sending locked ether to the recipient * @param _recipient Address where claimed ether will be sent to * @param _requestIds Array of request ids to claim * @param _hints Checkpoint hints. can be found with `findCheckpointHintBatch(_requestIds, 1, getLastCheckpointIndex())` @@ -532,7 +670,7 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea } /** - * @notice Claim one `_requestId` request once finalized sending locked ether to the owner + * @notice Claim one `_requestId` request once finalized sending locked ether to the recipient * @param _recipient Address where claimed ether will be sent to * @param _requestId Request id to claim * @dev @@ -821,37 +959,49 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea } WithdrawalRequest memory prevRequest = $.requests[_requestId - 1]; - (, claimableEth,,) = _calcRequestStats(prevRequest, _request, checkpoint.stvRate, checkpoint.stethShareRate); + (, claimableEth,,,) = _calcRequestAmounts(prevRequest, _request, checkpoint); } - function _calcRequestStats( + function _calcRequestAmounts( WithdrawalRequest memory _prevRequest, WithdrawalRequest memory _request, - uint256 finalizationStvRate, - uint256 stethShareRate + Checkpoint memory _checkpoint ) internal pure - returns (uint256 stv, uint256 assetsToClaim, uint256 stethSharesToRebalance, uint256 assetsToRebalance) + returns ( + uint256 stv, + uint256 assetsToClaim, + uint256 stethSharesToRebalance, + uint256 assetsToRebalance, + uint256 gasCostCoverage + ) { stv = _request.cumulativeStv - _prevRequest.cumulativeStv; stethSharesToRebalance = _request.cumulativeStethShares - _prevRequest.cumulativeStethShares; assetsToClaim = _request.cumulativeAssets - _prevRequest.cumulativeAssets; + // Calculate stv rate at the time of request creation uint256 requestStvRate = (assetsToClaim * E36_PRECISION_BASE) / stv; // Apply discount if the request stv rate is above the finalization stv rate - if (requestStvRate > finalizationStvRate) { - assetsToClaim = Math.mulDiv(stv, finalizationStvRate, E36_PRECISION_BASE, Math.Rounding.Floor); + if (requestStvRate > _checkpoint.stvRate) { + assetsToClaim = Math.mulDiv(stv, _checkpoint.stvRate, E36_PRECISION_BASE, Math.Rounding.Floor); } if (stethSharesToRebalance > 0) { assetsToRebalance = - Math.mulDiv(stethSharesToRebalance, stethShareRate, E27_PRECISION_BASE, Math.Rounding.Ceil); + Math.mulDiv(stethSharesToRebalance, _checkpoint.stethShareRate, E27_PRECISION_BASE, Math.Rounding.Ceil); // Decrease assets to claim by the amount of assets to rebalance assetsToClaim = Math.saturatingSub(assetsToClaim, assetsToRebalance); } + + // Apply request finalization gas cost coverage + if (_checkpoint.gasCostCoverage > 0) { + gasCostCoverage = Math.min(assetsToClaim, _checkpoint.gasCostCoverage); + assetsToClaim -= gasCostCoverage; + } } // ================================================================================= @@ -860,11 +1010,11 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea /** * @notice Return the number of unfinalized requests in the queue - * @return requestNumber Number of unfinalized requests + * @return requestsNumber Number of unfinalized requests */ - function unfinalizedRequestNumber() external view returns (uint256 requestNumber) { + function unfinalizedRequestsNumber() external view returns (uint256 requestsNumber) { WithdrawalQueueStorage storage $ = _getWithdrawalQueueStorage(); - requestNumber = $.lastRequestId - $.lastFinalizedRequestId; + requestsNumber = $.lastRequestId - $.lastFinalizedRequestId; } /** @@ -916,61 +1066,17 @@ contract WithdrawalQueue is AccessControlEnumerableUpgradeable, PausableUpgradea requestId = _getWithdrawalQueueStorage().lastFinalizedRequestId; } - // ================================================================================= - // EMERGENCY EXIT - // ================================================================================= - - /** - * @notice Returns true if Emergency Exit is activated - * @return isActivated True if Emergency Exit is activated - */ - function isEmergencyExitActivated() public view returns (bool isActivated) { - isActivated = _getWithdrawalQueueStorage().emergencyExitActivationTimestamp > 0; - } - - /** - * @notice Returns true if requests have not been finalized for a long time - * @return isStuck True if Withdrawal Queue is stuck - */ - function isWithdrawalQueueStuck() public view returns (bool isStuck) { - WithdrawalQueueStorage storage $ = _getWithdrawalQueueStorage(); - if ($.lastFinalizedRequestId >= $.lastRequestId) return false; - - uint256 firstPendingRequest = $.lastFinalizedRequestId + 1; - uint256 firstPendingRequestTimestamp = $.requests[firstPendingRequest].timestamp; - uint256 maxAcceptableTime = firstPendingRequestTimestamp + MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS; - - isStuck = maxAcceptableTime < block.timestamp; - } - - /** - * @notice Permissionless method to activate Emergency Exit - * @dev Can only be called if Withdrawal Queue is stuck - */ - function activateEmergencyExit() external { - WithdrawalQueueStorage storage $ = _getWithdrawalQueueStorage(); - if ($.emergencyExitActivationTimestamp > 0 || !isWithdrawalQueueStuck()) { - revert InvalidEmergencyExitActivation(); - } - - $.emergencyExitActivationTimestamp = uint40(block.timestamp); - - emit EmergencyExitActivated($.emergencyExitActivationTimestamp); - } - // ================================================================================= // CHECKS // ================================================================================= - function _checkArrayLength(uint256 firstArrayLength, uint256 secondArrayLength) internal pure { - if (firstArrayLength != secondArrayLength) revert ArraysLengthMismatch(firstArrayLength, secondArrayLength); - } - - function _checkResumedOrEmergencyExit() internal view { - if (!isEmergencyExitActivated()) _requireNotPaused(); + function _checkArrayLength(uint256 _firstArrayLength, uint256 _secondArrayLength) internal pure { + if (_firstArrayLength != _secondArrayLength) { + revert ArraysLengthMismatch(_firstArrayLength, _secondArrayLength); + } } function _checkFreshReport() internal view { - if (!VAULT_HUB.isReportFresh(address(STAKING_VAULT))) revert VaultReportStale(); + if (!VAULT_HUB.isReportFresh(address(VAULT))) revert VaultReportStale(); } } diff --git a/src/factories/DistributorFactory.sol b/src/factories/DistributorFactory.sol index f69e9b6..a851bd8 100644 --- a/src/factories/DistributorFactory.sol +++ b/src/factories/DistributorFactory.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Distributor} from "src/Distributor.sol"; diff --git a/src/factories/GGVStrategyFactory.sol b/src/factories/GGVStrategyFactory.sol index d39487e..6e4a248 100644 --- a/src/factories/GGVStrategyFactory.sol +++ b/src/factories/GGVStrategyFactory.sol @@ -1,16 +1,26 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {IStrategyFactory} from "src/interfaces/IStrategyFactory.sol"; import {GGVStrategy} from "src/strategy/GGVStrategy.sol"; import {StrategyCallForwarder} from "src/strategy/StrategyCallForwarder.sol"; contract GGVStrategyFactory is IStrategyFactory { - function deploy(address _pool, address _steth, address _wsteth, address _teller, address _boringQueue) - external - returns (address impl) - { + bytes32 public immutable STRATEGY_ID = keccak256("strategy.ggv.v1"); + address public immutable TELLER; + address public immutable BORING_QUEUE; + + constructor(address _teller, address _boringQueue) { + require(_teller.code.length > 0, "TELLER: not a contract"); + require(_boringQueue.code.length > 0, "BORING_QUEUE: not a contract"); + TELLER = _teller; + BORING_QUEUE = _boringQueue; + } + + function deploy(address _pool, bytes calldata _deployBytes) external returns (address impl) { + // _deployBytes is unused for GGVStrategy, but required by IStrategyFactory interface + _deployBytes; address strategyCallForwarderImpl = address(new StrategyCallForwarder()); - impl = address(new GGVStrategy(strategyCallForwarderImpl, _pool, _steth, _wsteth, _teller, _boringQueue)); + impl = address(new GGVStrategy(STRATEGY_ID, strategyCallForwarderImpl, _pool, TELLER, BORING_QUEUE)); } } diff --git a/src/factories/LoopStrategyFactory.sol b/src/factories/LoopStrategyFactory.sol deleted file mode 100644 index 4301879..0000000 --- a/src/factories/LoopStrategyFactory.sol +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; - -import {DummyImplementation} from "src/proxy/DummyImplementation.sol"; - -contract LoopStrategyFactory { - function deploy( - address, - /* _steth */ - address, - /* _pool */ - uint256 /* _loops */ - ) - external - returns (address impl) - { - impl = address(new DummyImplementation()); - // impl = address(new LoopStrategy(_steth, _pool, _loops)); - } -} diff --git a/src/factories/StvPoolFactory.sol b/src/factories/StvPoolFactory.sol index 9625161..1e0ad4f 100644 --- a/src/factories/StvPoolFactory.sol +++ b/src/factories/StvPoolFactory.sol @@ -1,13 +1,16 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {StvPool} from "src/StvPool.sol"; contract StvPoolFactory { - function deploy(address _dashboard, bool _allowlistEnabled, address _withdrawalQueue, address _distributor) - external - returns (address impl) - { - impl = address(new StvPool(_dashboard, _allowlistEnabled, _withdrawalQueue, _distributor)); + function deploy( + address _dashboard, + bool _allowlistEnabled, + address _withdrawalQueue, + address _distributor, + bytes32 _poolType + ) external returns (address impl) { + impl = address(new StvPool(_dashboard, _allowlistEnabled, _withdrawalQueue, _distributor, _poolType)); } } diff --git a/src/factories/StvStETHPoolFactory.sol b/src/factories/StvStETHPoolFactory.sol index cfe5eda..b6af1e7 100644 --- a/src/factories/StvStETHPoolFactory.sol +++ b/src/factories/StvStETHPoolFactory.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {StvStETHPool} from "src/StvStETHPool.sol"; diff --git a/src/factories/TimelockFactory.sol b/src/factories/TimelockFactory.sol index 7be4d07..a81d802 100644 --- a/src/factories/TimelockFactory.sol +++ b/src/factories/TimelockFactory.sol @@ -1,15 +1,19 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; contract TimelockFactory { - function deploy(uint256 minDelaySeconds, address proposer, address executor) external returns (address timelock) { + function deploy(uint256 _minDelaySeconds, address _proposer, address _executor) + external + returns (address timelock) + { address[] memory proposers = new address[](1); - proposers[0] = proposer; + proposers[0] = _proposer; + address[] memory executors = new address[](1); - executors[0] = executor; - TimelockController tl = new TimelockController(minDelaySeconds, proposers, executors, address(0)); - timelock = address(tl); + executors[0] = _executor; + + timelock = address(new TimelockController(_minDelaySeconds, proposers, executors, address(0))); } } diff --git a/src/factories/WithdrawalQueueFactory.sol b/src/factories/WithdrawalQueueFactory.sol index ab16a78..6bdb47b 100644 --- a/src/factories/WithdrawalQueueFactory.sol +++ b/src/factories/WithdrawalQueueFactory.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; @@ -11,7 +11,6 @@ contract WithdrawalQueueFactory { address _steth, address _vault, address _lazyOracle, - uint256 _maxFinalizationTime, uint256 _minWithdrawalDelayTime, bool _isRebalancingSupported ) external returns (address impl) { @@ -23,7 +22,6 @@ contract WithdrawalQueueFactory { _steth, _vault, _lazyOracle, - _maxFinalizationTime, _minWithdrawalDelayTime, _isRebalancingSupported ) diff --git a/src/interfaces/IBasePool.sol b/src/interfaces/IBasePool.sol index 1987a68..64ec48c 100644 --- a/src/interfaces/IBasePool.sol +++ b/src/interfaces/IBasePool.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {IDashboard} from "./IDashboard.sol"; -import {IVaultHub} from "./IVaultHub.sol"; +import {IDashboard} from "./core/IDashboard.sol"; +import {IVaultHub} from "./core/IVaultHub.sol"; interface IBasePool { function STETH() external view returns (address); function DASHBOARD() external view returns (IDashboard); function VAULT_HUB() external view returns (IVaultHub); - function STAKING_VAULT() external view returns (address); + function VAULT() external view returns (address); function previewWithdraw(uint256 _assets) external view returns (uint256); function previewRedeem(uint256 _stv) external view returns (uint256); diff --git a/src/interfaces/IOperatorGrid.sol b/src/interfaces/IOperatorGrid.sol deleted file mode 100644 index b8f078f..0000000 --- a/src/interfaces/IOperatorGrid.sol +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; - -interface IOperatorGrid { - struct TierParams { - uint256 shareLimit; - uint256 reserveRatioBP; - uint256 forcedRebalanceThresholdBP; - uint256 infraFeeBP; - uint256 liquidityFeeBP; - uint256 reservationFeeBP; - } - - struct Tier { - address operator; - uint96 shareLimit; - uint96 liabilityShares; - uint16 reserveRatioBP; - uint16 forcedRebalanceThresholdBP; - uint16 infraFeeBP; - uint16 liquidityFeeBP; - uint16 reservationFeeBP; - } - - function tier(uint256 _tierId) external view returns (Tier memory); - - function effectiveShareLimit(address _vault) external view returns (uint256); - - function isVaultInJail(address _vault) external view returns (bool); - - function vaultTierInfo(address _vault) - external - view - returns ( - address nodeOperator, - uint256 tierId, - uint256 shareLimit, - uint256 reserveRatioBP, - uint256 forcedRebalanceThresholdBP, - uint256 infraFeeBP, - uint256 liquidityFeeBP, - uint256 reservationFeeBP - ); - - function alterTiers(uint256[] calldata _tierIds, TierParams[] calldata _tierParams) external; -} diff --git a/src/interfaces/IStrategy.sol b/src/interfaces/IStrategy.sol index daccc02..7da53e1 100644 --- a/src/interfaces/IStrategy.sol +++ b/src/interfaces/IStrategy.sol @@ -1,30 +1,97 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; interface IStrategy { - event StrategySupplied(address indexed user, uint256 stv, uint256 stethShares, uint256 stethAmount, bytes data); - event StrategyExitRequested(address indexed user, bytes32 requestId, uint256 stethSharesToBurn, bytes data); - event StrategyExitFinalized(address indexed user, bytes32 requestId, uint256 stethShares); - - /// @notice Supplies stETH to the strategy - function supply(address _referral, bytes calldata _params) external payable; - - /// @notice Requests a withdrawal from the Withdrawal Queue - function requestWithdrawal( - uint256 _stvToWithdraw, - uint256 _stethSharesToBurn, - uint256 _stethSharesToRebalance, - address _receiver - ) external returns (uint256 requestId); - - /// @notice Requests a withdrawal from the strategy - function requestExitByStethShares(uint256 stethSharesToBurn, bytes calldata params) + event StrategySupplied( + address indexed user, address indexed referral, uint256 ethAmount, uint256 stv, uint256 wstethToMint, bytes data + ); + event StrategyExitRequested(address indexed user, bytes32 requestId, uint256 wsteth, bytes data); + event StrategyExitFinalized(address indexed user, bytes32 requestId, uint256 wsteth); + + /** + * @notice Initializes the strategy + * @param _admin The admin address + */ + function initialize(address _admin) external; + + /** + * @notice Returns the address of the pool + * @return The address of the pool + */ + function POOL() external view returns (address); + + /** + * @notice Supplies wstETH to the strategy + * @param _referral The referral address + * @param _wstethToMint The amount of wstETH to mint + * @param _params The parameters for the supply + * @return stv The minted amount of stv + */ + function supply(address _referral, uint256 _wstethToMint, bytes calldata _params) external - returns (bytes32 requestId); + payable + returns (uint256 stv); + + /** + * @notice Returns the remaining minting capacity shares of a user + * @param _user The user to get the remaining minting capacity shares for + * @param _ethToFund The amount of ETH to fund + * @return stethShares The remaining minting capacity shares + */ + function remainingMintingCapacitySharesOf(address _user, uint256 _ethToFund) + external + view + returns (uint256 stethShares); + + /** + * @notice Requests exit from the strategy + * @param _wsteth The amount of wstETH to request exit for + * @param _params The parameters for the exit + * @return requestId The Strategy request id + */ + function requestExitByWsteth(uint256 _wsteth, bytes calldata _params) external returns (bytes32 requestId); + + /** + * @notice Finalizes exit from the strategy + * @param requestId The Strategy request id + */ + function finalizeRequestExit(bytes32 requestId) external; + + /** + * @notice Burns wstETH to reduce the user's minted stETH shares obligation + * @param _wstethToBurn The amount of wstETH to burn + */ + function burnWsteth(uint256 _wstethToBurn) external; + + /** + * @notice Requests a withdrawal from the Withdrawal Queue + * @param _recipient The address to receive the withdrawal + * @param _stvToWithdraw The amount of stv to withdraw + * @param _stethSharesToRebalance The amount of stETH shares to rebalance + * @return requestId The Withdrawal Queue request ID + */ + function requestWithdrawalFromPool(address _recipient, uint256 _stvToWithdraw, uint256 _stethSharesToRebalance) + external + returns (uint256 requestId); + + /** + * @notice Returns the amount of wstETH of a user + * @param _user The user to get the wstETH for + * @return wsteth The amount of wstETH + */ + function wstethOf(address _user) external view returns (uint256); - /// @notice Finalizes a withdrawal from the strategy - function finalizeRequestExit(address receiver, bytes32 requestId) external; + /** + * @notice Returns the amount of stv of a user + * @param _user The user to get the stv for + * @return stv The amount of stv + */ + function stvOf(address _user) external view returns (uint256); - /// @notice Recovers ERC20 tokens from the strategy - function recoverERC20(address _token, address _recipient, uint256 _amount) external; + /** + * @notice Returns the amount of minted stETH shares of a user + * @param _user The user to get the minted stETH shares for + * @return mintedStethShares The amount of minted stETH shares + */ + function mintedStethSharesOf(address _user) external view returns (uint256 mintedStethShares); } diff --git a/src/interfaces/IStrategyCallForwarder.sol b/src/interfaces/IStrategyCallForwarder.sol index 037c4e2..c4cd472 100644 --- a/src/interfaces/IStrategyCallForwarder.sol +++ b/src/interfaces/IStrategyCallForwarder.sol @@ -1,12 +1,9 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity >=0.8.25; +pragma solidity 0.8.30; interface IStrategyCallForwarder { function initialize(address _owner) external; - function call(address _target, bytes calldata _data) external payable returns (bytes memory); - function callWithValue(address _target, bytes calldata _data, uint256 _value) - external - payable - returns (bytes memory); - function sendValue(address payable _recipient, uint256 _amount) external payable; + function doCall(address _target, bytes calldata _data) external returns (bytes memory); + function doCallWithValue(address _target, bytes calldata _data, uint256 _value) external returns (bytes memory); + function sendValue(address payable _recipient, uint256 _amount) external; } diff --git a/src/interfaces/IStrategyFactory.sol b/src/interfaces/IStrategyFactory.sol index 313e152..76d06f3 100644 --- a/src/interfaces/IStrategyFactory.sol +++ b/src/interfaces/IStrategyFactory.sol @@ -1,17 +1,12 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; /// @title IStrategyFactory /// @notice Interface for strategy factory contracts like GGVStrategyFactory interface IStrategyFactory { /// @notice Deploys a new strategy contract instance /// @param _pool Address of the pool contract - /// @param _steth Address of the stETH token - /// @param _wsteth Address of the wstETH token - /// @param _teller Address of the teller contract - /// @param _boringQueue Address of the boring queue contract + /// @param deployBytes Strategy-specific deployment parameters (can be empty) /// @return impl The address of the newly deployed strategy contract - function deploy(address _pool, address _steth, address _wsteth, address _teller, address _boringQueue) - external - returns (address impl); + function deploy(address _pool, bytes calldata deployBytes) external returns (address impl); } diff --git a/src/interfaces/IStvStETHPool.sol b/src/interfaces/IStvStETHPool.sol index 1c2013b..8bc8f3e 100644 --- a/src/interfaces/IStvStETHPool.sol +++ b/src/interfaces/IStvStETHPool.sol @@ -1,13 +1,13 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {IBasePool} from "./IBasePool.sol"; -import {IDashboard} from "./IDashboard.sol"; -import {IVaultHub} from "./IVaultHub.sol"; +import {IDashboard} from "./core/IDashboard.sol"; +import {IVaultHub} from "./core/IVaultHub.sol"; interface IStvStETHPool is IBasePool { function totalExceedingMintedSteth() external view returns (uint256); - function rebalanceMintedStethShares(uint256 _stethShares, uint256 _maxStvToBurn) + function rebalanceMintedStethSharesForWithdrawalQueue(uint256 _stethShares, uint256 _maxStvToBurn) external returns (uint256 stvBurned); function transferFromWithLiabilityForWithdrawalQueue(address _from, uint256 _stv, uint256 _stethShares) external; diff --git a/src/interfaces/IDashboard.sol b/src/interfaces/core/IDashboard.sol similarity index 85% rename from src/interfaces/IDashboard.sol rename to src/interfaces/core/IDashboard.sol index 6776388..6b55d83 100644 --- a/src/interfaces/IDashboard.sol +++ b/src/interfaces/core/IDashboard.sol @@ -29,17 +29,11 @@ interface IDashboard is IAccessControlEnumerable { event RoleMemberConfirmed( address indexed member, bytes32 indexed role, uint256 confirmTimestamp, uint256 expiryTimestamp, bytes data ); - event NodeOperatorFeeSet(address indexed sender, uint256 oldNodeOperatorFeeRate, uint256 newNodeOperatorFeeRate); - event NodeOperatorFeeRecipientSet( - address indexed sender, address oldNodeOperatorFeeRecipient, address newNodeOperatorFeeRecipient - ); - event NodeOperatorFeeDisbursed( - address indexed recipient, - uint256 amount, - IVaultHub.VaultConnection vaultConnection, - Report feePeriodStartReport, - Report feePeriodEndReport - ); + event FeeRateSet(address indexed sender, uint256 oldFeeRate, uint256 newFeeRate); + event FeeRecipientSet(address indexed sender, address oldFeeRecipient, address newFeeRecipient); + event FeeDisbursed(address indexed sender, uint256 fee, address recipient); + event SettledGrowthSet(int256 oldSettledGrowth, int256 newSettledGrowth); + event CorrectionTimestampUpdated(uint256 timestamp); // ==================== Errors ==================== error ExceedsWithdrawable(uint256 amount, uint256 withdrawableValue); @@ -76,9 +70,11 @@ interface IDashboard is IAccessControlEnumerable { function VAULT_HUB() external view returns (address); function LIDO_LOCATOR() external view returns (address); function NODE_OPERATOR_MANAGER_ROLE() external view returns (bytes32); - function NODE_OPERATOR_REWARDS_ADJUST_ROLE() external view returns (bytes32); - function MANUAL_REWARDS_ADJUSTMENT_LIMIT() external view returns (uint256); - function RECOVER_ASSETS_ROLE() external view returns (bytes32); + function NODE_OPERATOR_FEE_EXEMPT_ROLE() external view returns (bytes32); + function NODE_OPERATOR_UNGUARANTEED_DEPOSIT_ROLE() external view returns (bytes32); + function NODE_OPERATOR_PROVE_UNKNOWN_VALIDATOR_ROLE() external view returns (bytes32); + function VAULT_CONFIGURATION_ROLE() external view returns (bytes32); + function COLLECT_VAULT_ERC20_ROLE() external view returns (bytes32); function DEFAULT_ADMIN_ROLE() external view returns (bytes32); function MIN_CONFIRM_EXPIRY() external view returns (uint256); function MAX_CONFIRM_EXPIRY() external view returns (uint256); @@ -124,15 +120,18 @@ interface IDashboard is IAccessControlEnumerable { function healthShortfallShares() external view returns (uint256); // ==================== Node Operator Fee Functions ==================== - function nodeOperatorFeeRate() external view returns (uint256); - function nodeOperatorFeeRecipient() external view returns (address); + function feeRecipient() external view returns (address); + function feeRate() external view returns (uint16); + function settledGrowth() external view returns (int128); + function latestCorrectionTimestamp() external view returns (uint64); function latestReport() external view returns (Report memory); - function nodeOperatorDisbursableFee() external view returns (uint256); - function disburseNodeOperatorFee() external; - function setNodeOperatorFeeRate(uint256 _newNodeOperatorFeeRate) external returns (bool); - function setNodeOperatorFeeRecipient(address _newNodeOperatorFeeRecipient) external; - function increaseRewardsAdjustment(uint256 _adjustmentIncrease) external; - function setRewardsAdjustment(uint256 _proposedAdjustment, uint256 _expectedAdjustment) external returns (bool); + function accruedFee() external view returns (uint256); + function disburseFee() external; + function disburseAbnormallyHighFee() external; + function setFeeRate(uint256 _newFeeRate) external returns (bool); + function correctSettledGrowth(int256 _newSettledGrowth, int256 _expectedSettledGrowth) external returns (bool); + function addFeeExemption(uint256 _exemptedAmount) external; + function setFeeRecipient(address _newFeeRecipient) external; // ==================== Confirmation Functions ==================== function confirmingRoles() external pure returns (bytes32[] memory); diff --git a/src/interfaces/ILazyOracle.sol b/src/interfaces/core/ILazyOracle.sol similarity index 100% rename from src/interfaces/ILazyOracle.sol rename to src/interfaces/core/ILazyOracle.sol diff --git a/src/interfaces/ILido.sol b/src/interfaces/core/ILido.sol similarity index 100% rename from src/interfaces/ILido.sol rename to src/interfaces/core/ILido.sol diff --git a/src/interfaces/ILidoLocator.sol b/src/interfaces/core/ILidoLocator.sol similarity index 100% rename from src/interfaces/ILidoLocator.sol rename to src/interfaces/core/ILidoLocator.sol diff --git a/src/interfaces/core/IOperatorGrid.sol b/src/interfaces/core/IOperatorGrid.sol new file mode 100644 index 0000000..e62e25e --- /dev/null +++ b/src/interfaces/core/IOperatorGrid.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.25; + +import {IAccessControlEnumerable} from "@openzeppelin/contracts/access/extensions/IAccessControlEnumerable.sol"; + +interface IOperatorGrid is IAccessControlEnumerable { + event GroupAdded(address indexed nodeOperator, uint256 shareLimit); + event GroupShareLimitUpdated(address indexed nodeOperator, uint256 shareLimit); + event TierAdded( + address indexed nodeOperator, + uint256 indexed tierId, + uint256 shareLimit, + uint256 reserveRatioBP, + uint256 forcedRebalanceThresholdBP, + uint256 infraFeeBP, + uint256 liquidityFeeBP, + uint256 reservationFeeBP + ); + event TierChanged(address indexed vault, uint256 indexed tierId, uint256 shareLimit); + event TierUpdated( + uint256 indexed tierId, + uint256 shareLimit, + uint256 reserveRatioBP, + uint256 forcedRebalanceThresholdBP, + uint256 infraFeeBP, + uint256 liquidityFeeBP, + uint256 reservationFeeBP + ); + event VaultJailStatusUpdated(address indexed vault, bool isInJail); + + struct TierParams { + uint256 shareLimit; + uint256 reserveRatioBP; + uint256 forcedRebalanceThresholdBP; + uint256 infraFeeBP; + uint256 liquidityFeeBP; + uint256 reservationFeeBP; + } + + struct Tier { + address operator; + uint96 shareLimit; + uint96 liabilityShares; + uint16 reserveRatioBP; + uint16 forcedRebalanceThresholdBP; + uint16 infraFeeBP; + uint16 liquidityFeeBP; + uint16 reservationFeeBP; + } + + function LIDO_LOCATOR() external view returns (address); + function REGISTRY_ROLE() external view returns (bytes32); + function DEFAULT_TIER_ID() external view returns (uint256); + function DEFAULT_TIER_OPERATOR() external view returns (address); + + function tier(uint256 _tierId) external view returns (Tier memory); + + function tiersCount() external view returns (uint256); + + function effectiveShareLimit(address _vault) external view returns (uint256); + + function isVaultInJail(address _vault) external view returns (bool); + + function vaultTierInfo(address _vault) + external + view + returns ( + address nodeOperator, + uint256 tierId, + uint256 shareLimit, + uint256 reserveRatioBP, + uint256 forcedRebalanceThresholdBP, + uint256 infraFeeBP, + uint256 liquidityFeeBP, + uint256 reservationFeeBP + ); + + function alterTiers(uint256[] calldata _tierIds, TierParams[] calldata _tierParams) external; + + function registerTiers(address _nodeOperator, TierParams[] calldata _tiers) external; + + function registerGroup(address _nodeOperator, uint256 _shareLimit) external; +} diff --git a/src/interfaces/IOssifiableProxy.sol b/src/interfaces/core/IOssifiableProxy.sol similarity index 100% rename from src/interfaces/IOssifiableProxy.sol rename to src/interfaces/core/IOssifiableProxy.sol diff --git a/src/interfaces/IStETH.sol b/src/interfaces/core/IStETH.sol similarity index 100% rename from src/interfaces/IStETH.sol rename to src/interfaces/core/IStETH.sol diff --git a/src/interfaces/IStakingVault.sol b/src/interfaces/core/IStakingVault.sol similarity index 100% rename from src/interfaces/IStakingVault.sol rename to src/interfaces/core/IStakingVault.sol diff --git a/src/interfaces/IVaultFactory.sol b/src/interfaces/core/IVaultFactory.sol similarity index 100% rename from src/interfaces/IVaultFactory.sol rename to src/interfaces/core/IVaultFactory.sol diff --git a/src/interfaces/IVaultHub.sol b/src/interfaces/core/IVaultHub.sol similarity index 100% rename from src/interfaces/IVaultHub.sol rename to src/interfaces/core/IVaultHub.sol diff --git a/src/interfaces/IWstETH.sol b/src/interfaces/core/IWstETH.sol similarity index 98% rename from src/interfaces/IWstETH.sol rename to src/interfaces/core/IWstETH.sol index 8c8e7fd..1802362 100644 --- a/src/interfaces/IWstETH.sol +++ b/src/interfaces/core/IWstETH.sol @@ -1,4 +1,4 @@ -// src/interfaces/IWstETH.sol +// src/interfaces/core/IWstETH.sol pragma solidity >=0.8.25; import {IStETH} from "./IStETH.sol"; diff --git a/src/interfaces/ggv/IBoringOnChainQueue.sol b/src/interfaces/ggv/IBoringOnChainQueue.sol index 52e29d3..78cbee3 100644 --- a/src/interfaces/ggv/IBoringOnChainQueue.sol +++ b/src/interfaces/ggv/IBoringOnChainQueue.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; interface IBoringOnChainQueue { /** @@ -57,12 +57,9 @@ interface IBoringOnChainQueue { uint96 minimumShares ) external; - function requestOnChainWithdraw( - address assetOut, - uint128 amountOfShares, - uint16 discount, - uint24 secondsToDeadline - ) external returns (bytes32 requestId); + function requestOnChainWithdraw(address assetOut, uint128 amountOfShares, uint16 discount, uint24 secondsToDeadline) + external + returns (bytes32 requestId); function cancelOnChainWithdraw(OnChainWithdraw memory request) external returns (bytes32 requestId); function replaceOnChainWithdraw(OnChainWithdraw memory oldRequest, uint16 discount, uint24 secondsToDeadline) external diff --git a/src/interfaces/ggv/IBoringSolver.sol b/src/interfaces/ggv/IBoringSolver.sol index bd8b12e..fe44bf9 100644 --- a/src/interfaces/ggv/IBoringSolver.sol +++ b/src/interfaces/ggv/IBoringSolver.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {IBoringOnChainQueue} from "./IBoringOnChainQueue.sol"; diff --git a/src/interfaces/ggv/ITellerWithMultiAssetSupport.sol b/src/interfaces/ggv/ITellerWithMultiAssetSupport.sol index c3cfffc..e86da40 100644 --- a/src/interfaces/ggv/ITellerWithMultiAssetSupport.sol +++ b/src/interfaces/ggv/ITellerWithMultiAssetSupport.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; diff --git a/src/mock/LenderMock.sol b/src/mock/LenderMock.sol index 136096f..e448837 100644 --- a/src/mock/LenderMock.sol +++ b/src/mock/LenderMock.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; diff --git a/src/mock/ggv/BorrowedMath.sol b/src/mock/ggv/BorrowedMath.sol index 85ba328..a25f019 100644 --- a/src/mock/ggv/BorrowedMath.sol +++ b/src/mock/ggv/BorrowedMath.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; library BorrowedMath { uint256 internal constant MAX_UINT256 = 2 ** 256 - 1; diff --git a/src/mock/ggv/GGVMockTeller.sol b/src/mock/ggv/GGVMockTeller.sol index 1753079..d828cee 100644 --- a/src/mock/ggv/GGVMockTeller.sol +++ b/src/mock/ggv/GGVMockTeller.sol @@ -1,10 +1,11 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {GGVVaultMock} from "./GGVVaultMock.sol"; -import {IStETH} from "src/interfaces/IStETH.sol"; +import {IStETH} from "src/interfaces/core/IStETH.sol"; +import {IWstETH} from "src/interfaces/core/IWstETH.sol"; import {ITellerWithMultiAssetSupport} from "src/interfaces/ggv/ITellerWithMultiAssetSupport.sol"; contract GGVMockTeller is ITellerWithMultiAssetSupport { @@ -18,6 +19,7 @@ contract GGVMockTeller is ITellerWithMultiAssetSupport { GGVVaultMock public immutable _vault; uint256 internal immutable ONE_SHARE; IStETH public immutable steth; + IWstETH public immutable wsteth; mapping(ERC20 asset => Asset) public assets; @@ -27,12 +29,13 @@ contract GGVMockTeller is ITellerWithMultiAssetSupport { owner = _owner; _vault = GGVVaultMock(__vault); steth = IStETH(_steth); + wsteth = IWstETH(_wsteth); // eq to 10 ** vault.decimals() ONE_SHARE = 10 ** 18; _updateAssetData(ERC20(_steth), true, false, 0); - _updateAssetData(ERC20(_wsteth), false, true, 0); + _updateAssetData(ERC20(_wsteth), true, true, 0); } function deposit(ERC20 depositAsset, uint256 depositAmount, uint256 minimumMint, address referralAddress) @@ -47,7 +50,14 @@ contract GGVMockTeller is ITellerWithMultiAssetSupport { revert("Deposit amount must be greater than 0"); } - uint256 stethShares = steth.getSharesByPooledEth(depositAmount); + uint256 stethShares; + if (address(depositAsset) == address(steth)) { + stethShares = steth.getSharesByPooledEth(depositAmount); + } else if (address(depositAsset) == address(wsteth)) { + stethShares = depositAmount; + } else { + revert("Unsupported asset"); + } // hardcode share calculation for only steth shares = _vault.getSharesByAssets(stethShares); diff --git a/src/mock/ggv/GGVQueueMock.sol b/src/mock/ggv/GGVQueueMock.sol index 7297a60..486c647 100644 --- a/src/mock/ggv/GGVQueueMock.sol +++ b/src/mock/ggv/GGVQueueMock.sol @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {BorrowedMath} from "./BorrowedMath.sol"; import {GGVVaultMock} from "./GGVVaultMock.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {IStETH} from "src/interfaces/IStETH.sol"; -import {IWstETH} from "src/interfaces/IWstETH.sol"; +import {IStETH} from "src/interfaces/core/IStETH.sol"; +import {IWstETH} from "src/interfaces/core/IWstETH.sol"; import {IBoringOnChainQueue} from "src/interfaces/ggv/IBoringOnChainQueue.sol"; import {console} from "forge-std/console.sol"; diff --git a/src/mock/ggv/GGVVaultMock.sol b/src/mock/ggv/GGVVaultMock.sol index 0cfb388..225080e 100644 --- a/src/mock/ggv/GGVVaultMock.sol +++ b/src/mock/ggv/GGVVaultMock.sol @@ -1,12 +1,13 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {BorrowedMath} from "./BorrowedMath.sol"; import {GGVMockTeller} from "./GGVMockTeller.sol"; import {GGVQueueMock} from "./GGVQueueMock.sol"; -import {IStETH} from "src/interfaces/IStETH.sol"; +import {IStETH} from "src/interfaces/core/IStETH.sol"; +import {IWstETH} from "src/interfaces/core/IWstETH.sol"; import {IBoringOnChainQueue} from "src/interfaces/ggv/IBoringOnChainQueue.sol"; import {ITellerWithMultiAssetSupport} from "src/interfaces/ggv/ITellerWithMultiAssetSupport.sol"; @@ -15,34 +16,64 @@ contract GGVVaultMock is ERC20 { ITellerWithMultiAssetSupport public immutable TELLER; GGVQueueMock public immutable BORING_QUEUE; IStETH public immutable steth; + IWstETH public immutable wsteth; // steth shares as base vault asset // real ggv uses weth but it should be okay to peg it to steth shares for mock uint256 public _totalAssets; + error OnlyOwner(); + error OnlyTeller(); + error OnlyQueue(); + constructor(address _owner, address _steth, address _wsteth) ERC20("GGVVaultMock", "tGGV") { owner = _owner; TELLER = ITellerWithMultiAssetSupport(address(new GGVMockTeller(_owner, address(this), _steth, _wsteth))); BORING_QUEUE = new GGVQueueMock(address(this), _steth, _wsteth, _owner); steth = IStETH(_steth); + wsteth = IWstETH(_wsteth); // Mint some initial tokens to the dead address to avoid zero totalSupply issues _mint(address(0xdead), 1e18); _totalAssets = 1e18; } - function rebase(uint256 stethSharesToRebaseWith) external { - require(msg.sender == owner, "Only owner can rebase"); - steth.transferSharesFrom(msg.sender, address(this), stethSharesToRebaseWith); - _totalAssets += stethSharesToRebaseWith; + function _onlyOwner() internal view { + if (msg.sender != owner) revert OnlyOwner(); + } + + function _onlyTeller() internal view { + if (msg.sender != address(TELLER)) revert OnlyTeller(); } - function negativeRebase(uint256 stethSharesToRebaseWith) external { - require(msg.sender == owner, "Only owner can rebase"); + function _onlyQueue() internal view { + if (msg.sender != address(BORING_QUEUE)) revert OnlyQueue(); + } + + function rebaseSteth(uint256 _stethShares) external { + _onlyOwner(); + steth.transferSharesFrom(msg.sender, address(this), _stethShares); + _totalAssets += _stethShares; + } + + function negativeRebaseSteth(uint256 stethSharesToRebaseWith) external { + _onlyOwner(); steth.transferShares(msg.sender, stethSharesToRebaseWith); _totalAssets -= stethSharesToRebaseWith; } + function rebaseWsteth(uint256 wstethAmount) external { + _onlyOwner(); + wsteth.transferFrom(msg.sender, address(this), wstethAmount); + _totalAssets += wstethAmount; + } + + function negativeRebaseWsteth(uint256 wstethAmount) external { + _onlyOwner(); + wsteth.transfer(msg.sender, wstethAmount); + _totalAssets -= wstethAmount; + } + function getSharesByAssets(uint256 assets) public view returns (uint256) { uint256 supply = totalSupply(); uint256 totalAssets_ = totalAssets(); @@ -59,17 +90,22 @@ contract GGVVaultMock is ERC20 { } function depositByTeller(address asset, uint256 shares, uint256 assets, address user) external { - require(msg.sender == address(TELLER), "Only teller can call depositByTeller"); - require(asset == address(steth), "Only steth asset supported"); + _onlyTeller(); - steth.transferSharesFrom(user, address(this), assets); + if (asset == address(steth)) { + steth.transferSharesFrom(user, address(this), assets); + } else if (asset == address(wsteth)) { + wsteth.transferFrom(user, address(this), assets); + } else { + revert("Unsupported asset"); + } _mint(user, shares); _totalAssets += assets; } function burnSharesReturnAssets(ERC20 assetOut, uint256 shares, uint256 assets, address user) external { - require(msg.sender == address(BORING_QUEUE), "Only queue can call burnShares"); + _onlyQueue(); _burn(address(BORING_QUEUE), shares); _totalAssets -= assets; assetOut.transfer(user, assets); diff --git a/src/proxy/DummyImplementation.sol b/src/proxy/DummyImplementation.sol index 2265aa8..f9a73bf 100644 --- a/src/proxy/DummyImplementation.sol +++ b/src/proxy/DummyImplementation.sol @@ -1,4 +1,4 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; contract DummyImplementation {} diff --git a/src/proxy/OssifiableProxy.sol b/src/proxy/OssifiableProxy.sol index d9fd96d..7540588 100644 --- a/src/proxy/OssifiableProxy.sol +++ b/src/proxy/OssifiableProxy.sol @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Lido // SPDX-License-Identifier: GPL-3.0 -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {IERC1967} from "@openzeppelin/contracts/interfaces/IERC1967.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; diff --git a/src/strategy/GGVStrategy.sol b/src/strategy/GGVStrategy.sol index 94c7d1d..53d08a4 100644 --- a/src/strategy/GGVStrategy.sol +++ b/src/strategy/GGVStrategy.sol @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; +import { + AccessControlEnumerableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; @@ -9,193 +12,205 @@ import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; import {IStrategyCallForwarder} from "src/interfaces/IStrategyCallForwarder.sol"; import {IBoringOnChainQueue} from "src/interfaces/ggv/IBoringOnChainQueue.sol"; import {ITellerWithMultiAssetSupport} from "src/interfaces/ggv/ITellerWithMultiAssetSupport.sol"; -import {Strategy} from "src/strategy/Strategy.sol"; +import {StrategyCallForwarderRegistry} from "src/strategy/StrategyCallForwarderRegistry.sol"; +import {FeaturePausable} from "src/utils/FeaturePausable.sol"; + +import {IStrategy} from "src/interfaces/IStrategy.sol"; +import {IWstETH} from "src/interfaces/core/IWstETH.sol"; + +contract GGVStrategy is IStrategy, AccessControlEnumerableUpgradeable, FeaturePausable, StrategyCallForwarderRegistry { + StvStETHPool private immutable POOL_; + IWstETH public immutable WSTETH; -contract GGVStrategy is Strategy { ITellerWithMultiAssetSupport public immutable TELLER; IBoringOnChainQueue public immutable BORING_QUEUE; - // ==================== Events ==================== + // ACL + bytes32 public constant SUPPLY_FEATURE = keccak256("SUPPLY_FEATURE"); + bytes32 public constant SUPPLY_PAUSE_ROLE = keccak256("SUPPLY_PAUSE_ROLE"); + bytes32 public constant SUPPLY_RESUME_ROLE = keccak256("SUPPLY_RESUME_ROLE"); + + struct GGVParamsSupply { + uint16 minimumMint; + } + + struct GGVParamsRequestExit { + uint16 discount; + uint24 secondsToDeadline; + } event GGVDeposited( - address indexed recipient, uint256 stethAmount, uint256 ggvShares, address referralAddress, bytes data + address indexed recipient, + uint256 wstethAmount, + uint256 ggvShares, + address indexed referralAddress, + bytes paramsSupply + ); + event GGVWithdrawalRequested( + address indexed recipient, bytes32 requestId, uint256 ggvShares, bytes paramsRequestExit ); - event GGVWithdrawalRequested(address indexed recipient, bytes32 requestId, uint128 requestedGGV, bytes data); - - // ==================== Errors ==================== + error ZeroArgument(string name); error InvalidSender(); - error InvalidStethAmount(); - error AlreadyRequested(); - error InvalidRequestId(); + error InvalidWstethAmount(); + error NothingToExit(); + error InvalidGGVSharesAmount(); error NotImplemented(); - error InvalidGGVAmount(); - - struct GGVParams { - uint16 discount; - uint16 minimumMint; - uint24 secondsToDeadline; - } constructor( - address _strategyCallForwarderImplementation, + bytes32 _strategyId, + address _strategyCallForwarderImpl, address _pool, - address _stETH, - address _wstETH, address _teller, address _boringQueue - ) Strategy(_pool, _stETH, _wstETH, _strategyCallForwarderImplementation) { + ) StrategyCallForwarderRegistry(_strategyId, _strategyCallForwarderImpl) { + POOL_ = StvStETHPool(payable(_pool)); + WSTETH = IWstETH(POOL_.WSTETH()); + TELLER = ITellerWithMultiAssetSupport(_teller); BORING_QUEUE = IBoringOnChainQueue(_boringQueue); + + _disableInitializers(); + _pauseFeature(SUPPLY_FEATURE); } - /// @notice Supplies stETH to the strategy - /// @param _referral The referral address - /// @param _params The parameters for the supply - function supply(address _referral, bytes calldata _params) external payable { - address callForwarder = _getOrCreateCallForwarder(msg.sender); - uint256 stethShares = POOL.calcStethSharesToMintForAssets(msg.value); - uint256 stv = POOL.depositETHAndMintStethShares{value: msg.value}(callForwarder, _referral, stethShares); + /** + * @notice Initialize the contract storage explicitly + * @param _admin Admin address that can change every role + * @dev Reverts if `_admin` equals to `address(0)` + */ + function initialize(address _admin) external initializer { + if (_admin == address(0)) revert ZeroArgument("_admin"); - uint256 stethAmount = STETH.getPooledEthByShares(stethShares); + __AccessControlEnumerable_init(); - IStrategyCallForwarder(callForwarder) - .call(address(STETH), abi.encodeWithSelector(STETH.approve.selector, TELLER.vault(), stethAmount)); + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + } - GGVParams memory params = abi.decode(_params, (GGVParams)); + /** + * @inheritdoc IStrategy + */ + function POOL() external view returns (address) { + return address(POOL_); + } - bytes memory data = IStrategyCallForwarder(callForwarder) - .call( - address(TELLER), - abi.encodeWithSelector( - TELLER.deposit.selector, address(STETH), stethAmount, params.minimumMint, _referral - ) - ); - uint256 ggvShares = abi.decode(data, (uint256)); + // ================================================================================= + // PAUSE / RESUME + // ================================================================================= - emit StrategySupplied(msg.sender, stv, stethShares, stethAmount, _params); - emit GGVDeposited(msg.sender, stethAmount, ggvShares, _referral, _params); + /** + * @notice Pause supply + */ + function pauseSupply() external { + _checkRole(SUPPLY_PAUSE_ROLE, msg.sender); + _pauseFeature(SUPPLY_FEATURE); } - /// @notice Requests a withdrawal of ggv shares from the strategy - /// @param _stethAmount The amount of stETH to withdraw - /// @return requestId The request id - function requestExitByStETH(uint256 _stethAmount, bytes calldata _params) external returns (bytes32 requestId) { - uint256 stethSharesToBurn = STETH.getSharesByPooledEth(_stethAmount); - requestId = requestExitByStethShares(stethSharesToBurn, _params); + /** + * @notice Resume supply + */ + function resumeSupply() external { + _checkRole(SUPPLY_RESUME_ROLE, msg.sender); + _resumeFeature(SUPPLY_FEATURE); } - /// @notice Previews the amount of stETH shares that can be withdrawn by a given amount of GGV shares - /// @param _user The user to preview the amount of stETH shares for - /// @param _ggvShares The amount of GGV shares to preview the amount of stETH shares for - /// @param _params The parameters for the withdrawal - /// @return stethShares The amount of stETH shares that can be withdrawn - function previewStethSharesByGGV(address _user, uint256 _ggvShares, bytes calldata _params) + // ================================================================================= + // SUPPLY + // ================================================================================= + + /** + * @inheritdoc IStrategy + */ + function supply(address _referral, uint256 _wstethToMint, bytes calldata _params) external - view - returns (uint256 stethShares) + payable + returns (uint256 stv) { - address callForwarder = getStrategyCallForwarderAddress(_user); + _checkFeatureNotPaused(SUPPLY_FEATURE); - GGVParams memory params = abi.decode(_params, (GGVParams)); + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); - IERC20 boringVault = IERC20(TELLER.vault()); - uint256 totalGGV = boringVault.balanceOf(callForwarder); + if (msg.value > 0) { + stv = POOL_.depositETH{value: msg.value}(address(callForwarder), _referral); + } + + callForwarder.doCall(address(POOL_), abi.encodeWithSelector(POOL_.mintWsteth.selector, _wstethToMint)); + callForwarder.doCall( + address(WSTETH), abi.encodeWithSelector(WSTETH.approve.selector, TELLER.vault(), _wstethToMint) + ); + + GGVParamsSupply memory params = abi.decode(_params, (GGVParamsSupply)); + + bytes memory data = callForwarder.doCall( + address(TELLER), + abi.encodeWithSelector( + TELLER.deposit.selector, address(WSTETH), _wstethToMint, params.minimumMint, _referral + ) + ); + uint256 ggvShares = abi.decode(data, (uint256)); - if (totalGGV == 0) return 0; - if (_ggvShares > totalGGV) revert InvalidGGVAmount(); + emit StrategySupplied(msg.sender, _referral, msg.value, stv, _wstethToMint, _params); + emit GGVDeposited(msg.sender, _wstethToMint, ggvShares, _referral, _params); + } - uint256 totalStethSharesFromGgv = - BORING_QUEUE.previewAssetsOut(address(WSTETH), uint128(totalGGV), params.discount); - stethShares = Math.mulDiv(_ggvShares, totalStethSharesFromGgv, totalGGV); + // ================================================================================= + // REQUEST EXIT FROM STRATEGY + // ================================================================================= + + /** + * @notice Previews the amount of wstETH that can be withdrawn by a given amount of GGV shares + * @param _ggvShares The amount of GGV shares to preview the amount of wstETH for + * @param _params The parameters for the withdrawal + * @return wsteth The amount of wstETH that can be withdrawn + */ + function previewWstethByGGV(uint256 _ggvShares, bytes calldata _params) public view returns (uint256 wsteth) { + if (_ggvShares > type(uint128).max) revert InvalidGGVSharesAmount(); + GGVParamsRequestExit memory params = abi.decode(_params, (GGVParamsRequestExit)); + wsteth = BORING_QUEUE.previewAssetsOut(address(WSTETH), uint128(_ggvShares), params.discount); } - /// @notice Requests a withdrawal of ggv shares from the strategy - /// @param _stethSharesToBurn The amount of steth shares to burn - /// @param _params The parameters for the withdrawal - /// @return requestId The request id - function requestExitByStethShares(uint256 _stethSharesToBurn, bytes calldata _params) - public - returns (bytes32 requestId) - { - GGVParams memory params = abi.decode(_params, (GGVParams)); + /** + * @inheritdoc IStrategy + */ + function requestExitByWsteth(uint256 _wsteth, bytes calldata _params) external returns (bytes32 requestId) { + GGVParamsRequestExit memory params = abi.decode(_params, (GGVParamsRequestExit)); - address callForwarder = _getOrCreateCallForwarder(msg.sender); + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); IERC20 boringVault = IERC20(TELLER.vault()); // Calculate how much wsteth we'll get from total GGV shares - uint256 totalGGV = boringVault.balanceOf(callForwarder); - uint256 totalStethSharesFromGgv = - BORING_QUEUE.previewAssetsOut(address(WSTETH), uint128(totalGGV), params.discount); - if (totalStethSharesFromGgv == 0) revert InvalidStethAmount(); - if (_stethSharesToBurn > totalStethSharesFromGgv) revert InvalidStethAmount(); + uint256 totalGGV = boringVault.balanceOf(address(callForwarder)); + uint256 totalWstethFromGGV = previewWstethByGGV(totalGGV, _params); + if (totalWstethFromGGV == 0) revert InvalidWstethAmount(); + if (_wsteth > totalWstethFromGGV) revert NothingToExit(); // Approve GGV shares - uint256 ggvShares = Math.mulDiv(totalGGV, _stethSharesToBurn, totalStethSharesFromGgv); - IStrategyCallForwarder(callForwarder) - .call( - address(boringVault), - abi.encodeWithSelector(boringVault.approve.selector, address(BORING_QUEUE), ggvShares) - ); - - uint128 requestedGGV = uint128(ggvShares); + uint256 ggvShares = Math.mulDiv(totalGGV, _wsteth, totalWstethFromGGV, Math.Rounding.Ceil); + callForwarder.doCall( + address(boringVault), abi.encodeWithSelector(boringVault.approve.selector, address(BORING_QUEUE), ggvShares) + ); // Withdrawal request from GGV - bytes memory data = IStrategyCallForwarder(callForwarder) - .call( - address(BORING_QUEUE), - abi.encodeWithSelector( - BORING_QUEUE.requestOnChainWithdraw.selector, - address(WSTETH), - requestedGGV, - params.discount, - params.secondsToDeadline - ) - ); + bytes memory data = callForwarder.doCall( + address(BORING_QUEUE), + abi.encodeWithSelector( + BORING_QUEUE.requestOnChainWithdraw.selector, + address(WSTETH), + uint128(ggvShares), + params.discount, + params.secondsToDeadline + ) + ); requestId = abi.decode(data, (bytes32)); - emit StrategyExitRequested(msg.sender, requestId, _stethSharesToBurn, _params); - emit GGVWithdrawalRequested(msg.sender, requestId, requestedGGV, _params); + emit StrategyExitRequested(msg.sender, requestId, _wsteth, _params); + emit GGVWithdrawalRequested(msg.sender, requestId, ggvShares, _params); } - /// @notice Cancels a withdrawal request - /// @param request The request to cancel - function cancelGgvRequest(IBoringOnChainQueue.OnChainWithdraw memory request) external { - address callForwarder = getStrategyCallForwarderAddress(msg.sender); - if (callForwarder != request.user) revert InvalidSender(); - - IStrategyCallForwarder(callForwarder) - .call(address(BORING_QUEUE), abi.encodeWithSelector(BORING_QUEUE.cancelOnChainWithdraw.selector, request)); - } - - /// @notice Replaces a withdrawal request - /// @param request The request to replace - /// @param discount The discount to use - /// @param secondsToDeadline The deadline to use - /// @return oldRequestId The old request id - /// @return newRequestId The new request id - function replaceGgvOnChainWithdraw( - IBoringOnChainQueue.OnChainWithdraw memory request, - uint16 discount, - uint24 secondsToDeadline - ) external returns (bytes32 oldRequestId, bytes32 newRequestId) { - address callForwarder = getStrategyCallForwarderAddress(msg.sender); - if (callForwarder != request.user) revert InvalidSender(); - - bytes memory data = IStrategyCallForwarder(callForwarder) - .call( - address(BORING_QUEUE), - abi.encodeWithSelector( - BORING_QUEUE.replaceOnChainWithdraw.selector, request, discount, secondsToDeadline - ) - ); - (oldRequestId, newRequestId) = abi.decode(data, (bytes32, bytes32)); - } - - /// @notice Finalizes a withdrawal of stETH from the strategy + /** + * @inheritdoc IStrategy + */ function finalizeRequestExit( - address, - /*_receiver*/ bytes32 /*_requestId*/ ) external @@ -208,72 +223,147 @@ contract GGVStrategy is Strategy { revert NotImplemented(); } - /// @notice Returns the amount of stETH shares of a user - /// @param _user The user to get the stETH shares for - /// @return stethShares The amount of stETH shares - function proxyStethSharesOf(address _user) public view returns (uint256 stethShares) { - address callForwarder = getStrategyCallForwarderAddress(_user); + // ================================================================================= + // CANCEL / REPLACE GGV REQUEST + // ================================================================================= + + /** + * @notice Cancels a GGV withdrawal request + * @param _request The request to cancel + */ + function cancelGGVOnChainWithdraw(IBoringOnChainQueue.OnChainWithdraw memory _request) external { + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); + if (address(callForwarder) != _request.user) revert InvalidSender(); + + callForwarder.doCall( + address(BORING_QUEUE), abi.encodeWithSelector(BORING_QUEUE.cancelOnChainWithdraw.selector, _request) + ); + } - // simulate the unwrapping of wstETH to stETH with rounding issue - uint256 wstethAmount = WSTETH.balanceOf(callForwarder); - uint256 stETHAmount = STETH.getPooledEthByShares(wstethAmount); - uint256 sharesAfterUnwrapping = STETH.getSharesByPooledEth(stETHAmount); + /** + * @notice Replaces a withdrawal request + * @param request The request to replace + * @param discount The discount to use + * @param secondsToDeadline The deadline to use + * @return oldRequestId The old request id + * @return newRequestId The new request id + */ + function replaceGGVOnChainWithdraw( + IBoringOnChainQueue.OnChainWithdraw memory request, + uint16 discount, + uint24 secondsToDeadline + ) external returns (bytes32 oldRequestId, bytes32 newRequestId) { + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); + if (address(callForwarder) != request.user) revert InvalidSender(); - // add the stETH shares of the call forwarder - stethShares = sharesAfterUnwrapping + STETH.sharesOf(callForwarder); + bytes memory data = callForwarder.doCall( + address(BORING_QUEUE), + abi.encodeWithSelector(BORING_QUEUE.replaceOnChainWithdraw.selector, request, discount, secondsToDeadline) + ); + (oldRequestId, newRequestId) = abi.decode(data, (bytes32, bytes32)); } - /// @notice Calculates the amount of stETH shares to rebalance - /// @param _user The user to calculate the amount of stETH shares to rebalance for - /// @return stethShares The amount of stETH shares to rebalance - function proxyStethSharesToRebalance(address _user) external view returns (uint256 stethShares) { - address callForwarder = getStrategyCallForwarderAddress(_user); - uint256 mintedStethShares = POOL.mintedStethSharesOf(callForwarder); + // ================================================================================= + // HELPERS + // ================================================================================= - uint256 sharesAfterUnwrapping = proxyStethSharesOf(_user); + /** + * @inheritdoc IStrategy + */ + function mintedStethSharesOf(address _user) external view returns (uint256 mintedStethShares) { + IStrategyCallForwarder callForwarder = getStrategyCallForwarderAddress(_user); + mintedStethShares = POOL_.mintedStethSharesOf(address(callForwarder)); + } - if (mintedStethShares > sharesAfterUnwrapping) { - stethShares = mintedStethShares - sharesAfterUnwrapping; - } + /** + * @inheritdoc IStrategy + */ + function remainingMintingCapacitySharesOf(address _user, uint256 _ethToFund) + external + view + returns (uint256 stethShares) + { + IStrategyCallForwarder callForwarder = getStrategyCallForwarderAddress(_user); + stethShares = POOL_.remainingMintingCapacitySharesOf(address(callForwarder), _ethToFund); + } + + /** + * @inheritdoc IStrategy + */ + function wstethOf(address _user) external view returns (uint256 wsteth) { + IStrategyCallForwarder callForwarder = getStrategyCallForwarderAddress(_user); + wsteth = WSTETH.balanceOf(address(callForwarder)); + } + + /** + * @inheritdoc IStrategy + */ + function stvOf(address _user) external view returns (uint256 stv) { + IStrategyCallForwarder callForwarder = getStrategyCallForwarderAddress(_user); + stv = POOL_.balanceOf(address(callForwarder)); } - /// @notice Calculates the amount of stv that can be withdrawn - /// @param _user The user to calculate the amount of stv to withdraw for - /// @param _stethSharesToBurn The amount of stETH shares to burn - /// @return stv The amount of stv that can be withdrawn - function proxyUnlockedStvOf(address _user, uint256 _stethSharesToBurn) external view returns (uint256 stv) { - address callForwarder = getStrategyCallForwarderAddress(_user); - stv = POOL.unlockedStvOf(callForwarder, _stethSharesToBurn); + /** + * @notice Returns the amount of GGV shares of a user + * @param _user The user to get the GGV shares for + * @return ggvShares The amount of GGV shares + */ + function ggvOf(address _user) external view returns (uint256 ggvShares) { + IStrategyCallForwarder callForwarder = getStrategyCallForwarderAddress(_user); + ggvShares = IERC20(TELLER.vault()).balanceOf(address(callForwarder)); } - /// @notice Requests a withdrawal from the Withdrawal Queue - /// @param _stvToWithdraw The amount of stv to withdraw - /// @param _stethSharesToBurn The amount of stETH shares to burn - /// @param _stethSharesToRebalance The amount of stETH shares to rebalance - /// @param _receiver The address to receive the stv - /// @return requestId The Withdrawal Queue request ID - function requestWithdrawal( - uint256 _stvToWithdraw, - uint256 _stethSharesToBurn, - uint256 _stethSharesToRebalance, - address _receiver - ) external returns (uint256 requestId) { - address callForwarder = _getOrCreateCallForwarder(msg.sender); - - IStrategyCallForwarder(callForwarder) - .call(address(WSTETH), abi.encodeWithSelector(WSTETH.unwrap.selector, WSTETH.balanceOf(callForwarder))); - - IStrategyCallForwarder(callForwarder) - .call(address(POOL), abi.encodeWithSelector(StvStETHPool.burnStethShares.selector, _stethSharesToBurn)); + // ================================================================================= + // REQUEST WITHDRAWAL FROM POOL + // ================================================================================= + + /** + * @inheritdoc IStrategy + */ + function requestWithdrawalFromPool(address _recipient, uint256 _stvToWithdraw, uint256 _stethSharesToRebalance) + external + returns (uint256 requestId) + { + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); // request withdrawal from pool - bytes memory withdrawalData = IStrategyCallForwarder(callForwarder) - .call( - address(POOL.WITHDRAWAL_QUEUE()), - abi.encodeWithSelector( - WithdrawalQueue.requestWithdrawal.selector, _receiver, _stvToWithdraw, _stethSharesToRebalance - ) - ); + bytes memory withdrawalData = callForwarder.doCall( + address(POOL_.WITHDRAWAL_QUEUE()), + abi.encodeWithSelector( + WithdrawalQueue.requestWithdrawal.selector, _recipient, _stvToWithdraw, _stethSharesToRebalance + ) + ); requestId = abi.decode(withdrawalData, (uint256)); } + + /** + * @notice Burns wstETH to reduce the user's minted stETH obligation + * @param _wstethToBurn The amount of wstETH to burn + */ + function burnWsteth(uint256 _wstethToBurn) external { + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); + callForwarder.doCall( + address(WSTETH), abi.encodeWithSelector(WSTETH.approve.selector, address(POOL_), _wstethToBurn) + ); + callForwarder.doCall(address(POOL_), abi.encodeWithSelector(StvStETHPool.burnWsteth.selector, _wstethToBurn)); + } + + // ================================================================================= + // RECOVERY + // ================================================================================= + + /** + * @notice Recovers ERC20 tokens from the call forwarder + * @param _token The token to recover + * @param _recipient The recipient of the tokens + * @param _amount The amount of tokens to recover + */ + function recoverERC20(address _token, address _recipient, uint256 _amount) external { + if (_token == address(0)) revert ZeroArgument("_token"); + if (_recipient == address(0)) revert ZeroArgument("_recipient"); + if (_amount == 0) revert ZeroArgument("_amount"); + + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); + callForwarder.doCall(_token, abi.encodeWithSelector(IERC20.transfer.selector, _recipient, _amount)); + } } diff --git a/src/strategy/Strategy.sol b/src/strategy/Strategy.sol deleted file mode 100644 index 70d2318..0000000 --- a/src/strategy/Strategy.sol +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; - -import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -import {StvStETHPool} from "src/StvStETHPool.sol"; -import {IStETH} from "src/interfaces/IStETH.sol"; -import {IStrategy} from "src/interfaces/IStrategy.sol"; -import {IStrategyCallForwarder} from "src/interfaces/IStrategyCallForwarder.sol"; -import {IWstETH} from "src/interfaces/IWstETH.sol"; - -abstract contract Strategy is IStrategy { - StvStETHPool public immutable POOL; - IStETH public immutable STETH; - IWstETH public immutable WSTETH; - address public immutable STRATEGY_CALL_FORWARDER_IMPL; - - /// @dev WARNING: This ID is used to calculate user proxy addresses. - /// Changing this value will break user proxy address calculations. - bytes32 public constant STRATEGY_ID = keccak256("strategy.ggv.v1"); - - mapping(bytes32 salt => address proxy) private userStrategyCallForwarder; - - error ZeroArgument(string name); - - constructor(address _pool, address _stETH, address _wstETH, address _strategyCallForwarderImpl) { - STETH = IStETH(_stETH); - WSTETH = IWstETH(_wstETH); - STRATEGY_CALL_FORWARDER_IMPL = _strategyCallForwarderImpl; - POOL = StvStETHPool(payable(_pool)); - } - - /// @notice Recovers ERC20 tokens from the strategy - /// @param _token The token to recover - /// @param _recipient The recipient of the tokens - /// @param _amount The amount of tokens to recover - function recoverERC20(address _token, address _recipient, uint256 _amount) external { - if (_token == address(0)) revert ZeroArgument("_token"); - if (_recipient == address(0)) revert ZeroArgument("_recipient"); - if (_amount == 0) revert ZeroArgument("_amount"); - - address proxy = getStrategyCallForwarderAddress(msg.sender); - - IStrategyCallForwarder(proxy) - .call(address(STETH), abi.encodeWithSelector(IERC20.transfer.selector, _recipient, _amount)); - } - - /// @notice Returns the address of the strategy proxy for a given user - /// @param user The user for which to get the strategy call forwarder address - /// @return callForwarder The address of the strategy call forwarder - function getStrategyCallForwarderAddress(address user) public view returns (address callForwarder) { - bytes32 salt = _generateSalt(user); - callForwarder = Clones.predictDeterministicAddress(STRATEGY_CALL_FORWARDER_IMPL, salt); - } - - function _getOrCreateCallForwarder(address _user) internal returns (address callForwarder) { - if (_user == address(0)) revert ZeroArgument("_user"); - - bytes32 salt = _generateSalt(_user); - callForwarder = userStrategyCallForwarder[salt]; - if (callForwarder != address(0)) return callForwarder; - - callForwarder = Clones.cloneDeterministic(STRATEGY_CALL_FORWARDER_IMPL, salt); - IStrategyCallForwarder(callForwarder).initialize(address(this)); - IStrategyCallForwarder(callForwarder) - .call(address(STETH), abi.encodeWithSelector(STETH.approve.selector, address(POOL), type(uint256).max)); - userStrategyCallForwarder[salt] = callForwarder; - } - - function _generateSalt(address _user) internal view returns (bytes32 salt) { - salt = keccak256(abi.encode(STRATEGY_ID, address(this), _user)); - } -} diff --git a/src/strategy/StrategyCallForwarder.sol b/src/strategy/StrategyCallForwarder.sol index d44cc11..3d19b4d 100644 --- a/src/strategy/StrategyCallForwarder.sol +++ b/src/strategy/StrategyCallForwarder.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0 -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; @@ -25,32 +25,39 @@ contract StrategyCallForwarder is /// @notice Function for receiving native assets receive() external payable {} - /// @notice Executes a call on the target contract - /// @dev Only callable by owner. To convert to the expected return value, use abi.decode. - /// @param _target The address of the target contract - /// @param _data The call data - /// @return Returns the raw returned data. - function call(address _target, bytes calldata _data) external payable onlyOwner returns (bytes memory) { - return Address.functionCall(_target, _data); + /** + * @notice Executes a call on the target contract + * @dev Only callable by owner. To convert to the expected return value, use abi.decode. + * @param _target The address of the target contract + * @param _data The call data + * @return data The raw returned data. + */ + function doCall(address _target, bytes calldata _data) external onlyOwner returns (bytes memory data) { + data = Address.functionCall(_target, _data); } - /// @notice Executes a call on the target contract, but also transferring value wei to the target. - /// @dev Only callable by owner. To convert to the expected return value, use abi.decode. - /// @param _target The address of the target contract - /// @param _data The call data - /// @param _value The value to send with the call - /// @return Returns the raw returned data. - function callWithValue(address _target, bytes calldata _data, uint256 _value) + /** + * @notice Executes a call on the target contract, but also transferring value wei to the target. + * @dev Only callable by owner. To convert to the expected return value, use abi.decode. + * @param _target The address of the target contract + * @param _data The call data + * @param _value The value to send with the call + * @return Returns the raw returned data. + */ + function doCallWithValue(address _target, bytes calldata _data, uint256 _value) external - payable onlyOwner returns (bytes memory) { return Address.functionCallWithValue(_target, _data, _value); } - /// @notice sends `_amount` wei to `_recipient` - function sendValue(address payable _recipient, uint256 _amount) external payable onlyOwner nonReentrant { + /** + * @notice sends `_amount` wei to `_recipient` + * @param _recipient The address to send the value to + * @param _amount The amount of value to send + */ + function sendValue(address payable _recipient, uint256 _amount) external onlyOwner nonReentrant { Address.sendValue(_recipient, _amount); } } diff --git a/src/strategy/StrategyCallForwarderRegistry.sol b/src/strategy/StrategyCallForwarderRegistry.sol new file mode 100644 index 0000000..b342bad --- /dev/null +++ b/src/strategy/StrategyCallForwarderRegistry.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IStrategyCallForwarder} from "src/interfaces/IStrategyCallForwarder.sol"; + +abstract contract StrategyCallForwarderRegistry { + error CallForwarderZeroArgument(string name); + + /// @dev WARNING: This ID is used to calculate user proxy addresses. + /// Changing this value will break user proxy address calculations. + bytes32 public immutable STRATEGY_ID; + address public immutable STRATEGY_CALL_FORWARDER_IMPL; + + /// @custom:storage-location erc7201:pool.storage.StrategyCallForwarderRegistry + struct CallForwarderStorage { + mapping(bytes32 salt => IStrategyCallForwarder callForwarder) userCallForwarder; + } + + // keccak256(abi.encode(uint256(keccak256("pool.storage.StrategyCallForwarderRegistry")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant CALL_FORWARDER_STORAGE_LOCATION = + 0x3074294e9a887c21033ca796133e603629c1fad03ac5b84cce0cfe20ad599d00; + + function _getCallForwarderRegistryStorage() internal pure returns (CallForwarderStorage storage $) { + assembly { + $.slot := CALL_FORWARDER_STORAGE_LOCATION + } + } + + constructor(bytes32 _strategyId, address _strategyCallForwarderImpl) { + if (_strategyId == bytes32(0)) revert CallForwarderZeroArgument("_strategyId"); + if (_strategyCallForwarderImpl == address(0)) revert CallForwarderZeroArgument("_strategyCallForwarderImpl"); + + STRATEGY_ID = _strategyId; + STRATEGY_CALL_FORWARDER_IMPL = _strategyCallForwarderImpl; + } + + /** + * @notice Returns the address of the strategy call forwarder for a given user + * @param _user The user for which to get the strategy call forwarder address + * @return callForwarder The address of the strategy call forwarder + */ + function getStrategyCallForwarderAddress(address _user) public view returns (IStrategyCallForwarder callForwarder) { + bytes32 salt = _generateSalt(_user); + callForwarder = IStrategyCallForwarder(Clones.predictDeterministicAddress(STRATEGY_CALL_FORWARDER_IMPL, salt)); + } + + function _getOrCreateCallForwarder(address _user) internal returns (IStrategyCallForwarder callForwarder) { + if (_user == address(0)) revert CallForwarderZeroArgument("_user"); + + CallForwarderStorage storage $ = _getCallForwarderRegistryStorage(); + + bytes32 salt = _generateSalt(_user); + callForwarder = $.userCallForwarder[salt]; + if (address(callForwarder) != address(0)) return callForwarder; + + callForwarder = IStrategyCallForwarder(Clones.cloneDeterministic(STRATEGY_CALL_FORWARDER_IMPL, salt)); + callForwarder.initialize(address(this)); + + $.userCallForwarder[salt] = callForwarder; + } + + function _generateSalt(address _user) internal view returns (bytes32 salt) { + salt = keccak256(abi.encode(block.chainid, STRATEGY_ID, address(this), _user)); + } +} diff --git a/src/utils/FeaturePausable.sol b/src/utils/FeaturePausable.sol new file mode 100644 index 0000000..1dca049 --- /dev/null +++ b/src/utils/FeaturePausable.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +/** + * @title FeaturePausable + * @notice Generic feature pausable helper that allows inheriting contracts to gate arbitrary flows + * @dev Stores paused states per feature id and exposes internal checks plus pause/unpause helpers + */ +abstract contract FeaturePausable { + // ================================================================================= + // STORAGE + // ================================================================================= + + /// @custom:storage-location erc7201:pool.storage.FeaturePausable + struct FeaturePausableStorage { + mapping(bytes32 featureId => bool isPaused) isFeaturePaused; + } + + // keccak256(abi.encode(uint256(keccak256("pool.storage.FeaturePausable")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant FEATURE_PAUSABLE_STORAGE_LOCATION = + 0x15990678dc6e70d79055b1ed64b76145ce68c29f966eaa9eea39165e8a41bd00; + + function _getFeaturePausableStorage() private pure returns (FeaturePausableStorage storage $) { + assembly { + $.slot := FEATURE_PAUSABLE_STORAGE_LOCATION + } + } + + // ================================================================================= + // EVENTS + // ================================================================================= + + event FeaturePaused(bytes32 indexed featureId, address indexed account); + event FeatureUnpaused(bytes32 indexed featureId, address indexed account); + + // ================================================================================= + // ERRORS + // ================================================================================= + + error FeaturePauseEnforced(bytes32 featureId); + error FeaturePauseExpected(bytes32 featureId); + + // ================================================================================= + // PUBLIC METHODS + // ================================================================================= + + /** + * @notice Check if a feature is paused + * @param _featureId Feature identifier + * @return isPaused True if paused + */ + function isFeaturePaused(bytes32 _featureId) public view returns (bool isPaused) { + isPaused = _getFeaturePausableStorage().isFeaturePaused[_featureId]; + } + + // ================================================================================= + // CHECK HELPERS + // ================================================================================= + + /** + * @notice Revert if a feature is paused + * @param _featureId Feature identifier + */ + function _checkFeatureNotPaused(bytes32 _featureId) internal view { + if (isFeaturePaused(_featureId)) revert FeaturePauseEnforced(_featureId); + } + + /** + * @notice Revert if a feature is not paused + * @param _featureId Feature identifier + */ + function _checkFeaturePaused(bytes32 _featureId) internal view { + if (!isFeaturePaused(_featureId)) revert FeaturePauseExpected(_featureId); + } + + // ================================================================================= + // PAUSE/UNPAUSE HELPERS + // ================================================================================= + + /** + * @notice Pause a feature + * @param _featureId Feature identifier + */ + function _pauseFeature(bytes32 _featureId) internal { + _checkFeatureNotPaused(_featureId); + _getFeaturePausableStorage().isFeaturePaused[_featureId] = true; + emit FeaturePaused(_featureId, msg.sender); + } + + /** + * @notice Resume a feature + * @param _featureId Feature identifier + */ + function _resumeFeature(bytes32 _featureId) internal { + _checkFeaturePaused(_featureId); + _getFeaturePausableStorage().isFeaturePaused[_featureId] = false; + emit FeatureUnpaused(_featureId, msg.sender); + } +} diff --git a/test/Factory.test.sol b/test/Factory.test.sol index 32ff883..9a3a54d 100644 --- a/test/Factory.test.sol +++ b/test/Factory.test.sol @@ -1,12 +1,11 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Test} from "forge-std/Test.sol"; import {Factory} from "src/Factory.sol"; import {DistributorFactory} from "src/factories/DistributorFactory.sol"; import {GGVStrategyFactory} from "src/factories/GGVStrategyFactory.sol"; -import {LoopStrategyFactory} from "src/factories/LoopStrategyFactory.sol"; import {StvPoolFactory} from "src/factories/StvPoolFactory.sol"; import {StvStETHPoolFactory} from "src/factories/StvStETHPoolFactory.sol"; import {TimelockFactory} from "src/factories/TimelockFactory.sol"; @@ -16,8 +15,9 @@ import {Distributor} from "src/Distributor.sol"; import {StvPool} from "src/StvPool.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; -import {IDashboard} from "src/interfaces/IDashboard.sol"; +import {IDashboard} from "src/interfaces/core/IDashboard.sol"; +import {DummyImplementation} from "src/proxy/DummyImplementation.sol"; import {MockDashboard} from "test/mocks/MockDashboard.sol"; import {MockERC20} from "test/mocks/MockERC20.sol"; import {MockLazyOracle} from "test/mocks/MockLazyOracle.sol"; @@ -40,6 +40,7 @@ contract FactoryTest is Test { address public nodeOperatorManager = address(0x3); uint256 public connectDeposit = 1 ether; + uint256 internal immutable fusakaTxGasLimit = 16_777_216; function setUp() public { vaultHub = new MockVaultHub(); @@ -57,49 +58,64 @@ contract FactoryTest is Test { subFactories.stvStETHPoolFactory = address(new StvStETHPoolFactory()); subFactories.withdrawalQueueFactory = address(new WithdrawalQueueFactory()); subFactories.distributorFactory = address(new DistributorFactory()); - subFactories.loopStrategyFactory = address(new LoopStrategyFactory()); - subFactories.ggvStrategyFactory = address(new GGVStrategyFactory()); + address dummyTeller = address(new DummyImplementation()); + address dummyQueue = address(new DummyImplementation()); + subFactories.ggvStrategyFactory = address(new GGVStrategyFactory(dummyTeller, dummyQueue)); subFactories.timelockFactory = address(new TimelockFactory()); - Factory.TimelockConfig memory timelockConfig = Factory.TimelockConfig({minDelaySeconds: 0, executor: admin}); - - Factory.StrategyParameters memory strategyParams = - Factory.StrategyParameters({ggvTeller: address(0x1111), ggvBoringOnChainQueue: address(0x2222)}); - - wrapperFactory = new Factory(address(locator), subFactories, timelockConfig, strategyParams); + wrapperFactory = new Factory(address(locator), subFactories); vm.deal(admin, 100 ether); } - function _basePoolConfig(bool allowlistEnabled, bool mintingEnabled, uint256 reserveRatioGapBP) + function _buildConfigs( + bool allowlistEnabled, + bool mintingEnabled, + uint256 reserveRatioGapBP, + string memory name, + string memory symbol + ) internal view - returns (Factory.PoolFullConfig memory) + returns ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig + ) { - return Factory.PoolFullConfig({ - allowlistEnabled: allowlistEnabled, - mintingEnabled: mintingEnabled, - owner: admin, + vaultConfig = Factory.VaultConfig({ nodeOperator: nodeOperator, nodeOperatorManager: nodeOperatorManager, nodeOperatorFeeBP: 100, - confirmExpiry: 3600, - maxFinalizationTime: 30 days, - minWithdrawalDelayTime: 1 days, - reserveRatioGapBP: reserveRatioGapBP, - name: mintingEnabled ? "Factory stETH Pool" : "Factory STV Pool", - symbol: mintingEnabled ? "FSTETH" : "FSTV" + confirmExpiry: 3600 }); + + commonPoolConfig = Factory.CommonPoolConfig({minWithdrawalDelayTime: 1 days, name: name, symbol: symbol}); + + auxiliaryConfig = Factory.AuxiliaryPoolConfig({ + allowlistEnabled: allowlistEnabled, mintingEnabled: mintingEnabled, reserveRatioGapBP: reserveRatioGapBP + }); + } + + function _defaultTimelockConfig() internal view returns (Factory.TimelockConfig memory) { + return Factory.TimelockConfig({minDelaySeconds: 0, proposer: address(this), executor: admin}); } function test_canCreatePool() public { - Factory.PoolFullConfig memory poolConfig = _basePoolConfig(false, false, 0); - Factory.StrategyConfig memory strategyConfig = Factory.StrategyConfig({factory: address(0)}); + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig + ) = _buildConfigs(false, false, 0, "Factory STV Pool", "FSTV"); + + Factory.TimelockConfig memory timelockConfig = _defaultTimelockConfig(); + address strategyFactory = address(0); vm.startPrank(admin); - Factory.StvPoolIntermediate memory intermediate = - wrapperFactory.createPoolStart{value: connectDeposit}(poolConfig, strategyConfig); - Factory.StvPoolDeployment memory deployment = wrapperFactory.createPoolFinish(intermediate, strategyConfig); + Factory.PoolIntermediate memory intermediate = wrapperFactory.createPoolStart{value: connectDeposit}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + Factory.PoolDeployment memory deployment = wrapperFactory.createPoolFinish(intermediate); vm.stopPrank(); StvPool pool = StvPool(payable(deployment.pool)); @@ -112,7 +128,7 @@ contract FactoryTest is Test { assertEq(address(pool.DISTRIBUTOR()), address(distributor)); assertEq(deployment.vault, address(dashboard.stakingVault())); - assertEq(address(pool.STAKING_VAULT()), deployment.vault); + assertEq(address(pool.VAULT()), deployment.vault); MockDashboard mockDashboard = MockDashboard(payable(address(dashboard))); assertTrue(mockDashboard.hasRole(mockDashboard.DEFAULT_ADMIN_ROLE(), deployment.timelock)); @@ -124,27 +140,41 @@ contract FactoryTest is Test { } function test_revertWithoutConnectDeposit() public { - Factory.PoolFullConfig memory poolConfig = _basePoolConfig(false, false, 0); - Factory.StrategyConfig memory strategyConfig = Factory.StrategyConfig({factory: address(0)}); + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig + ) = _buildConfigs(false, false, 0, "Factory STV Pool", "FSTV"); + + Factory.TimelockConfig memory timelockConfig = _defaultTimelockConfig(); + address strategyFactory = address(0); vm.startPrank(admin); vm.expectRevert(); - wrapperFactory.createPoolStart(poolConfig, strategyConfig); + wrapperFactory.createPoolStart( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); vm.stopPrank(); } function test_canCreateWithStrategy() public { - Factory.PoolFullConfig memory poolConfig = _basePoolConfig(true, true, 0); - Factory.StrategyConfig memory strategyConfig = - Factory.StrategyConfig({factory: address(wrapperFactory.GGV_STRATEGY_FACTORY())}); + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig + ) = _buildConfigs(true, true, 0, "Factory stETH Pool", "FSTETH"); + + Factory.TimelockConfig memory timelockConfig = _defaultTimelockConfig(); + address strategyFactory = address(wrapperFactory.GGV_STRATEGY_FACTORY()); address ggvFactory = address(wrapperFactory.GGV_STRATEGY_FACTORY()); vm.startPrank(admin); - Factory.StvPoolIntermediate memory intermediate = - wrapperFactory.createPoolStart{value: connectDeposit}(poolConfig, strategyConfig); + Factory.PoolIntermediate memory intermediate = wrapperFactory.createPoolStart{value: connectDeposit}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); uint256 nonceBefore = vm.getNonce(ggvFactory); - Factory.StvPoolDeployment memory deployment = wrapperFactory.createPoolFinish(intermediate, strategyConfig); + Factory.PoolDeployment memory deployment = wrapperFactory.createPoolFinish(intermediate); vm.stopPrank(); StvStETHPool pool = StvStETHPool(payable(deployment.pool)); @@ -161,17 +191,274 @@ contract FactoryTest is Test { } function test_allowlistEnabled() public { - Factory.PoolFullConfig memory poolConfig = _basePoolConfig(true, false, 0); - Factory.StrategyConfig memory strategyConfig = Factory.StrategyConfig({factory: address(0)}); + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig + ) = _buildConfigs(true, false, 0, "Factory STV Pool", "FSTV"); + + Factory.TimelockConfig memory timelockConfig = _defaultTimelockConfig(); + address strategyFactory = address(0); vm.startPrank(admin); - Factory.StvPoolIntermediate memory intermediate = - wrapperFactory.createPoolStart{value: connectDeposit}(poolConfig, strategyConfig); - Factory.StvPoolDeployment memory deployment = wrapperFactory.createPoolFinish(intermediate, strategyConfig); + Factory.PoolIntermediate memory intermediate = wrapperFactory.createPoolStart{value: connectDeposit}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + Factory.PoolDeployment memory deployment = wrapperFactory.createPoolFinish(intermediate); vm.stopPrank(); StvPool pool = StvPool(payable(deployment.pool)); assertTrue(pool.ALLOW_LIST_ENABLED()); assertEq(deployment.poolType, wrapperFactory.STV_POOL_TYPE()); } + + function test_createPoolStartGasConsumptionBelowFusakaLimit() public { + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig + ) = _buildConfigs(false, false, 0, "Factory STV Pool", "FSTV"); + + Factory.TimelockConfig memory timelockConfig = _defaultTimelockConfig(); + address strategyFactory = address(0); + + vm.startPrank(admin); + uint256 gasBefore = gasleft(); + Factory.PoolIntermediate memory intermediate = wrapperFactory.createPoolStart{value: connectDeposit}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + uint256 gasUsedStart = gasBefore - gasleft(); + + uint256 gasBeforeFinish = gasleft(); + wrapperFactory.createPoolFinish(intermediate); + uint256 gasUsedFinish = gasBeforeFinish - gasleft(); + vm.stopPrank(); + + emit log_named_uint("createPoolStart gas", gasUsedStart); + emit log_named_uint("createPoolFinish gas", gasUsedFinish); + assertLt(gasUsedStart, fusakaTxGasLimit, "createPoolStart gas exceeds Fusaka limit"); + assertLt(gasUsedFinish, fusakaTxGasLimit, "createPoolFinish gas exceeds Fusaka limit"); + } + + function test_createPoolStartGasConsumptionBelowFusakaLimitForStvSteth() public { + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig + ) = _buildConfigs(false, true, 0, "Factory stETH Pool", "FSTETH"); + + Factory.TimelockConfig memory timelockConfig = _defaultTimelockConfig(); + address strategyFactory = address(0); + + vm.startPrank(admin); + uint256 gasBefore = gasleft(); + Factory.PoolIntermediate memory intermediate = wrapperFactory.createPoolStart{value: connectDeposit}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + uint256 gasUsedStart = gasBefore - gasleft(); + + uint256 gasBeforeFinish = gasleft(); + wrapperFactory.createPoolFinish(intermediate); + uint256 gasUsedFinish = gasBeforeFinish - gasleft(); + vm.stopPrank(); + + emit log_named_uint("createPoolStart stv steth gas", gasUsedStart); + emit log_named_uint("createPoolFinish stv steth gas", gasUsedFinish); + assertLt(gasUsedStart, fusakaTxGasLimit, "createPoolStart stv steth gas exceeds Fusaka limit"); + assertLt(gasUsedFinish, fusakaTxGasLimit, "createPoolFinish stv steth gas exceeds Fusaka limit"); + } + + function test_createPoolStartGasConsumptionBelowFusakaLimitForStvGgv() public { + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig + ) = _buildConfigs(true, true, 0, "Factory Strategy Pool", "FSP"); + + Factory.TimelockConfig memory timelockConfig = _defaultTimelockConfig(); + address strategyFactory = address(wrapperFactory.GGV_STRATEGY_FACTORY()); + + vm.startPrank(admin); + uint256 gasBefore = gasleft(); + Factory.PoolIntermediate memory intermediate = wrapperFactory.createPoolStart{value: connectDeposit}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + uint256 gasUsedStart = gasBefore - gasleft(); + + uint256 gasBeforeFinish = gasleft(); + wrapperFactory.createPoolFinish(intermediate); + uint256 gasUsedFinish = gasBeforeFinish - gasleft(); + vm.stopPrank(); + + emit log_named_uint("createPoolStart stv ggv gas", gasUsedStart); + emit log_named_uint("createPoolFinish stv ggv gas", gasUsedFinish); + assertLt(gasUsedStart, fusakaTxGasLimit, "createPoolStart stv ggv gas exceeds Fusaka limit"); + assertLt(gasUsedFinish, fusakaTxGasLimit, "createPoolFinish stv ggv gas exceeds Fusaka limit"); + } + + // ============ Finish Deadline Tests ============ + + function test_finishWithinDeadline() public { + // Test that finishing within the deadline (1 day) works correctly + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig + ) = _buildConfigs(false, false, 0, "Deadline Test Pool", "DTP"); + + Factory.TimelockConfig memory timelockConfig = _defaultTimelockConfig(); + address strategyFactory = address(0); + + vm.startPrank(admin); + Factory.PoolIntermediate memory intermediate = wrapperFactory.createPoolStart{value: connectDeposit}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + + // Move time forward but still within deadline (23 hours) + vm.warp(block.timestamp + 23 hours); + + // Should succeed + Factory.PoolDeployment memory deployment = wrapperFactory.createPoolFinish(intermediate); + vm.stopPrank(); + + // Verify deployment was successful + assertTrue(deployment.pool != address(0), "Pool should be deployed"); + assertTrue(deployment.dashboard != address(0), "Dashboard should be deployed"); + } + + function test_finishAtExactDeadline() public { + // Test that finishing exactly at the deadline works + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig + ) = _buildConfigs(false, false, 0, "Deadline Test Pool", "DTP"); + + Factory.TimelockConfig memory timelockConfig = _defaultTimelockConfig(); + address strategyFactory = address(0); + + vm.startPrank(admin); + Factory.PoolIntermediate memory intermediate = wrapperFactory.createPoolStart{value: connectDeposit}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + + // Move time forward to exactly the deadline (1 day) + vm.warp(block.timestamp + wrapperFactory.DEPLOY_START_FINISH_SPAN_SECONDS()); + + // Should succeed (deadline is inclusive) + Factory.PoolDeployment memory deployment = wrapperFactory.createPoolFinish(intermediate); + vm.stopPrank(); + + assertTrue(deployment.pool != address(0), "Pool should be deployed at exact deadline"); + } + + function test_revertFinishAfterDeadlineExpired() public { + // Test that finishing after the deadline reverts + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig + ) = _buildConfigs(false, false, 0, "Deadline Test Pool", "DTP"); + + Factory.TimelockConfig memory timelockConfig = _defaultTimelockConfig(); + address strategyFactory = address(0); + + vm.startPrank(admin); + Factory.PoolIntermediate memory intermediate = wrapperFactory.createPoolStart{value: connectDeposit}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + + // Move time forward past the deadline (1 day + 1 second) + vm.warp(block.timestamp + wrapperFactory.DEPLOY_START_FINISH_SPAN_SECONDS() + 1); + + // Should revert with deadline passed error + vm.expectRevert(abi.encodeWithSignature("InvalidConfiguration(string)", "deploy finish deadline passed")); + wrapperFactory.createPoolFinish(intermediate); + vm.stopPrank(); + } + + function test_revertFinishWithoutStart() public { + // Test that calling finish without start reverts + + // Create an intermediate struct but don't call createPoolStart + Factory.PoolIntermediate memory fakeIntermediate = Factory.PoolIntermediate({ + pool: address(0x123), timelock: address(0x456), strategyFactory: address(0), strategyDeployBytes: "" + }); + + vm.startPrank(admin); + // Should revert with "deploy not started" error + vm.expectRevert(abi.encodeWithSignature("InvalidConfiguration(string)", "deploy not started")); + wrapperFactory.createPoolFinish(fakeIntermediate); + vm.stopPrank(); + } + + function test_revertDoubleFinish() public { + // Test that calling finish twice on the same deployment reverts + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig + ) = _buildConfigs(false, false, 0, "Deadline Test Pool", "DTP"); + + Factory.TimelockConfig memory timelockConfig = _defaultTimelockConfig(); + address strategyFactory = address(0); + + vm.startPrank(admin); + Factory.PoolIntermediate memory intermediate = wrapperFactory.createPoolStart{value: connectDeposit}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + + // First finish should succeed + wrapperFactory.createPoolFinish(intermediate); + + // Second finish should revert with "deploy already finished" + vm.expectRevert(abi.encodeWithSignature("InvalidConfiguration(string)", "deploy already finished")); + wrapperFactory.createPoolFinish(intermediate); + vm.stopPrank(); + } + + function test_finishDeadlineIndependentPerDeployer() public { + // Test that different deployers have independent deadlines for the same config + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig + ) = _buildConfigs(false, false, 0, "Deadline Test Pool", "DTP"); + + Factory.TimelockConfig memory timelockConfig = _defaultTimelockConfig(); + address strategyFactory = address(0); + + address deployer1 = address(0x1001); + address deployer2 = address(0x1002); + vm.deal(deployer1, 10 ether); + vm.deal(deployer2, 10 ether); + + // Deployer 1 starts deployment + vm.prank(deployer1); + Factory.PoolIntermediate memory intermediate1 = wrapperFactory.createPoolStart{value: connectDeposit}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + + // Move time forward + vm.warp(block.timestamp + 12 hours); + + // Deployer 2 starts deployment + vm.prank(deployer2); + Factory.PoolIntermediate memory intermediate2 = wrapperFactory.createPoolStart{value: connectDeposit}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + + // Move time forward past deployer1's deadline but within deployer2's deadline + vm.warp(block.timestamp + 13 hours); // Total: 25 hours from deployer1's start, 13 hours from deployer2's start + + // Deployer 1's finish should fail (past deadline) + vm.prank(deployer1); + vm.expectRevert(abi.encodeWithSignature("InvalidConfiguration(string)", "deploy finish deadline passed")); + wrapperFactory.createPoolFinish(intermediate1); + + // Deployer 2's finish should succeed (within deadline) + vm.prank(deployer2); + Factory.PoolDeployment memory deployment2 = wrapperFactory.createPoolFinish(intermediate2); + assertTrue(deployment2.pool != address(0), "Deployer 2 should successfully finish"); + } } diff --git a/test/integration/dashboard-roles.test.sol b/test/integration/dashboard-roles.test.sol new file mode 100644 index 0000000..6bdfda7 --- /dev/null +++ b/test/integration/dashboard-roles.test.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {StvStETHPoolHarness} from "test/utils/StvStETHPoolHarness.sol"; + +/** + * @title DashboardRolesTest + * @notice Integration tests for Dashboard roles + */ +contract DashboardRolesTest is StvStETHPoolHarness { + WrapperContext ctxMintEnabled; + WrapperContext ctxMintDisabled; + + address nodeOperatorManager = NODE_OPERATOR; + + function setUp() public { + _initializeCore(); + + ctxMintEnabled = _deployStvStETHPool({enableAllowlist: false, nodeOperatorFeeBP: 200, reserveRatioGapBP: 500}); + ctxMintDisabled = _deployStvPool({enableAllowlist: false, nodeOperatorFeeBP: 200}); + } + + // Helpers + + function _fetchRoleHash(WrapperContext storage ctx, string memory roleName) internal view returns (bytes32) { + bytes4 selector = bytes4(keccak256(abi.encodePacked(string.concat(roleName, "()")))); + (bool ok, bytes memory result) = address(ctx.dashboard).staticcall(abi.encodePacked(selector)); + + assertTrue(ok, "Failed to get role hash"); + return abi.decode(result, (bytes32)); + } + + function _assertRoleAssigned(WrapperContext storage ctx, string memory roleName, address expectedMember) + internal + view + { + bytes32 roleHash = _fetchRoleHash(ctx, roleName); + assertEq(ctx.dashboard.getRoleMemberCount(roleHash), 1, string.concat(roleName, " member count mismatch")); + assertEq(ctx.dashboard.getRoleMember(roleHash, 0), expectedMember, string.concat(roleName, " member mismatch")); + } + + function _assertRoleNotAssigned(WrapperContext storage ctx, string memory roleName) internal view { + bytes32 roleHash = _fetchRoleHash(ctx, roleName); + assertEq(ctx.dashboard.getRoleMemberCount(roleHash), 0, string.concat(roleName, " member count mismatch")); + } + + // Should be assign to the Timelock contract + // - DEFAULT_ADMIN_ROLE + + function test_DashboardRoles_TimelockIsAdmin() public view { + _assertRoleAssigned(ctxMintEnabled, "DEFAULT_ADMIN_ROLE", address(ctxMintEnabled.timelock)); + _assertRoleAssigned(ctxMintDisabled, "DEFAULT_ADMIN_ROLE", address(ctxMintDisabled.timelock)); + } + + // Should be assigned to the Node Operator Manager + // - NODE_OPERATOR_MANAGER_ROLE + + function test_DashboardRoles_NodeOperatorManagerIsAssigned() public view { + _assertRoleAssigned(ctxMintEnabled, "NODE_OPERATOR_MANAGER_ROLE", address(nodeOperatorManager)); + _assertRoleAssigned(ctxMintDisabled, "NODE_OPERATOR_MANAGER_ROLE", address(nodeOperatorManager)); + } + + // Should be assigned to the the Pool contract + // - FUND_ROLE + // - REBALANCE_ROLE + + function test_DashboardRoles_FundRoleIsAssigned() public view { + _assertRoleAssigned(ctxMintEnabled, "FUND_ROLE", address(ctxMintEnabled.pool)); + _assertRoleAssigned(ctxMintDisabled, "FUND_ROLE", address(ctxMintDisabled.pool)); + } + + function test_DashboardRoles_RebalanceRoleIsAssigned() public view { + _assertRoleAssigned(ctxMintEnabled, "REBALANCE_ROLE", address(ctxMintEnabled.pool)); + _assertRoleAssigned(ctxMintDisabled, "REBALANCE_ROLE", address(ctxMintDisabled.pool)); + } + + // Should be assigned to the Pool contract ONLY if minting is enabled + // - MINT_ROLE + // - BURN_ROLE + + function test_DashboardRoles_MintRoleIsAssigned() public view { + _assertRoleAssigned(ctxMintEnabled, "MINT_ROLE", address(ctxMintEnabled.pool)); + _assertRoleNotAssigned(ctxMintDisabled, "MINT_ROLE"); + } + + function test_DashboardRoles_BurnRoleIsAssigned() public view { + _assertRoleAssigned(ctxMintEnabled, "BURN_ROLE", address(ctxMintEnabled.pool)); + _assertRoleNotAssigned(ctxMintDisabled, "BURN_ROLE"); + } + + // Should be assigned to the WithdrawalQueue contract: + // - WITHDRAW_ROLE + + function test_DashboardRoles_WithdrawalRoleIsAssigned() public view { + _assertRoleAssigned(ctxMintEnabled, "WITHDRAW_ROLE", address(ctxMintEnabled.withdrawalQueue)); + _assertRoleAssigned(ctxMintDisabled, "WITHDRAW_ROLE", address(ctxMintDisabled.withdrawalQueue)); + } + + // Should not be assigned to anyone + // - NODE_OPERATOR_UNGUARANTEED_DEPOSIT_ROLE - can be assigned from node operator manager + // - NODE_OPERATOR_PROVE_UNKNOWN_VALIDATOR_ROLE - can be assigned from node operator manager + // - NODE_OPERATOR_FEE_EXEMPT_ROLE - can be assigned from node operator manager + // - VOLUNTARY_DISCONNECT_ROLE - can be assigned from timelock + + function test_DashboardRoles_UnguaranteedRoleIsNotAssigned() public view { + _assertRoleNotAssigned(ctxMintEnabled, "NODE_OPERATOR_UNGUARANTEED_DEPOSIT_ROLE"); + _assertRoleNotAssigned(ctxMintDisabled, "NODE_OPERATOR_UNGUARANTEED_DEPOSIT_ROLE"); + } + + function test_DashboardRoles_ProveUnknownValidatorRoleIsNotAssigned() public view { + _assertRoleNotAssigned(ctxMintEnabled, "NODE_OPERATOR_PROVE_UNKNOWN_VALIDATOR_ROLE"); + _assertRoleNotAssigned(ctxMintDisabled, "NODE_OPERATOR_PROVE_UNKNOWN_VALIDATOR_ROLE"); + } + + function test_DashboardRoles_FeeExemptRoleIsNotAssigned() public view { + _assertRoleNotAssigned(ctxMintEnabled, "NODE_OPERATOR_FEE_EXEMPT_ROLE"); + _assertRoleNotAssigned(ctxMintDisabled, "NODE_OPERATOR_FEE_EXEMPT_ROLE"); + } + + function test_DashboardRoles_VoluntaryDisconnectRoleIsNotAssigned() public view { + _assertRoleNotAssigned(ctxMintEnabled, "VOLUNTARY_DISCONNECT_ROLE"); + _assertRoleNotAssigned(ctxMintDisabled, "VOLUNTARY_DISCONNECT_ROLE"); + } + + // Can be assigned: + // - COLLECT_VAULT_ERC20_ROLE + // - VAULT_CONFIGURATION_ROLE + // - REQUEST_VALIDATOR_EXIT_ROLE + // - TRIGGER_VALIDATOR_WITHDRAWAL_ROLE + // - PAUSE_BEACON_CHAIN_DEPOSITS_ROLE + // - RESUME_BEACON_CHAIN_DEPOSITS_ROLE +} diff --git a/test/integration/dashboard.test.sol b/test/integration/dashboard.test.sol new file mode 100644 index 0000000..e5084f2 --- /dev/null +++ b/test/integration/dashboard.test.sol @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {IOperatorGrid} from "src/interfaces/core/IOperatorGrid.sol"; +import {IVaultHub} from "src/interfaces/core/IVaultHub.sol"; +import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; +import {StvPoolHarness} from "test/utils/StvPoolHarness.sol"; +import {TimelockHarness} from "test/utils/TimelockHarness.sol"; + +/** + * @title DashboardTest + * @notice Integration tests for Dashboard functionality + */ +contract DashboardTest is StvPoolHarness, TimelockHarness { + WrapperContext ctx; + + // Deployment parameters + uint256 nodeOperatorFeeBP = 200; // 2% + uint256 confirmExpiry = CONFIRM_EXPIRY; + address feeRecipient = NODE_OPERATOR; + + // Role holders + address nodeOperatorManager = NODE_OPERATOR; + + function setUp() public { + _initializeCore(); + + ctx = _deployStvPool({enableAllowlist: false, nodeOperatorFeeBP: nodeOperatorFeeBP}); + _setupTimelock(address(ctx.timelock), NODE_OPERATOR, NODE_OPERATOR); + } + + // Timelock tests + + function test_Dashboard_RolesAreSetCorrectly() public view { + // Check that the timelock is the admin of the dashboard + bytes32 adminRole = ctx.dashboard.DEFAULT_ADMIN_ROLE(); + assertTrue(ctx.dashboard.hasRole(adminRole, address(ctx.timelock))); + assertEq(ctx.dashboard.getRoleMember(adminRole, 0), address(ctx.timelock)); + assertEq(ctx.dashboard.getRoleMemberCount(adminRole), 1); + assertEq(ctx.dashboard.getRoleAdmin(adminRole), adminRole); + + // Check that the timelock has proposer and executor roles + bytes32 proposerRole = ctx.timelock.PROPOSER_ROLE(); + bytes32 executorRole = ctx.timelock.EXECUTOR_ROLE(); + + assertTrue(ctx.timelock.hasRole(proposerRole, timelockProposer)); + assertTrue(ctx.timelock.hasRole(executorRole, timelockExecutor)); + } + + // Methods required both DEFAULT_ADMIN_ROLE and NODE_OPERATOR_MANAGER_ROLE access: + // - setFeeRate + // - setConfirmExpiry + // - correctSettledGrowth + + function test_Dashboard_CanSetFeeRate() public { + assertEq(ctx.dashboard.feeRate(), nodeOperatorFeeBP); + uint256 expectedOperatorFeeBP = nodeOperatorFeeBP + 100; // + 1% + + // 1. Set Fee Rate by Timelock + _timelockSchedule(address(ctx.dashboard), abi.encodeWithSignature("setFeeRate(uint256)", expectedOperatorFeeBP)); + _timelockWarp(); + reportVaultValueChangeNoFees(ctx, 0); // setFeeRate() requires oracle report + _timelockExecute(address(ctx.dashboard), abi.encodeWithSignature("setFeeRate(uint256)", expectedOperatorFeeBP)); + + assertEq(ctx.dashboard.feeRate(), nodeOperatorFeeBP); // shouldn't change + + // 2. Set Fee Rate by Node Operator Manager + vm.prank(nodeOperatorManager); + bool updated = ctx.dashboard.setFeeRate(expectedOperatorFeeBP); + assertTrue(updated); + assertEq(uint256(ctx.dashboard.feeRate()), expectedOperatorFeeBP); + } + + function test_Dashboard_CanSetConfirmExpiry() public { + assertEq(ctx.dashboard.getConfirmExpiry(), confirmExpiry); + uint256 newConfirmExpiry = confirmExpiry + 1 hours; + + // 1. Set Confirm Expiry by Timelock + _timelockScheduleAndExecute( + address(ctx.dashboard), abi.encodeWithSignature("setConfirmExpiry(uint256)", newConfirmExpiry) + ); + assertEq(ctx.dashboard.getConfirmExpiry(), confirmExpiry); // shouldn't change + + // 2. Set Confirm Expiry by Node Operator Manager + vm.prank(nodeOperatorManager); + bool updated = ctx.dashboard.setConfirmExpiry(newConfirmExpiry); + assertTrue(updated); + assertEq(uint256(ctx.dashboard.getConfirmExpiry()), newConfirmExpiry); + } + + function test_Dashboard_CanCorrectSettledGrowth() public { + int256 initialSettledGrowth = ctx.dashboard.settledGrowth(); + int256 newSettledGrowth = initialSettledGrowth + 1; + + // 1. Correct Settled Growth by Timelock + _timelockScheduleAndExecute( + address(ctx.dashboard), + abi.encodeWithSignature("correctSettledGrowth(int256,int256)", newSettledGrowth, initialSettledGrowth) + ); + assertEq(ctx.dashboard.settledGrowth(), initialSettledGrowth); // shouldn't change + + // 2. Correct Settled Growth by Node Operator Manager + vm.prank(nodeOperatorManager); + bool updated = ctx.dashboard.correctSettledGrowth(newSettledGrowth, initialSettledGrowth); + assertTrue(updated); + assertEq(ctx.dashboard.settledGrowth(), newSettledGrowth); + } + + // Methods required a DEFAULT_ADMIN_ROLE access: + // - disburseAbnormallyHighFee + // - recoverERC20 + // - collectERC20FromVault (can also be called from COLLECT_VAULT_ERC20_ROLE) + + function test_Dashboard_CanDisburseAbnormallyHighFee() public { + _timelockScheduleAndExecute(address(ctx.dashboard), abi.encodeWithSignature("disburseAbnormallyHighFee()")); + } + + function test_Dashboard_CanRecoverERC20() public { + address receiver = makeAddr("receiver"); + + // ERC20 + ERC20 tokenERC20 = new ERC20(); + uint256 amountERC20 = 1 * 10 ** 18; + + tokenERC20.mint(address(ctx.dashboard), amountERC20); + + _timelockScheduleAndExecute( + address(ctx.dashboard), + abi.encodeWithSignature("recoverERC20(address,address,uint256)", address(tokenERC20), receiver, amountERC20) + ); + vm.assertEq(tokenERC20.balanceOf(receiver), amountERC20); + + // ETH + address tokenETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + uint256 amountETH = 1 ether; + + uint256 receiverInitialBalance = receiver.balance; + vm.deal(address(ctx.dashboard), amountETH); + + _timelockScheduleAndExecute( + address(ctx.dashboard), + abi.encodeWithSignature("recoverERC20(address,address,uint256)", tokenETH, receiver, amountETH) + ); + vm.assertEq(receiver.balance, receiverInitialBalance + amountETH); + } + + function test_Dashboard_CanCollectERC20FromVault() public { + ERC20 token = new ERC20(); + address receiver = makeAddr("receiver"); + uint256 amount = 1 * 10 ** 18; + + token.mint(address(ctx.vault), amount); + + _timelockScheduleAndExecute( + address(ctx.dashboard), + abi.encodeWithSignature("collectERC20FromVault(address,address,uint256)", address(token), receiver, amount) + ); + vm.assertEq(token.balanceOf(receiver), amount); + } + + // Methods required a single-role access: + // - addFeeExemption. Requires NODE_OPERATOR_FEE_EXEMPT_ROLE + // - setFeeRecipient. Requires NODE_OPERATOR_MANAGER_ROLE + // - changeTier. Requires VAULT_CONFIGURATION_ROLE + // - syncTier. Requires VAULT_CONFIGURATION_ROLE + // - updateShareLimit. Requires VAULT_CONFIGURATION_ROLE + + function test_Dashboard_CanAddFeeExemption() public { + // The role is not granted initially + bytes32 feeExemptionRole = ctx.dashboard.NODE_OPERATOR_FEE_EXEMPT_ROLE(); + assertFalse(ctx.dashboard.hasRole(feeExemptionRole, address(this))); + + // Grant the role to this contract + vm.prank(nodeOperatorManager); + ctx.dashboard.grantRole(feeExemptionRole, address(this)); + assertTrue(ctx.dashboard.hasRole(feeExemptionRole, address(this))); + + // Add fee exemptions + ctx.dashboard.addFeeExemption(1 wei); + } + + function test_Dashboard_CanSetFeeRecipient() public { + assertEq(ctx.dashboard.feeRecipient(), feeRecipient); + address newFeeRecipient = makeAddr("newFeeRecipient"); + + vm.prank(nodeOperatorManager); + ctx.dashboard.setFeeRecipient(newFeeRecipient); + } + + function test_Dashboard_CanChangeTier() public { + // Register a new tier + IOperatorGrid operatorGrid = core.operatorGrid(); + IOperatorGrid.TierParams memory tier = IOperatorGrid.TierParams({ + shareLimit: 10 * 10 ** 18, + reserveRatioBP: 1000, + forcedRebalanceThresholdBP: 500, + infraFeeBP: 100, + liquidityFeeBP: 50, + reservationFeeBP: 25 + }); + IOperatorGrid.TierParams[] memory params = new IOperatorGrid.TierParams[](1); + params[0] = tier; + + address registrator = operatorGrid.getRoleMember(operatorGrid.REGISTRY_ROLE(), 0); + uint256 tierId = operatorGrid.tiersCount(); + + vm.startPrank(registrator); + operatorGrid.registerGroup(NODE_OPERATOR, 100 * 10 ** 18); + + vm.expectEmit(true, true, true, false); + emit IOperatorGrid.TierAdded(NODE_OPERATOR, tierId, 0, 0, 0, 0, 0, 0); + + operatorGrid.registerTiers(NODE_OPERATOR, params); + vm.stopPrank(); + + // The role is not granted initially + bytes32 vaultConfigurationRole = ctx.dashboard.VAULT_CONFIGURATION_ROLE(); + assertFalse(ctx.dashboard.hasRole(vaultConfigurationRole, address(this))); + + // Grant the role to this contract + _timelockScheduleAndExecute( + address(ctx.dashboard), + abi.encodeWithSignature("grantRole(bytes32,address)", vaultConfigurationRole, address(this)) + ); + assertTrue(ctx.dashboard.hasRole(vaultConfigurationRole, address(this))); + + // Change tier + ctx.dashboard.changeTier(tierId, 10 ** 18); + } + + function test_Dashboard_CanSyncTier() public { + // Modify the current tier + IOperatorGrid operatorGrid = core.operatorGrid(); + (, uint256 tierId,,,,,,) = operatorGrid.vaultTierInfo(address(ctx.vault)); + + uint256[] memory ids = new uint256[](1); + ids[0] = tierId; + + IOperatorGrid.TierParams[] memory params = new IOperatorGrid.TierParams[](1); + IOperatorGrid.Tier memory tier = operatorGrid.tier(tierId); + params[0] = IOperatorGrid.TierParams({ + shareLimit: tier.shareLimit, + reserveRatioBP: tier.reserveRatioBP, + forcedRebalanceThresholdBP: tier.forcedRebalanceThresholdBP, + infraFeeBP: tier.infraFeeBP + 1, // change infra fee + liquidityFeeBP: tier.liquidityFeeBP, + reservationFeeBP: tier.reservationFeeBP + }); + + address registrator = operatorGrid.getRoleMember(operatorGrid.REGISTRY_ROLE(), 0); + vm.prank(registrator); + operatorGrid.alterTiers(ids, params); + + // The role is not granted initially + bytes32 vaultConfigurationRole = ctx.dashboard.VAULT_CONFIGURATION_ROLE(); + assertFalse(ctx.dashboard.hasRole(vaultConfigurationRole, address(this))); + + // Grant the role to this contract + _timelockScheduleAndExecute( + address(ctx.dashboard), + abi.encodeWithSignature("grantRole(bytes32,address)", vaultConfigurationRole, address(this)) + ); + assertTrue(ctx.dashboard.hasRole(vaultConfigurationRole, address(this))); + + // Sync tier + ctx.dashboard.syncTier(); + } + + function test_Dashboard_CanUpdateShareLimit() public { + uint256 currentShareLimit = ctx.dashboard.vaultConnection().shareLimit; + assertGt(currentShareLimit, 10 ** 18); + + uint256 newShareLimit = currentShareLimit - 10 ** 18; + + // The role is not granted initially + bytes32 vaultConfigurationRole = ctx.dashboard.VAULT_CONFIGURATION_ROLE(); + assertFalse(ctx.dashboard.hasRole(vaultConfigurationRole, address(this))); + + // Grant the role to this contract + _timelockScheduleAndExecute( + address(ctx.dashboard), + abi.encodeWithSignature("grantRole(bytes32,address)", vaultConfigurationRole, address(this)) + ); + assertTrue(ctx.dashboard.hasRole(vaultConfigurationRole, address(this))); + + // Update share limit + ctx.dashboard.updateShareLimit(newShareLimit); + } + + // Voluntary disconnect from VaultHub + + function test_Dashboard_CanVoluntaryDisconnect() public { + IVaultHub vaultHub = core.vaultHub(); + + // Verify initial connection state + assertTrue(vaultHub.isVaultConnected(address(ctx.vault))); + assertFalse(vaultHub.isPendingDisconnect(address(ctx.vault))); + + // Schedule and execute disconnect + _timelockSchedule(address(ctx.dashboard), abi.encodeWithSignature("voluntaryDisconnect()")); + _timelockWarp(); + reportVaultValueChangeNoFees(ctx, 0); // voluntaryDisconnect() requires fresh oracle report + _timelockExecute(address(ctx.dashboard), abi.encodeWithSignature("voluntaryDisconnect()")); + + // Verify disconnect is pending + assertTrue(vaultHub.isVaultConnected(address(ctx.vault))); + assertTrue(vaultHub.isPendingDisconnect(address(ctx.vault))); + + // Apply oracle report to finalize disconnect + IVaultHub.VaultRecord memory vaultRecord = vaultHub.vaultRecord(address(ctx.vault)); + + vm.prank(address(core.lazyOracle())); + vaultHub.applyVaultReport({ + _vault: address(ctx.vault), + _reportTimestamp: block.timestamp, + _reportTotalValue: vaultRecord.report.totalValue, + _reportInOutDelta: vaultRecord.report.inOutDelta, + _reportCumulativeLidoFees: vaultRecord.cumulativeLidoFees, + _reportLiabilityShares: vaultRecord.liabilityShares, + _reportMaxLiabilityShares: vaultRecord.maxLiabilityShares, + _reportSlashingReserve: 0 + }); + + assertFalse(vaultHub.isVaultConnected(address(ctx.vault))); + } +} + +contract ERC20 is ERC20Upgradeable { + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/test/integration/disconnect.test.sol b/test/integration/disconnect.test.sol new file mode 100644 index 0000000..1a785dc --- /dev/null +++ b/test/integration/disconnect.test.sol @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; +import {IOperatorGrid} from "src/interfaces/core/IOperatorGrid.sol"; +import {IVaultHub} from "src/interfaces/core/IVaultHub.sol"; +import {IWstETH} from "src/interfaces/core/IWstETH.sol"; +import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; +import {FeaturePausable} from "src/utils/FeaturePausable.sol"; +import {StvStETHPoolHarness} from "test/utils/StvStETHPoolHarness.sol"; +import {TimelockHarness} from "test/utils/TimelockHarness.sol"; + +/** + * @title DisconnectTest + * @notice Disconnection flow steps + * + * - Inform users about upcoming disconnect and timeline + * - Make sure all roles you will need are assigned + * - Exit all validators + * - Voluntarily if possible + * - Forcibly if needed: + * - Call `triggerValidatorWithdrawals` on Pool contract from `TRIGGER_VALIDATOR_WITHDRAWAL_ROLE` + * - Finalize all withdrawal requests + * - Pause deposits and minting (if enabled) on Pool contract and withdrawals on Withdrawal Queue contract + * - Call `pauseDeposits` method on Pool contract from `DEPOSITS_PAUSE_ROLE` + * - Call `pauseMinting` method on Pool contract from `MINTING_PAUSE_ROLE` + * - Call `pauseWithdrawals` method on Withdrawal Queue contract from `WITHDRAWALS_PAUSE_ROLE` + * - Rebalance Staking Vault if liability shares are left + * - Rebalance Staking Vault to zero liability + * - Call `rebalanceVaultWithShares` on Dashboard contract from `REBALANCE_ROLE` + * - Ensure no undercollateralized users. Force rebalance them if any exist + * - Call `forceRebalanceAndSocializeLoss` on Pool contract from `LOSS_SOCIALIZER_ROLE` + * - Disconnect Staking Vault + * - Initiate voluntary disconnect on Dashboard from Timelock Controller + * - Withdraw assets from Staking Vault and distribute them to users + * - Make sure you account for Initial Connect Deposit that remains locked in the vault + */ +contract DisconnectTest is StvStETHPoolHarness, TimelockHarness { + WrapperContext ctx; + + address finalizer = NODE_OPERATOR; + + function setUp() public { + _initializeCore(); + + ctx = _deployStvStETHPool({enableAllowlist: false, nodeOperatorFeeBP: 200, reserveRatioGapBP: 500}); + _setupTimelock(address(ctx.timelock), NODE_OPERATOR, NODE_OPERATOR); + + vm.deal(address(this), 100 ether); + } + + function test_Disconnect_InitialState() public view { + // Vault is connected + assertTrue(core.vaultHub().isVaultConnected(address(ctx.vault)), "Vault should be connected"); + assertFalse(core.vaultHub().isPendingDisconnect(address(ctx.vault)), "Vault should not be pending disconnect"); + + // Pool has assets and supply + assertGt(ctx.pool.totalAssets(), 0, "Pool should have assets"); + assertGt(ctx.pool.totalSupply(), 0, "Pool should have supply"); + + // No liability + assertEq(ctx.dashboard.liabilityShares(), 0, "Should have no liability shares initially"); + } + + function test_Disconnect_VoluntaryDisconnect() public { + IVaultHub vaultHub = core.vaultHub(); + StvStETHPool pool = stvStETHPool(ctx); + + // Users can deposit before disconnect + uint256 depositAmount = 10 ether; + pool.depositETH{value: depositAmount}(address(this), address(0)); + assertGt(pool.balanceOf(address(this)), 0, "User should receive STV tokens"); + assertApproxEqAbs( + pool.assetsOf(address(this)), depositAmount, WEI_ROUNDING_TOLERANCE, "User assets should match deposit" + ); + + // Users can mint stETH before disconnect + uint256 stethSharesToMint = 10 ** 18; + pool.mintStethShares(stethSharesToMint); + assertEq(pool.mintedStethSharesOf(address(this)), stethSharesToMint, "User should have minted stETH shares"); + + // Disconnect should revert since liability shares are not zero + vm.prank(address(ctx.timelock)); + vm.expectRevert( + abi.encodeWithSignature( + "NoLiabilitySharesShouldBeLeft(address,uint256)", address(ctx.vault), stethSharesToMint + ) + ); + ctx.dashboard.voluntaryDisconnect(); + + // Users have time to exit from the pool + uint256 requestId = ctx.withdrawalQueue.requestWithdrawal(address(this), pool.balanceOf(address(this)) / 5, 0); + vm.warp(block.timestamp + 30 days); + + // Assign roles to temp trusted actor + address trustedActor = makeAddr("trustedActor"); + vm.deal(trustedActor, 10 ether); + + address[] memory targets = new address[](7); + bytes[] memory payloads = new bytes[](7); + + targets[0] = address(pool); + targets[1] = address(pool); + targets[2] = address(pool); + targets[3] = address(ctx.withdrawalQueue); + targets[4] = address(ctx.withdrawalQueue); + targets[5] = address(ctx.dashboard); + targets[6] = address(ctx.dashboard); + + bytes32 lossSocializerRole = pool.LOSS_SOCIALIZER_ROLE(); + bytes32 depositsPauseRole = pool.DEPOSITS_PAUSE_ROLE(); + bytes32 mintingPauseRole = pool.MINTING_PAUSE_ROLE(); + bytes32 withdrawalsPauseRole = ctx.withdrawalQueue.WITHDRAWALS_PAUSE_ROLE(); + bytes32 finalizeRole = ctx.withdrawalQueue.FINALIZE_ROLE(); + bytes32 triggerValidatorRole = ctx.dashboard.TRIGGER_VALIDATOR_WITHDRAWAL_ROLE(); + bytes32 rebalanceRole = ctx.dashboard.REBALANCE_ROLE(); + + payloads[0] = abi.encodeWithSignature("grantRole(bytes32,address)", lossSocializerRole, trustedActor); + payloads[1] = abi.encodeWithSignature("grantRole(bytes32,address)", depositsPauseRole, trustedActor); + payloads[2] = abi.encodeWithSignature("grantRole(bytes32,address)", mintingPauseRole, trustedActor); + payloads[3] = abi.encodeWithSignature("grantRole(bytes32,address)", withdrawalsPauseRole, trustedActor); + payloads[4] = abi.encodeWithSignature("grantRole(bytes32,address)", finalizeRole, trustedActor); + payloads[5] = abi.encodeWithSignature("grantRole(bytes32,address)", triggerValidatorRole, trustedActor); + payloads[6] = abi.encodeWithSignature("grantRole(bytes32,address)", rebalanceRole, trustedActor); + + _timelockScheduleAndExecuteBatch(targets, payloads); + + // Verify roles assigned + assertTrue(pool.hasRole(lossSocializerRole, trustedActor), "Loss socializer role should be granted"); + assertTrue(pool.hasRole(depositsPauseRole, trustedActor), "Deposits pause role should be granted"); + assertTrue(pool.hasRole(mintingPauseRole, trustedActor), "Minting pause role should be granted"); + assertTrue( + ctx.withdrawalQueue.hasRole(withdrawalsPauseRole, trustedActor), "Withdrawals pause role should be granted" + ); + assertTrue(ctx.withdrawalQueue.hasRole(finalizeRole, trustedActor), "Finalize role should be granted"); + assertTrue( + ctx.dashboard.hasRole(triggerValidatorRole, trustedActor), "Trigger validator role should be granted" + ); + assertTrue(ctx.dashboard.hasRole(rebalanceRole, trustedActor), "Rebalance role should be granted"); + + // Pause Withdrawal Queue + bytes32 withdrawalsFeatureId = ctx.withdrawalQueue.WITHDRAWALS_FEATURE(); + vm.prank(trustedActor); + ctx.withdrawalQueue.pauseWithdrawals(); + assertTrue(ctx.withdrawalQueue.isFeaturePaused(withdrawalsFeatureId), "Withdrawals feature should be paused"); + + // Check withdrawal requests are paused + uint256 toWithdrawAmount = pool.balanceOf(address(this)) / 5; + assertGt(toWithdrawAmount, 0, "Amount to withdraw should be greater than zero"); + vm.expectRevert(abi.encodeWithSelector(FeaturePausable.FeaturePauseEnforced.selector, withdrawalsFeatureId)); + ctx.withdrawalQueue.requestWithdrawal(address(this), toWithdrawAmount, 0); + + // Check there are requests to finalize + uint256 requestToFinalized = ctx.withdrawalQueue.unfinalizedRequestsNumber(); + assertGt(requestToFinalized, 0, "Should have unfinalized withdrawal requests"); + + // Oracle report to update vault state + reportVaultValueChangeNoFees(ctx, 100_00); + + // Finalize all withdrawal requests + vm.prank(trustedActor); + ctx.withdrawalQueue.finalize(requestToFinalized, address(0)); + assertEq(ctx.withdrawalQueue.unfinalizedRequestsNumber(), 0, "All requests should be finalized"); + assertEq(ctx.withdrawalQueue.unfinalizedStv(), 0, "No unfinalized STV should remain"); + assertEq(ctx.withdrawalQueue.unfinalizedAssets(), 0, "No unfinalized assets should remain"); + assertEq(ctx.withdrawalQueue.unfinalizedStethShares(), 0, "No unfinalized stETH shares should remain"); + + // Pause Deposits + bytes32 depositsFeatureId = pool.DEPOSITS_FEATURE(); + vm.prank(trustedActor); + pool.pauseDeposits(); + assertTrue(pool.isFeaturePaused(depositsFeatureId), "Deposits feature should be paused"); + + // Check deposits are paused + vm.expectRevert(abi.encodeWithSelector(FeaturePausable.FeaturePauseEnforced.selector, depositsFeatureId)); + pool.depositETH{value: 1 ether}(address(this), address(0)); + + // Pause Minting + bytes32 mintingFeatureId = pool.MINTING_FEATURE(); + vm.prank(trustedActor); + pool.pauseMinting(); + assertTrue(pool.isFeaturePaused(mintingFeatureId), "Minting feature should be paused"); + + // Check minting is paused + vm.expectRevert(abi.encodeWithSelector(FeaturePausable.FeaturePauseEnforced.selector, mintingFeatureId)); + pool.mintStethShares(10 ** 18); + + vm.expectRevert(abi.encodeWithSelector(FeaturePausable.FeaturePauseEnforced.selector, mintingFeatureId)); + pool.mintWsteth(10 ** 18); + + // Verify validators can be forcibly withdrawn + bytes memory mockPubkey = new bytes(48); + mockPubkey[47] = 0x01; + + uint64[] memory amountsInGwei = new uint64[](1); + amountsInGwei[0] = 32 * 10 ** 9; + + uint256 withdrawalFee = 10 gwei; + address twContract = 0x00000961Ef480Eb55e80D19ad83579A64c007002; // EL triggerable withdrawals (EIP-7002) contract + + vm.mockCall(twContract, new bytes(0), abi.encode(uint256(withdrawalFee))); + vm.mockCall(twContract, bytes.concat(mockPubkey, bytes8(uint64(amountsInGwei[0]))), new bytes(0)); + vm.prank(trustedActor); + ctx.dashboard.triggerValidatorWithdrawals{value: withdrawalFee}(mockPubkey, amountsInGwei, trustedActor); + + // Check vault has liability shares + uint256 liabilityShares = ctx.dashboard.liabilityShares(); + assertGt(liabilityShares, 0, "Vault should have liability shares before rebalance"); + + // Rebalance vault to zero liability + vm.prank(trustedActor); + ctx.dashboard.rebalanceVaultWithShares(liabilityShares); + assertEq(ctx.dashboard.liabilityShares(), 0, "Liability shares should be zero after rebalance"); + + // Schedule and execute disconnect + _timelockSchedule(address(ctx.dashboard), abi.encodeWithSignature("voluntaryDisconnect()")); + _timelockWarp(); + reportVaultValueChangeNoFees(ctx, 0); // voluntaryDisconnect() requires fresh oracle report + _timelockExecute(address(ctx.dashboard), abi.encodeWithSignature("voluntaryDisconnect()")); + + // Verify disconnect is pending + assertTrue(vaultHub.isVaultConnected(address(ctx.vault)), "Vault should still be connected"); + assertTrue(vaultHub.isPendingDisconnect(address(ctx.vault)), "Vault should be pending disconnect"); + + // Apply oracle report to finalize disconnect + IVaultHub.VaultRecord memory vaultRecord = vaultHub.vaultRecord(address(ctx.vault)); + vm.prank(address(core.lazyOracle())); + vaultHub.applyVaultReport({ + _vault: address(ctx.vault), + _reportTimestamp: block.timestamp, + _reportTotalValue: vaultRecord.report.totalValue, + _reportInOutDelta: vaultRecord.report.inOutDelta, + _reportCumulativeLidoFees: vaultRecord.cumulativeLidoFees, + _reportLiabilityShares: vaultRecord.liabilityShares, + _reportMaxLiabilityShares: vaultRecord.maxLiabilityShares, + _reportSlashingReserve: 0 + }); + assertFalse(vaultHub.isVaultConnected(address(ctx.vault)), "Vault should be disconnected after report"); + + // Finalize disconnect by abandoning dashboard + assertEq(ctx.vault.owner(), address(core.vaultHub()), "VaultHub should be vault owner initially"); + vm.prank(address(ctx.timelock)); + ctx.dashboard.abandonDashboard(trustedActor); + + vm.prank(trustedActor); + ctx.vault.acceptOwnership(); + assertEq(ctx.vault.owner(), trustedActor, "Disconnect manager should be vault owner after transfer"); + + // Check that claims are possible after disconnect + uint256 balanceBefore = address(this).balance; + uint256 claimableEther = ctx.withdrawalQueue.getClaimableEther(requestId); + ctx.withdrawalQueue.claimWithdrawal(address(this), requestId); + uint256 balanceAfter = address(this).balance; + assertGt(claimableEther, 0, "Should have claimable ether"); + assertEq(balanceAfter - balanceBefore, claimableEther, "Claimed amount should match expected"); + + // Check the vault has non zero assets to withdraw + uint256 availableBalance = ctx.vault.availableBalance(); + assertGt(availableBalance, 0, "Vault should have available balance"); + assertEq(address(ctx.vault).balance, availableBalance, "Vault ETH balance should match available balance"); + + // Withdraw assets from the vault to distributor contract + address distributor = address(pool.DISTRIBUTOR()); + + // Distributor has no eth support, so ETH should be converted to WETH or wstETH before + IWstETH wsteth = core.wsteth(); + uint256 vaultWstethBalanceBefore = wsteth.balanceOf(address(ctx.vault)); + + vm.prank(trustedActor); + ctx.vault.withdraw(address(wsteth), availableBalance); + uint256 vaultWstethBalanceAfter = wsteth.balanceOf(address(ctx.vault)); + assertGt(vaultWstethBalanceAfter, vaultWstethBalanceBefore, "Vault should receive wstETH after withdrawal"); + + uint256 distributorWstethBalanceBefore = wsteth.balanceOf(distributor); + vm.prank(trustedActor); + ctx.vault.collectERC20(address(wsteth), distributor, vaultWstethBalanceAfter); + uint256 distributorWstethBalanceAfter = wsteth.balanceOf(distributor); + assertGt(distributorWstethBalanceAfter, distributorWstethBalanceBefore, "Distributor should receive wstETH"); + } + + // Fallback to receive ETH + receive() external payable {} +} diff --git a/test/integration/factory.test.sol b/test/integration/factory.test.sol new file mode 100644 index 0000000..4a098f1 --- /dev/null +++ b/test/integration/factory.test.sol @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {Vm} from "forge-std/Vm.sol"; +import {Factory} from "src/Factory.sol"; +import {StvPool} from "src/StvPool.sol"; +import {IDashboard} from "src/interfaces/core/IDashboard.sol"; +import {FactoryHelper} from "test/utils/FactoryHelper.sol"; +import {StvPoolHarness} from "test/utils/StvPoolHarness.sol"; + +contract FactoryIntegrationTest is StvPoolHarness { + Factory internal factory; + + function setUp() public { + _initializeCore(); + + FactoryHelper helper = new FactoryHelper(); + + factory = helper.deployMainFactory(address(core.locator()), address(0), address(0)); + } + + function _buildConfigs( + bool allowlistEnabled, + bool mintingEnabled, + uint256 reserveRatioGapBP, + string memory name, + string memory symbol + ) + internal + pure + returns ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig, + Factory.TimelockConfig memory timelockConfig + ) + { + vaultConfig = Factory.VaultConfig({ + nodeOperator: NODE_OPERATOR, + nodeOperatorManager: NODE_OPERATOR, + nodeOperatorFeeBP: 500, + confirmExpiry: CONFIRM_EXPIRY + }); + + commonPoolConfig = Factory.CommonPoolConfig({minWithdrawalDelayTime: 1 days, name: name, symbol: symbol}); + + auxiliaryConfig = Factory.AuxiliaryPoolConfig({ + allowlistEnabled: allowlistEnabled, mintingEnabled: mintingEnabled, reserveRatioGapBP: reserveRatioGapBP + }); + + timelockConfig = Factory.TimelockConfig({minDelaySeconds: 0, proposer: NODE_OPERATOR, executor: NODE_OPERATOR}); + } + + function _deployThroughFactory( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig, + Factory.TimelockConfig memory timelockConfig, + address strategyFactory + ) internal returns (Factory.PoolIntermediate memory, Factory.PoolDeployment memory) { + vm.startPrank(vaultConfig.nodeOperator); + Factory.PoolIntermediate memory intermediate = factory.createPoolStart{value: CONNECT_DEPOSIT}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + Factory.PoolDeployment memory deployment = factory.createPoolFinish(intermediate); + vm.stopPrank(); + + return (intermediate, deployment); + } + + function test_createPoolStart_reverts_without_exact_connect_deposit() public { + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig, + Factory.TimelockConfig memory timelockConfig + ) = _buildConfigs(false, false, 0, "Factory Test Pool", "FT-STV"); + address strategyFactory = address(0); + + assertGt(CONNECT_DEPOSIT, 1, "CONNECT_DEPOSIT must be > 1 for this test"); + + vm.startPrank(vaultConfig.nodeOperator); + vm.expectRevert( + abi.encodeWithSelector(Factory.InsufficientConnectDeposit.selector, CONNECT_DEPOSIT - 1, CONNECT_DEPOSIT) + ); + factory.createPoolStart{value: CONNECT_DEPOSIT - 1}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + vm.stopPrank(); + } + + function test_createPool_without_minting_configures_roles() public { + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig, + Factory.TimelockConfig memory timelockConfig + ) = _buildConfigs(false, false, 0, "Factory No Mint", "FNM"); + address strategyFactory = address(0); + + (, Factory.PoolDeployment memory deployment) = + _deployThroughFactory(vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory); + + assertEq(deployment.strategy, address(0), "strategy should not be deployed"); + + IDashboard dashboard = IDashboard(payable(deployment.dashboard)); + StvPool pool = StvPool(payable(deployment.pool)); + + assertTrue(dashboard.hasRole(dashboard.FUND_ROLE(), deployment.pool), "pool should have FUND_ROLE"); + assertTrue( + dashboard.hasRole(dashboard.WITHDRAW_ROLE(), deployment.withdrawalQueue), + "withdrawal queue should have WITHDRAW_ROLE" + ); + assertFalse(dashboard.hasRole(dashboard.MINT_ROLE(), deployment.pool), "mint role should not be granted"); + assertTrue(pool.hasRole(pool.DEFAULT_ADMIN_ROLE(), deployment.timelock), "timelock should own pool"); + } + + function test_createPool_with_minting_grants_mint_and_burn_roles() public { + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig, + Factory.TimelockConfig memory timelockConfig + ) = _buildConfigs(false, true, 0, "Factory Mint Pool", "FMP"); + address strategyFactory = address(0); + + (, Factory.PoolDeployment memory deployment) = + _deployThroughFactory(vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory); + + IDashboard dashboard = IDashboard(payable(deployment.dashboard)); + + assertTrue(dashboard.hasRole(dashboard.MINT_ROLE(), deployment.pool), "mint role should be granted"); + assertTrue(dashboard.hasRole(dashboard.BURN_ROLE(), deployment.pool), "burn role should be granted"); + } + + function test_createPool_with_strategy_deploys_strategy_and_allowlists_it() public { + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig, + Factory.TimelockConfig memory timelockConfig + ) = _buildConfigs(true, true, 500, "Factory Strategy Pool", "FSP"); + address strategyFactory = address(factory.GGV_STRATEGY_FACTORY()); + + (, Factory.PoolDeployment memory deployment) = + _deployThroughFactory(vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory); + + assertTrue(deployment.strategy != address(0), "strategy should be deployed"); + + StvPool pool = StvPool(payable(deployment.pool)); + assertTrue(pool.ALLOW_LIST_ENABLED(), "allowlist should be enabled"); + assertTrue(pool.isAllowListed(deployment.strategy), "strategy should be allowlisted"); + } + + function test_createPoolFinish_reverts_with_modified_intermediate() public { + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig, + Factory.TimelockConfig memory timelockConfig + ) = _buildConfigs(false, false, 0, "Factory Tamper", "FTAMP"); + + address strategyFactory = address(0); + + vm.startPrank(vaultConfig.nodeOperator); + Factory.PoolIntermediate memory intermediate = factory.createPoolStart{value: CONNECT_DEPOSIT}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + + // Tamper with the intermediate before finishing to ensure the deployment hash is checked. + intermediate.pool = address(0xdead); + + vm.expectRevert(abi.encodeWithSelector(Factory.InvalidConfiguration.selector, "deploy not started")); + factory.createPoolFinish(intermediate); + vm.stopPrank(); + } + + function test_createPoolFinish_reverts_with_different_sender() public { + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig, + Factory.TimelockConfig memory timelockConfig + ) = _buildConfigs(false, false, 0, "Factory Wrong Sender", "FWS"); + + address strategyFactory = address(0); + + vm.startPrank(vaultConfig.nodeOperator); + Factory.PoolIntermediate memory intermediate = factory.createPoolStart{value: CONNECT_DEPOSIT}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + vm.stopPrank(); + + address otherSender = address(0xbeef); + vm.expectRevert(abi.encodeWithSelector(Factory.InvalidConfiguration.selector, "deploy not started")); + vm.prank(otherSender); + factory.createPoolFinish(intermediate); + } + + function test_createPoolFinish_reverts_when_called_twice() public { + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig, + Factory.TimelockConfig memory timelockConfig + ) = _buildConfigs(false, false, 0, "Factory Double Finish", "FDF"); + + address strategyFactory = address(0); + + vm.startPrank(vaultConfig.nodeOperator); + Factory.PoolIntermediate memory intermediate = factory.createPoolStart{value: CONNECT_DEPOSIT}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory, "" + ); + + factory.createPoolFinish(intermediate); + + // The intermediate hash is set to DEPLOY_COMPLETE after the first successful call; the second should fail. + vm.expectRevert(abi.encodeWithSelector(Factory.InvalidConfiguration.selector, "deploy already finished")); + factory.createPoolFinish(intermediate); + vm.stopPrank(); + } + + function test_emits_pool_intermediate_created_event() public { + ( + Factory.VaultConfig memory vaultConfig, + Factory.CommonPoolConfig memory commonPoolConfig, + Factory.AuxiliaryPoolConfig memory auxiliaryConfig, + Factory.TimelockConfig memory timelockConfig + ) = _buildConfigs(false, false, 0, "Factory Event Pool", "FEP"); + address strategyFactory = address(0); + + vm.recordLogs(); + (Factory.PoolIntermediate memory intermediate,) = + _deployThroughFactory(vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactory); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 expectedTopic = keccak256("PoolCreationStarted((address,address,address,bytes),uint256)"); + + bool found; + for (uint256 i = 0; i < entries.length; i++) { + if (entries[i].emitter != address(factory)) continue; + if (entries[i].topics.length == 0 || entries[i].topics[0] != expectedTopic) continue; + + Factory.PoolIntermediate memory emitted = abi.decode(entries[i].data, (Factory.PoolIntermediate)); + assertEq(emitted.pool, intermediate.pool, "pool address should match"); + assertEq(emitted.timelock, intermediate.timelock, "timelock should match"); + assertEq(emitted.strategyFactory, intermediate.strategyFactory, "strategy factory should match"); + assertEq( + emitted.strategyDeployBytes, intermediate.strategyDeployBytes, "strategy deploy bytes should match" + ); + found = true; + break; + } + + assertTrue(found, "PoolIntermediateCreated event should be emitted"); + } +} diff --git a/test/integration/ggv.test.sol b/test/integration/ggv.test.sol index a94b7ae..85e0664 100644 --- a/test/integration/ggv.test.sol +++ b/test/integration/ggv.test.sol @@ -1,26 +1,30 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {console} from "forge-std/Test.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {ITellerWithMultiAssetSupport} from "src/interfaces/ggv/ITellerWithMultiAssetSupport.sol"; import {IBoringOnChainQueue} from "src/interfaces/ggv/IBoringOnChainQueue.sol"; import {IBoringSolver} from "src/interfaces/ggv/IBoringSolver.sol"; +import {ITellerWithMultiAssetSupport} from "src/interfaces/ggv/ITellerWithMultiAssetSupport.sol"; import {StvStrategyPoolHarness} from "test/utils/StvStrategyPoolHarness.sol"; -import {GGVStrategy} from "src/strategy/GGVStrategy.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; import {IStrategy} from "src/interfaces/IStrategy.sol"; -import {StvStETHPool} from "src/StvStETHPool.sol"; +import {GGVStrategy} from "src/strategy/GGVStrategy.sol"; +import {IStrategyCallForwarder} from "src/interfaces/IStrategyCallForwarder.sol"; import {TableUtils} from "../utils/format/TableUtils.sol"; -import {GGVVaultMock} from "src/mock/ggv/GGVVaultMock.sol"; +import {AllowList} from "src/AllowList.sol"; import {GGVMockTeller} from "src/mock/ggv/GGVMockTeller.sol"; import {GGVQueueMock} from "src/mock/ggv/GGVQueueMock.sol"; -import {AllowList} from "src/AllowList.sol"; +import {GGVVaultMock} from "src/mock/ggv/GGVVaultMock.sol"; + +import {console} from "forge-std/console.sol"; interface IAuthority { function setUserRole(address user, uint8 role, bool enabled) external; @@ -91,19 +95,13 @@ contract GGVTest is StvStrategyPoolHarness { strategy = IStrategy(ctx.strategy); ggvStrategy = GGVStrategy(address(strategy)); - user1StrategyCallForwarder = ggvStrategy.getStrategyCallForwarderAddress(USER1); + user1StrategyCallForwarder = address(ggvStrategy.getStrategyCallForwarderAddress(USER1)); vm.label(user1StrategyCallForwarder, "User1StrategyCallForwarder"); - user2StrategyCallForwarder = ggvStrategy.getStrategyCallForwarderAddress(USER2); + user2StrategyCallForwarder = address(ggvStrategy.getStrategyCallForwarderAddress(USER2)); vm.label(user2StrategyCallForwarder, "User2StrategyCallForwarder"); - _log.init( - address(pool), - address(boringVault), - address(steth), - address(wsteth), - address(boringOnChainQueue) - ); + _log.init(address(pool), address(boringVault), address(steth), address(wsteth), address(boringOnChainQueue)); vm.startPrank(ADMIN); steth.submit{value: 10 ether}(ADMIN); @@ -111,7 +109,7 @@ contract GGVTest is StvStrategyPoolHarness { vm.stopPrank(); vm.startPrank(SOLVER); - uint256 solverSteth = steth.submit{value: 1 ether}(SOLVER); + uint256 solverSteth = steth.submit{value: 2 ether}(SOLVER); steth.approve(address(wsteth), type(uint256).max); uint256 solverWsteth = wsteth.wrap(solverSteth); wsteth.transfer(address(boringVault), solverWsteth); @@ -145,6 +143,13 @@ contract GGVTest is StvStrategyPoolHarness { console.log("setup GGV finished\n"); } + function test_revert_if_user_is_not_allowlisted() public { + uint256 depositAmount = 1 ether; + vm.prank(USER1); + vm.expectRevert(abi.encodeWithSelector(AllowList.NotAllowListed.selector, USER1)); + pool.depositETH{value: depositAmount}(USER1, address(0)); + } + function test_rebase_scenario() public { uint256 stethIncrease = 0; uint256 vaultIncrease = 0; @@ -154,7 +159,7 @@ contract GGVTest is StvStrategyPoolHarness { uint256 vaultProfit = depositAmount * vaultIncrease / 100; // 0.05 ether profit logUsers.push(TableUtils.User(USER1, "user1")); - logUsers.push(TableUtils.User(user1StrategyCallForwarder, "user1_call_forwarder")); + logUsers.push(TableUtils.User(user1StrategyCallForwarder, "user1_forwarder")); logUsers.push(TableUtils.User(address(pool), "pool")); logUsers.push(TableUtils.User(address(pool.WITHDRAWAL_QUEUE()), "wq")); logUsers.push(TableUtils.User(address(boringVault), "boringVault")); @@ -164,45 +169,31 @@ contract GGVTest is StvStrategyPoolHarness { core.increaseBufferedEther(steth.totalSupply() * stethIncrease / 100); console.log("INITIAL share rate %s", steth.getPooledEthByShares(1e18)); - // _log.printUsers("[SCENARIO] Initial State", logUsers, ggvDiscount); - - // Check that user is not allowed to deposit directly - vm.prank(USER1); - vm.expectRevert(abi.encodeWithSelector(AllowList.NotAllowListed.selector, USER1)); - pool.depositETH{value: depositAmount}(USER1, address(0)); + _log.printUsers("[SCENARIO] Initial State", logUsers, ggvDiscount); // 1. Initial Deposit - vm.prank(USER1); - ggvStrategy.supply{value: depositAmount}(address(0), abi.encode(GGVStrategy.GGVParams(0, 0, 0))); - // _log.printUsers("[SCENARIO] After Deposit (1 ETH)", logUsers, ggvDiscount); + uint256 wstethToMint = pool.remainingMintingCapacitySharesOf(USER1, depositAmount); - // 2. Simulate Rebases - console.log("\n[SCENARIO] Simulating Rebases (Vault +5%, stETH +4%)"); - - // a) Vault Rebase (simulated via mock report) - // uint256 currentLiabilityShares = pool.DASHBOARD().liabilityShares(); - // uint256 currentTotalAssets = pool.totalAssets(); + vm.prank(USER1); + ggvStrategy.supply{value: depositAmount}(address(0), wstethToMint, abi.encode(GGVStrategy.GGVParamsSupply(0))); - // core.applyVaultReport(address(ctx.vault), currentTotalAssets + vaultProfit, 0, currentLiabilityShares, 0, false); + _log.printUsers("[SCENARIO] After Deposit (1 ETH)", logUsers, ggvDiscount); - // _log.printUsers("[SCENARIO] After report (increase vault balance)", logUsers, ggvDiscount); + uint256 userMintedStethSharesAfterDeposit = ggvStrategy.mintedStethSharesOf(USER1); -// 3. Request withdrawal (full amount, based on appreciated value) + // 3. Request withdrawal (full amount, based on appreciated value) uint256 totalGgvShares = boringVault.balanceOf(user1StrategyCallForwarder); - uint256 withdrawalStethAmount = - boringOnChainQueue.previewAssetsOut(address(steth), uint128(totalGgvShares), uint16(ggvDiscount)); + uint256 withdrawalWstethAmount = + boringOnChainQueue.previewAssetsOut(address(wsteth), uint128(totalGgvShares), uint16(ggvDiscount)); - console.log("\n[SCENARIO] Requesting withdrawal based on new appreciated assets:", withdrawalStethAmount); + console.log("\n[SCENARIO] Requesting withdrawal based on new appreciated assets:", withdrawalWstethAmount); - GGVStrategy.GGVParams memory params = GGVStrategy.GGVParams({ - discount: uint16(ggvDiscount), - minimumMint: 0, - secondsToDeadline: type(uint24).max - }); + GGVStrategy.GGVParamsRequestExit memory params = + GGVStrategy.GGVParamsRequestExit({discount: uint16(ggvDiscount), secondsToDeadline: type(uint24).max}); vm.prank(USER1); - bytes32 requestId = ggvStrategy.requestExitByStETH(withdrawalStethAmount, abi.encode(params)); + bytes32 requestId = ggvStrategy.requestExitByWsteth(withdrawalWstethAmount, abi.encode(params)); assertNotEq(requestId, 0); // Apply 1% increase to core (stETH share ratio) @@ -229,12 +220,30 @@ contract GGVTest is StvStrategyPoolHarness { // 5. User Finalizes Withdrawal (Wrapper side) console.log("\n[SCENARIO] Step 5. Finalize Wrapper withdrawal"); - uint256 _stethSharesToBurn = ggvStrategy.proxyStethSharesOf(USER1); - uint256 _stethSharesToRebalance = ggvStrategy.proxyStethSharesToRebalance(USER1); - uint256 _stvToWithdraw = ggvStrategy.proxyUnlockedStvOf(USER1, _stethSharesToRebalance + _stethSharesToBurn); + // simulate the unwrapping of wstETH to stETH with rounding issue + uint256 wstethUserBalance = ggvStrategy.wstethOf(USER1); + assertGt( + userMintedStethSharesAfterDeposit, + wstethUserBalance, + "user minted steth shares should be greater than wsteth balance" + ); + + uint256 mintedStethShares = ggvStrategy.mintedStethSharesOf(USER1); + uint256 wstethToBurn = Math.min(mintedStethShares, wstethUserBalance); + + uint256 stETHAmount = steth.getPooledEthByShares(wstethToBurn); + uint256 sharesAfterUnwrapping = steth.getSharesByPooledEth(stETHAmount); + + uint256 stethSharesToRebalance = 0; + if (mintedStethShares > sharesAfterUnwrapping) { + stethSharesToRebalance = mintedStethShares - sharesAfterUnwrapping; + } + + uint256 stvToWithdraw = ggvStrategy.stvOf(USER1); vm.startPrank(USER1); - ggvStrategy.requestWithdrawal(_stvToWithdraw, _stethSharesToBurn, _stethSharesToRebalance, USER1); + ggvStrategy.burnWsteth(wstethToBurn); + ggvStrategy.requestWithdrawalFromPool(USER1, stvToWithdraw, stethSharesToRebalance); vm.stopPrank(); _log.printUsers("After User Finalizes Wrapper", logUsers, ggvDiscount); @@ -262,34 +271,93 @@ contract GGVTest is StvStrategyPoolHarness { console.log("ETH Claimed:", ethClaimed); _log.printUsers("After User Claims ETH", logUsers, ggvDiscount); + } + + function test_positive_wsteth_rebase_flow() public { + uint256 depositAmount = 1 ether; + uint16 discount = 0; + + uint256 wstethToMint = pool.remainingMintingCapacitySharesOf(USER1, depositAmount); -// // 8. Recover Surplus stETH (если есть) -// uint256 surplusStETH = steth.balanceOf(user1StrategyCallForwarder); -// if (surplusStETH > 0) { -// uint256 stethBalance = steth.sharesOf(user1StrategyCallForwarder); -// uint256 stethDebt = pool.mintedStethSharesOf(user1StrategyCallForwarder); -// uint256 surplusInShares = stethBalance > stethDebt ? stethBalance - stethDebt : 0; -// uint256 maxAmount = steth.getPooledEthByShares(surplusInShares); + vm.prank(USER1); + ggvStrategy.supply{value: depositAmount}(address(0), wstethToMint, abi.encode(GGVStrategy.GGVParamsSupply(0))); + + uint256 mintedSharesBefore = ggvStrategy.mintedStethSharesOf(USER1); + assertEq(mintedSharesBefore, wstethToMint, "minted shares mismatch"); + + IStrategyCallForwarder callForwarder = ggvStrategy.getStrategyCallForwarderAddress(USER1); + uint256 totalGGVShares = boringVault.balanceOf(address(callForwarder)); + + // Simulate GGV rewards + uint256 rebaseStethAmount = 0.1 ether; + vm.startPrank(ADMIN); + steth.approve(address(wsteth), type(uint256).max); + uint256 rebaseWstethAmount = wsteth.wrap(rebaseStethAmount); + wsteth.approve(address(boringVault), type(uint256).max); + boringVault.rebaseWsteth(rebaseWstethAmount); + vm.stopPrank(); + + uint128 withdrawSharesPreview = + boringOnChainQueue.previewAssetsOut(address(wsteth), uint128(totalGGVShares), discount); + + GGVStrategy.GGVParamsRequestExit memory params = + GGVStrategy.GGVParamsRequestExit({discount: discount, secondsToDeadline: type(uint24).max}); -// console.log("\n[SCENARIO] Step 8. Recover Surplus stETH:", maxAmount); -// vm.prank(USER1); -// ggvStrategy.recoverERC20(address(steth), USER1, maxAmount); -// } + vm.prank(USER1); + bytes32 requestId = ggvStrategy.requestExitByWsteth(uint256(withdrawSharesPreview), abi.encode(params)); -// _log.printUsers("After Recovery", logUsers); + IBoringOnChainQueue.OnChainWithdraw memory request = + GGVQueueMock(address(boringOnChainQueue)).mockGetRequestById(requestId); + IBoringOnChainQueue.OnChainWithdraw[] memory requests = new IBoringOnChainQueue.OnChainWithdraw[](1); + requests[0] = request; + + vm.prank(SOLVER); + boringOnChainQueue.solveOnChainWithdraws(requests, new bytes(0), address(0)); + + uint256 wstethAfterSolve = ggvStrategy.wstethOf(USER1); + assertGt(wstethAfterSolve, mintedSharesBefore, "wstETH returned should exceed supplied amount"); + + uint256 stvBalance = ggvStrategy.stvOf(USER1); + + vm.startPrank(USER1); + ggvStrategy.burnWsteth(mintedSharesBefore); + + uint256 remainingLiability = ggvStrategy.mintedStethSharesOf(USER1); + uint256 leftoverWsteth = ggvStrategy.wstethOf(USER1); + ggvStrategy.requestWithdrawalFromPool(USER1, stvBalance, remainingLiability); + vm.stopPrank(); + + assertGt(leftoverWsteth, 0, "surplus wstETH expected after covering liability"); + + _finalizeWQ(1, 0); + + uint256[] memory wqRequestIds = withdrawalQueue.withdrawalRequestsOf(USER1); + uint256 userEthBefore = USER1.balance; + + vm.prank(USER1); + withdrawalQueue.claimWithdrawal(USER1, wqRequestIds[0]); + + assertGt(USER1.balance - userEthBefore, 0, "user should receive ETH on claim"); + + uint256 recoverableWsteth = ggvStrategy.wstethOf(USER1); + assertEq(recoverableWsteth, leftoverWsteth, "unexpected wstETH balance on strategy"); + + uint256 userWstethBefore = wsteth.balanceOf(USER1); + + vm.prank(USER1); + ggvStrategy.recoverERC20(address(wsteth), USER1, recoverableWsteth); + + assertEq(ggvStrategy.wstethOf(USER1), 0, "strategy call forwarder should have no wstETH left"); + assertEq( + wsteth.balanceOf(USER1) - userWstethBefore, recoverableWsteth, "user must receive recovered wstETH amount" + ); } function _finalizeWQ(uint256 _maxRequest, uint256 vaultProfit) public { - vm.deal(address(pool.STAKING_VAULT()), 1 ether); + vm.deal(address(pool.VAULT()), 1 ether); vm.warp(block.timestamp + 1 days); - core.applyVaultReport( - address(pool.STAKING_VAULT()), - pool.totalAssets(), - 0, - pool.DASHBOARD().liabilityShares(), - 0 - ); + core.applyVaultReport(address(pool.VAULT()), pool.totalAssets(), 0, pool.DASHBOARD().liabilityShares(), 0); if (vaultProfit != 0) { vm.startPrank(NODE_OPERATOR); @@ -298,7 +366,7 @@ contract GGVTest is StvStrategyPoolHarness { } vm.startPrank(NODE_OPERATOR); - uint256 finalizedRequests = pool.WITHDRAWAL_QUEUE().finalize(_maxRequest); + uint256 finalizedRequests = pool.WITHDRAWAL_QUEUE().finalize(_maxRequest, address(0)); vm.stopPrank(); assertEq(finalizedRequests, _maxRequest, "Invalid finalized requests"); diff --git a/test/integration/report-freshness.test.sol b/test/integration/report-freshness.test.sol new file mode 100644 index 0000000..c648a67 --- /dev/null +++ b/test/integration/report-freshness.test.sol @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {StvPool} from "src/StvPool.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; +import {IVaultHub} from "src/interfaces/core/IVaultHub.sol"; +import {StvStETHPoolHarness} from "test/utils/StvStETHPoolHarness.sol"; + +/** + * @title Report Freshness Integration Tests + * @notice Integration tests for StvPool (no minting, no strategy) + */ +contract ReportFreshnessTest is StvStETHPoolHarness { + WrapperContext ctx; + StvStETHPool pool; + + address requestFinalizer = NODE_OPERATOR; + address lossSocializer; + + function setUp() public { + _initializeCore(); + ctx = _deployStvStETHPool(false, 0, 25); + pool = stvStETHPool(ctx); + + vm.deal(address(this), 10 ether); + + // Grant LOSS_SOCIALIZER_ROLE to a dedicated address + lossSocializer = makeAddr("lossSocializer"); + bytes32 lossSocializerRole = pool.LOSS_SOCIALIZER_ROLE(); + vm.prank(address(ctx.timelock)); + pool.grantRole(lossSocializerRole, lossSocializer); + + // Enable loss socialization + vm.prank(address(ctx.timelock)); + pool.setMaxLossSocializationBP(100_00); // 100% + } + + function _waitForStaleOracleReport() internal { + vm.warp(block.timestamp + 5 days); + assertFalse(core.vaultHub().isReportFresh(address(ctx.vault))); + } + + function test_deposit_requires_fresh_report() public { + // Warp time to ensure oracle report stale + _waitForStaleOracleReport(); + + // Try to deposit. Should revert due to stale oracle report + vm.expectRevert(StvPool.VaultReportStale.selector); + pool.depositETH{value: 1 ether}(address(this), address(0)); + + // Deliver oracle report + reportVaultValueChangeNoFees(ctx, 100_00); + + // Deposit again. Should pass + pool.depositETH{value: 1 ether}(address(this), address(0)); + uint256 stv = pool.balanceOf(address(this)); + assertGt(stv, 0); + } + + function test_withdrawals_requires_fresh_report() public { + // Deposit first + pool.depositETH{value: 1 ether}(address(this), address(0)); + uint256 stv = pool.balanceOf(address(this)); + assertGt(stv, 0); + + // Warp time to ensure oracle report stale + _waitForStaleOracleReport(); + + // Try to withdraw. Should revert due to stale oracle report + vm.expectRevert(StvPool.VaultReportStale.selector); + ctx.withdrawalQueue.requestWithdrawal(address(this), stv, 0); + + // Deliver oracle report + reportVaultValueChangeNoFees(ctx, 100_00); + + // Withdraw again. Should pass + ctx.withdrawalQueue.requestWithdrawal(address(this), stv, 0); + assertEq(ctx.withdrawalQueue.unfinalizedRequestsNumber(), 1); + } + + function test_withdrawals_finalization_requires_fresh_report() public { + // Deposit first + pool.depositETH{value: 1 ether}(address(this), address(0)); + uint256 stv = pool.balanceOf(address(this)); + assertGt(stv, 0); + + // Request withdrawal + ctx.withdrawalQueue.requestWithdrawal(address(this), stv, 0); + assertEq(ctx.withdrawalQueue.unfinalizedRequestsNumber(), 1); + + // Warp time to ensure oracle report stale + _waitForStaleOracleReport(); + + // Try to finalize withdrawal. Should revert due to stale oracle report + vm.prank(requestFinalizer); + vm.expectRevert(WithdrawalQueue.VaultReportStale.selector); + ctx.withdrawalQueue.finalize(1, address(this)); + + // Deliver oracle report + reportVaultValueChangeNoFees(ctx, 100_00); + + // Finalize withdrawal again. Should pass + vm.prank(requestFinalizer); + ctx.withdrawalQueue.finalize(1, address(this)); + assertEq(ctx.withdrawalQueue.unfinalizedRequestsNumber(), 0); + } + + function test_minting_requires_fresh_report() public { + // Deposit first + pool.depositETH{value: 1 ether}(address(this), address(0)); + uint256 stv = pool.balanceOf(address(this)); + assertGt(stv, 0); + + // Warp time to ensure oracle report stale + _waitForStaleOracleReport(); + + // Calc steth shares to mint + uint256 stethSharesToMint = pool.remainingMintingCapacitySharesOf(address(this), 0) / 2; + assertGt(stethSharesToMint, 0); + + // Try to mint stETH. Should revert due to stale oracle report + vm.expectRevert(abi.encodeWithSelector(IVaultHub.VaultReportStale.selector, address(ctx.vault))); + pool.mintStethShares(stethSharesToMint); + + // Try to mint wstETH. Should revert due to stale oracle report + vm.expectRevert(abi.encodeWithSelector(IVaultHub.VaultReportStale.selector, address(ctx.vault))); + pool.mintWsteth(stethSharesToMint); + + // Deliver oracle report + reportVaultValueChangeNoFees(ctx, 100_00); + + // Mint stETH again. Should pass + pool.mintStethShares(stethSharesToMint); + assertEq(pool.mintedStethSharesOf(address(this)), stethSharesToMint); + + // Mint wstETH again. Should pass + pool.mintWsteth(stethSharesToMint); + assertEq(pool.mintedStethSharesOf(address(this)), stethSharesToMint * 2); + } + + function test_force_rebalance_requires_fresh_report() public { + // Deposit first + pool.depositETH{value: 1 ether}(address(this), address(0)); + uint256 stv = pool.balanceOf(address(this)); + assertGt(stv, 0); + + // Calc max steth shares to mint + uint256 maxStethSharesToMint = pool.remainingMintingCapacitySharesOf(address(this), 0); + assertGt(maxStethSharesToMint, 0); + + // Mint steth shares + pool.mintStethShares(maxStethSharesToMint); + + // Deliver oracle report with losses + reportVaultValueChangeNoFees(ctx, 80_00); // 20% loss + + // Check user is unhealthy + assertFalse(pool.isHealthyOf(address(this))); + + // Check rebalance preview shows need to rebalance + (uint256 stethSharesToRebalance, uint256 stvToRebalance, bool isUndercollateralized) = + pool.previewForceRebalance(address(this)); + + assertGt(stethSharesToRebalance, 0); + assertGt(stvToRebalance, 0); + + // Should be collateralized (assets > liability) for permisionless rebalance + assertFalse(isUndercollateralized); + + // Warp time to ensure oracle report stale + _waitForStaleOracleReport(); + + // Try to rebalance. Should revert due to stale oracle report + vm.expectRevert(StvPool.VaultReportStale.selector); + pool.forceRebalance(address(this)); + + // Deliver fresh oracle report + reportVaultValueChangeNoFees(ctx, 100_00); + + // Rebalance again. Should pass + uint256 stvBurned = pool.forceRebalance(address(this)); + assertGt(stvBurned, 0); + } + + function test_force_rebalance_with_socialization_requires_fresh_report() public { + // Deposit first + pool.depositETH{value: 1 ether}(address(this), address(0)); + uint256 stv = pool.balanceOf(address(this)); + assertGt(stv, 0); + + // Deposit to another user to have socialization effect + vm.prank(USER1); + pool.depositETH{value: 10 ether}(USER1, address(0)); + + // Calc max steth shares to mint + uint256 maxStethSharesToMint = pool.remainingMintingCapacitySharesOf(address(this), 0); + assertGt(maxStethSharesToMint, 0); + + // Mint steth shares + pool.mintStethShares(maxStethSharesToMint); + + // Deliver oracle report with huge losses + reportVaultValueChangeNoFees(ctx, 40_00); // 60% loss + + // Check user is unhealthy + assertFalse(pool.isHealthyOf(address(this))); + + // Check rebalance preview shows undercollateralization + (uint256 stethSharesToRebalance, uint256 stvToRebalance, bool isUndercollateralized) = + pool.previewForceRebalance(address(this)); + + assertGt(stethSharesToRebalance, 0); + assertGt(stvToRebalance, 0); + + // Should be uncollateralized (assets < liability) + assertTrue(isUndercollateralized); + + // Warp time to ensure oracle report stale + _waitForStaleOracleReport(); + + // Try to rebalance. Should revert due to stale oracle report + vm.prank(lossSocializer); + vm.expectRevert(StvPool.VaultReportStale.selector); + pool.forceRebalanceAndSocializeLoss(address(this)); + + // Deliver fresh oracle report + reportVaultValueChangeNoFees(ctx, 100_00); + + // Check other user balance before socialization + uint256 user1AssetsBefore = pool.assetsOf(USER1); + + // Rebalance again. Should pass + vm.prank(lossSocializer); + uint256 stvBurned = pool.forceRebalanceAndSocializeLoss(address(this)); + assertGt(stvBurned, 0); + + // Check other user balance decreased due to socialization + uint256 user1AssetsAfter = pool.assetsOf(USER1); + assertLt(user1AssetsAfter, user1AssetsBefore); + } +} diff --git a/test/integration/stv-pool.test.sol b/test/integration/stv-pool.test.sol index 9cbecec..3ad58ae 100644 --- a/test/integration/stv-pool.test.sol +++ b/test/integration/stv-pool.test.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {StvPoolHarness} from "test/utils/StvPoolHarness.sol"; @@ -16,13 +16,15 @@ contract StvPoolTest is StvPoolHarness { // Deploy pool system WrapperContext memory ctx = _deployStvPool(false, 0); - // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_AMOUNT) + // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_VALUE) uint256 depositAmount = 0.01 ether; uint256 expectedStv = ctx.pool.previewDeposit(depositAmount); vm.prank(USER1); ctx.pool.depositETH{value: depositAmount}(USER1, address(0)); assertEq(ctx.pool.balanceOf(USER1), expectedStv, "minted shares should match previewDeposit"); - assertEq(address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount"); + assertEq( + address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount" + ); // 2) USER1 immediately requests withdrawal of all their shares vm.prank(USER1); @@ -36,7 +38,7 @@ contract StvPoolTest is StvPoolHarness { // 4) Node Operator finalizes one request vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); // 5) USER1 claims uint256 userBalanceBefore = USER1.balance; @@ -50,13 +52,15 @@ contract StvPoolTest is StvPoolHarness { // Deploy pool system WrapperContext memory ctx = _deployStvPool(false, 0); - // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_AMOUNT) + // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_VALUE) uint256 depositAmount = 0.01 ether; uint256 expectedStv = ctx.pool.previewDeposit(depositAmount); vm.prank(USER1); ctx.pool.depositETH{value: depositAmount}(USER1, address(0)); assertEq(ctx.pool.balanceOf(USER1), expectedStv, "minted shares should match previewDeposit"); - assertEq(address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount"); + assertEq( + address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount" + ); // 2) USER1 immediately requests withdrawal of all their shares vm.prank(USER1); @@ -74,7 +78,7 @@ contract StvPoolTest is StvPoolHarness { // 6) Node Operator finalizes one request vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); // 7) USER1 claims uint256 userBalanceBefore = USER1.balance; @@ -89,13 +93,15 @@ contract StvPoolTest is StvPoolHarness { // Deploy pool system WrapperContext memory ctx = _deployStvPool(false, 0); - // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_AMOUNT) + // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_VALUE) uint256 depositAmount = 0.01 ether; uint256 expectedStv = ctx.pool.previewDeposit(depositAmount); vm.prank(USER1); ctx.pool.depositETH{value: depositAmount}(USER1, address(0)); assertEq(ctx.pool.balanceOf(USER1), expectedStv, "minted shares should match previewDeposit"); - assertEq(address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount"); + assertEq( + address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount" + ); // 2) Apply +3% rewards via vault report BEFORE withdrawal request reportVaultValueChangeNoFees(ctx, 10300); // +3% @@ -103,7 +109,9 @@ contract StvPoolTest is StvPoolHarness { // 3) Now request withdrawal of all USER1 shares // Expected ETH is increased by ~3% compared to initial deposit uint256 expectedEth = ctx.pool.previewRedeem(expectedStv); - assertApproxEqAbs(expectedEth, (depositAmount * 103) / 100, WEI_ROUNDING_TOLERANCE, "expected eth should be ~+3% of deposit"); + assertApproxEqAbs( + expectedEth, (depositAmount * 103) / 100, WEI_ROUNDING_TOLERANCE, "expected eth should be ~+3% of deposit" + ); vm.prank(USER1); uint256 requestId = ctx.withdrawalQueue.requestWithdrawal(USER1, expectedStv, 0); @@ -112,7 +120,7 @@ contract StvPoolTest is StvPoolHarness { // 6) Node Operator finalizes one request vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); // 7) USER1 claims and receives the increased amount uint256 userBalanceBefore = USER1.balance; @@ -131,13 +139,15 @@ contract StvPoolTest is StvPoolHarness { // Deploy pool system WrapperContext memory ctx = _deployStvPool(false, 0); - // 1) USER1 deposits 0.0001 ether (above MIN_WITHDRAWAL_AMOUNT) - uint256 depositAmount = 0.0001 ether; + // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_VALUE) + uint256 depositAmount = 0.01 ether; uint256 expectedStv = ctx.pool.previewDeposit(depositAmount); vm.prank(USER1); ctx.pool.depositETH{value: depositAmount}(USER1, address(0)); assertEq(ctx.pool.balanceOf(USER1), expectedStv, "minted shares should match previewDeposit"); - assertEq(address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount"); + assertEq( + address(ctx.vault).balance, depositAmount + CONNECT_DEPOSIT, "vault balance should match deposit amount" + ); // 2) USER1 immediately requests withdrawal of all their shares vm.prank(USER1); @@ -151,12 +161,16 @@ contract StvPoolTest is StvPoolHarness { reportVaultValueChangeNoFees(ctx, 9900); // -1% // After the loss report, totalValue should be less than CONNECT_DEPOSIT - assertLt(ctx.dashboard.totalValue(), CONNECT_DEPOSIT, "totalValue should be less than CONNECT_DEPOSIT after loss report"); + assertLt( + ctx.dashboard.totalValue(), + CONNECT_DEPOSIT, + "totalValue should be less than CONNECT_DEPOSIT after loss report" + ); // Finalization should revert due to insufficient ETH to cover the request vm.prank(NODE_OPERATOR); vm.expectRevert(); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); } function test_withdrawal_request_finalized_after_reward_and_loss_reports() public { @@ -166,7 +180,7 @@ contract StvPoolTest is StvPoolHarness { // Simulate a +3% vault value report before deposit reportVaultValueChangeNoFees(ctx, 10300); // +3% - // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_AMOUNT) + // 1) USER1 deposits 0.01 ether (above MIN_WITHDRAWAL_VALUE) uint256 depositAmount = 0.01 ether; uint256 expectedStv = ctx.pool.previewDeposit(depositAmount); vm.prank(USER1); @@ -184,7 +198,7 @@ contract StvPoolTest is StvPoolHarness { // 6) Node Operator finalizes one request vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); // 7) USER1 claims and receives the decreased amount uint256 userBalanceBefore = USER1.balance; @@ -229,7 +243,7 @@ contract StvPoolTest is StvPoolHarness { // 4) Finalize both requests vm.prank(NODE_OPERATOR); - uint256 finalized = ctx.withdrawalQueue.finalize(2); + uint256 finalized = ctx.withdrawalQueue.finalize(2, address(0)); assertEq(finalized, 2, "should finalize both partial requests"); // 5) Claim both and verify total equals sum of previews; user ends with zero shares @@ -277,7 +291,7 @@ contract StvPoolTest is StvPoolHarness { _withdrawFromCL(ctx, firstAssets); vm.prank(NODE_OPERATOR); - uint256 finalized = ctx.withdrawalQueue.finalize(2); + uint256 finalized = ctx.withdrawalQueue.finalize(2, address(0)); assertEq(finalized, 1, "should finalize only the first request due to insufficient withdrawable"); // 5) Claim first, second remains unfinalized @@ -290,7 +304,7 @@ contract StvPoolTest is StvPoolHarness { _withdrawFromCL(ctx, secondAssets); vm.prank(NODE_OPERATOR); - finalized = ctx.withdrawalQueue.finalize(1); + finalized = ctx.withdrawalQueue.finalize(1, address(0)); assertEq(finalized, 1, "second request should now finalize after funding"); // 7) Claim second @@ -348,7 +362,7 @@ contract StvPoolTest is StvPoolHarness { // Finalize vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); // Claim succeeds uint256 before = USER1.balance; diff --git a/test/integration/stv-steth-pool.test.sol b/test/integration/stv-steth-pool.test.sol index e15eefd..5d57b8e 100644 --- a/test/integration/stv-steth-pool.test.sol +++ b/test/integration/stv-steth-pool.test.sol @@ -1,12 +1,12 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {StvStETHPoolHarness} from "test/utils/StvStETHPoolHarness.sol"; -import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; -import {IStETH} from "src/interfaces/IStETH.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; +import {IStETH} from "src/interfaces/core/IStETH.sol"; +import {StvStETHPoolHarness} from "test/utils/StvStETHPoolHarness.sol"; /** * @title StvStETHPoolTest @@ -47,12 +47,14 @@ contract StvStETHPoolTest is StvStETHPoolHarness { "stETH shares for withdrawal should be equal to 0" ); assertEq(ctx.dashboard.liabilityShares(), 0, "Vault's liability shares should be equal to 0"); - assertEq(ctx.dashboard.totalValue(), CONNECT_DEPOSIT + user1Deposit, "Vault's total value should be equal to CONNECT_DEPOSIT + user1Deposit"); + assertEq( + ctx.dashboard.totalValue(), + CONNECT_DEPOSIT + user1Deposit, + "Vault's total value should be equal to CONNECT_DEPOSIT + user1Deposit" + ); assertGt( - ctx.dashboard.remainingMintingCapacityShares(0), - 0, - "Remaining minting capacity should be greater than 0" + ctx.dashboard.remainingMintingCapacityShares(0), 0, "Remaining minting capacity should be greater than 0" ); // @@ -85,7 +87,11 @@ contract StvStETHPoolTest is StvStETHPoolHarness { assertGt( ctx.dashboard.remainingMintingCapacityShares(0), 0, "Remaining minting capacity should be greater than 0" ); - assertEq(stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "Mintable stETH shares should be equal to 0"); + assertEq( + stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), + 0, + "Mintable stETH shares should be equal to 0" + ); } function test_depositETH_with_max_mintable_amount() public { @@ -96,9 +102,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // uint256 user1Deposit = 10_000 wei; uint256 user1StethSharesToMint = _calcMaxMintableStShares(ctx, user1Deposit); - + vm.prank(USER1); - stvStETHPool(ctx).depositETHAndMintStethShares{value: user1Deposit}(USER1, address(0), user1StethSharesToMint); + stvStETHPool(ctx).depositETHAndMintStethShares{value: user1Deposit}(address(0), user1StethSharesToMint); _assertUniversalInvariants("Step 1", ctx); @@ -117,7 +123,11 @@ contract StvStETHPoolTest is StvStETHPoolHarness { user1StethSharesToMint, "Vault's liability shares should equal minted shares" ); - assertEq(stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "No additional mintable shares should remain"); + assertEq( + stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), + 0, + "No additional mintable shares should remain" + ); // // Step 2: User deposits more ETH and mints max for new deposit @@ -126,7 +136,7 @@ contract StvStETHPoolTest is StvStETHPoolHarness { uint256 user1StethSharesToMint2 = _calcMaxMintableStShares(ctx, user1Deposit2); vm.prank(USER1); - stvStETHPool(ctx).depositETHAndMintStethShares{value: user1Deposit2}(USER1, address(0), user1StethSharesToMint2); + stvStETHPool(ctx).depositETHAndMintStethShares{value: user1Deposit2}(address(0), user1StethSharesToMint2); _assertUniversalInvariants("Step 2", ctx); @@ -240,7 +250,11 @@ contract StvStETHPoolTest is StvStETHPoolHarness { ); // Still remaining capacity is higher due to CONNECT_DEPOSIT assertGt(ctx.dashboard.remainingMintingCapacityShares(0), 0, "Remaining minting capacity should be equal to 0"); - assertEq(stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "Mintable stETH shares should be equal to 0"); + assertEq( + stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), + 0, + "Mintable stETH shares should be equal to 0" + ); } function test_two_users_mint_full_in_two_steps() public { @@ -365,7 +379,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { user1ExpectedMintableStethShares, "USER1 stSharesForWithdrawal should equal full expected" ); - assertEq(stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "USER1 remaining mintable should be zero"); + assertEq( + stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "USER1 remaining mintable should be zero" + ); assertEq( ctx.dashboard.liabilityShares(), user1ExpectedMintableStethShares + user2StSharesPart1, @@ -392,7 +408,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { user2ExpectedMintableStethShares, "USER2 stSharesForWithdrawal should equal full expected" ); - assertEq(stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER2, 0), 0, "USER2 remaining mintable should be zero"); + assertEq( + stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER2, 0), 0, "USER2 remaining mintable should be zero" + ); // Still remaining capacity is higher due to CONNECT_DEPOSIT assertGt( ctx.dashboard.remainingMintingCapacityShares(0), 0, "Remaining minting capacity should be greater than 0" @@ -413,10 +431,12 @@ contract StvStETHPoolTest is StvStETHPoolHarness { uint256 user1Deposit = 200 ether; uint256 user1ExpectedMintable = _calcMaxMintableStShares(ctx, user1Deposit); vm.prank(USER1); - stvStETHPool(ctx).depositETHAndMintStethShares{value: user1Deposit}(USER1, address(0), user1ExpectedMintable); + stvStETHPool(ctx).depositETHAndMintStethShares{value: user1Deposit}(address(0), user1ExpectedMintable); assertEq(steth.sharesOf(USER1), user1ExpectedMintable, "USER1 stETH shares should equal expected minted"); - assertEq(stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "USER1 remaining mintable should be zero"); + assertEq( + stvStETHPool(ctx).remainingMintingCapacitySharesOf(USER1, 0), 0, "USER1 remaining mintable should be zero" + ); assertGt( ctx.dashboard.remainingMintingCapacityShares(0), 0, @@ -454,11 +474,11 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // // Step 1: User1 deposits // - uint256 user1Deposit = 2 * ctx.withdrawalQueue.MIN_WITHDRAWAL_AMOUNT() * 100; // * 100 to have +1% rewards enough for min withdrawal + uint256 user1Deposit = 2 * ctx.withdrawalQueue.MIN_WITHDRAWAL_VALUE() * 100; // * 100 to have +1% rewards enough for min withdrawal uint256 sharesForDeposit = _calcMaxMintableStShares(ctx, user1Deposit); vm.prank(USER1); - w.depositETHAndMintStethShares{value: user1Deposit}(USER1, address(0), sharesForDeposit); + w.depositETHAndMintStethShares{value: user1Deposit}(address(0), sharesForDeposit); uint256 expectedUser1MintedStShares = sharesForDeposit; assertEq( @@ -494,15 +514,24 @@ contract StvStETHPoolTest is StvStETHPoolHarness { ); assertEq(w.unlockedAssetsOf(USER1, 0), user1Rewards, "USER1 withdrawable eth should be equal to user1Rewards"); - assertEq(w.unlockedAssetsOf(USER1, expectedUser1MintedStShares), w.previewRedeem(w.balanceOf(USER1)), "USER1 withdrawable eth should be equal to user1Deposit + user1Rewards"); + assertEq( + w.unlockedAssetsOf(USER1, expectedUser1MintedStShares), + w.previewRedeem(w.balanceOf(USER1)), + "USER1 withdrawable eth should be equal to user1Deposit + user1Rewards" + ); - assertEq(w.stethSharesToBurnForStvOf(USER1, w.balanceOf(USER1)), expectedUser1MintedStShares, "USER1 stSharesForWithdrawal should be equal to expectedUser1MintedStShares"); + assertEq( + w.stethSharesToBurnForStvOf(USER1, w.balanceOf(USER1)), + expectedUser1MintedStShares, + "USER1 stSharesForWithdrawal should be equal to expectedUser1MintedStShares" + ); uint256 rewardsStv = Math.mulDiv(user1Rewards, w.balanceOf(USER1), user1Deposit + user1Rewards, Math.Rounding.Floor); // TODO: fix fail here assertLe( - w.stethSharesToBurnForStvOf(USER1, rewardsStv), WEI_ROUNDING_TOLERANCE, + w.stethSharesToBurnForStvOf(USER1, rewardsStv), + WEI_ROUNDING_TOLERANCE, "USER1 stSharesForWithdrawal for rewards-only should be ~0" ); assertEq(w.stethSharesToBurnForStvOf(USER1, rewardsStv), 0, "USER1 stSharesForWithdrawal should be equal to 0"); @@ -514,26 +543,32 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // uint256 withdrawableStvWithoutBurning = w.unlockedStvOf(USER1, 0); - assertEq( - withdrawableStvWithoutBurning, rewardsStv, "Withdrawable stv should be equal to rewardsStv" - ); + assertEq(withdrawableStvWithoutBurning, rewardsStv, "Withdrawable stv should be equal to rewardsStv"); vm.prank(USER1); uint256 requestId = ctx.withdrawalQueue.requestWithdrawal(USER1, rewardsStv, 0); WithdrawalQueue.WithdrawalRequestStatus memory status = ctx.withdrawalQueue.getWithdrawalStatus(requestId); - assertEq(status.amountOfAssets, user1Rewards, "Withdrawal request amount should match previewRedeem"); + assertLe( + user1Rewards - status.amountOfAssets, + WEI_ROUNDING_TOLERANCE, + "Withdrawal request amount should almost match previewRedeem" + ); // Update report data with current timestamp to make it fresh core.applyVaultReport(address(ctx.vault), w.totalAssets(), 0, 0, 0); _advancePastMinDelayAndRefreshReport(ctx, requestId); vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); status = ctx.withdrawalQueue.getWithdrawalStatus(requestId); assertTrue(status.isFinalized, "Withdrawal request should be finalized"); - assertEq(status.amountOfAssets, user1Rewards, "Withdrawal request amount should match previewRedeem"); + assertLe( + user1Rewards - status.amountOfAssets, + WEI_ROUNDING_TOLERANCE, + "Withdrawal request amount should almost match previewRedeem" + ); assertEq(status.amountOfStv, rewardsStv, "Withdrawal request shares should match user1SharesToWithdraw"); // Deal ETH to withdrawal queue for the claim (simulating validator exit) @@ -562,14 +597,14 @@ contract StvStETHPoolTest is StvStETHPoolHarness { assertGt(w.balanceOf(USER1), stvFor1Wei, "USER1 stv balance should be greater than stvFor1Wei"); vm.startPrank(USER1); - vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestAmountTooSmall.selector, 1 wei)); + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestValueTooSmall.selector, 1 wei)); ctx.withdrawalQueue.requestWithdrawal(USER1, stvFor1Wei, 0); vm.stopPrank(); // // Step 2.2: User1 withdraws stv with burning stethShares // - uint256 stvForMinWithdrawal = w.previewWithdraw(ctx.withdrawalQueue.MIN_WITHDRAWAL_AMOUNT()); + uint256 stvForMinWithdrawal = w.previewWithdraw(ctx.withdrawalQueue.MIN_WITHDRAWAL_VALUE()); uint256 stethSharesToBurn = w.stethSharesToBurnForStvOf(USER1, stvForMinWithdrawal); vm.startPrank(USER1); @@ -598,7 +633,7 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // Finalize and claim the second (min-withdrawal) request _advancePastMinDelayAndRefreshReport(ctx, requestId); vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); status = ctx.withdrawalQueue.getWithdrawalStatus(requestId); assertTrue(status.isFinalized, "Min-withdrawal request should be finalized"); @@ -633,7 +668,7 @@ contract StvStETHPoolTest is StvStETHPoolHarness { _advancePastMinDelayAndRefreshReport(ctx, requestId3); vm.prank(NODE_OPERATOR); - ctx.withdrawalQueue.finalize(1); + ctx.withdrawalQueue.finalize(1, address(0)); WithdrawalQueue.WithdrawalRequestStatus memory st3 = ctx.withdrawalQueue.getWithdrawalStatus(requestId3); assertTrue(st3.isFinalized, "Final full-withdrawal request should be finalized"); @@ -681,7 +716,7 @@ contract StvStETHPoolTest is StvStETHPoolHarness { user1MintedShares = user1MintedShares / 4 * 4; // Make it divisible by 4 for easier splits vm.prank(USER1); - w.depositETHAndMintStethShares{value: user1Deposit}(USER1, address(0), user1MintedShares); + w.depositETHAndMintStethShares{value: user1Deposit}(address(0), user1MintedShares); uint256 user1Stv = w.balanceOf(USER1); @@ -720,11 +755,7 @@ contract StvStETHPoolTest is StvStETHPoolHarness { assertEq(w.mintedStethSharesOf(USER1), user1MintedShares / 2, "USER1 should have half the liability"); uint256 user1RequiredStv = w.calcStvToLockForStethShares(w.mintedStethSharesOf(USER1)); - assertGe( - w.balanceOf(USER1), - user1RequiredStv, - "USER1 should maintain minimum reserve ratio after transfer" - ); + assertGe(w.balanceOf(USER1), user1RequiredStv, "USER1 should maintain minimum reserve ratio after transfer"); _assertUniversalInvariants("Step 4", ctx); @@ -783,9 +814,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // User deposits and mints maximum stETH shares uint256 userDeposit = 100 ether; - + uint256 maxMintableShares = _calcMaxMintableStShares(ctx, userDeposit); vm.prank(USER1); - w.depositETHAndMintStethShares{value: userDeposit}(USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit)); + w.depositETHAndMintStethShares{value: userDeposit}(address(0), maxMintableShares); vm.startPrank(USER1); vm.expectRevert(StvStETHPool.InsufficientReservedBalance.selector); @@ -801,9 +832,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { StvStETHPool w = stvStETHPool(ctx); uint256 user1Deposit = 50 ether; - vm.prank(USER1); uint256 stethSharesToMint = _calcMaxMintableStShares(ctx, user1Deposit) / 2 * 2; // make even for easier half transfer - w.depositETHAndMintStethShares{value: user1Deposit}(USER1, address(0), stethSharesToMint); + vm.prank(USER1); + w.depositETHAndMintStethShares{value: user1Deposit}(address(0), stethSharesToMint); uint256 sharesToTransfer = w.mintedStethSharesOf(USER1) / 2; uint256 minStvRequired = w.calcStvToLockForStethShares(sharesToTransfer); @@ -828,8 +859,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // User deposits and mints max shares uint256 userDeposit = 100 ether; + uint256 maxMintableShares = _calcMaxMintableStShares(ctx, userDeposit); vm.prank(USER1); - w.depositETHAndMintStethShares{value: userDeposit}(USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit)); + w.depositETHAndMintStethShares{value: userDeposit}(address(0), maxMintableShares); // Vault loses 10% value - user now below reserve ratio vm.warp(block.timestamp + 1 days); @@ -875,8 +907,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // User deposits and mints max shares uint256 userDeposit = 100 ether; + uint256 maxMintableShares = _calcMaxMintableStShares(ctx, userDeposit); vm.prank(USER1); - w.depositETHAndMintStethShares{value: userDeposit}(USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit)); + w.depositETHAndMintStethShares{value: userDeposit}(address(0), maxMintableShares); // Vault loses value vm.warp(block.timestamp + 1 days); @@ -909,8 +942,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // User deposits and mints max shares uint256 userDeposit = 100 ether; + uint256 maxMintableShares = _calcMaxMintableStShares(ctx, userDeposit); vm.prank(USER1); - w.depositETHAndMintStethShares{value: userDeposit}(USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit)); + w.depositETHAndMintStethShares{value: userDeposit}(address(0), maxMintableShares); // Initially can't transfer - at exact reserve ratio vm.startPrank(USER1); @@ -942,8 +976,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // User deposits and mints uint256 userDeposit = 100 ether; + uint256 maxMintableShares = _calcMaxMintableStShares(ctx, userDeposit); vm.prank(USER1); - w.depositETHAndMintStethShares{value: userDeposit}(USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit)); + w.depositETHAndMintStethShares{value: userDeposit}(address(0), maxMintableShares); // Vault gains 1% reportVaultValueChangeNoFees(ctx, 100_00 + 100); @@ -988,8 +1023,9 @@ contract StvStETHPoolTest is StvStETHPoolHarness { // User deposits and mints max uint256 userDeposit = 100 ether; + uint256 maxMintableShares = _calcMaxMintableStShares(ctx, userDeposit); vm.prank(USER1); - w.depositETHAndMintStethShares{value: userDeposit}(USER1, address(0), _calcMaxMintableStShares(ctx, userDeposit)); + w.depositETHAndMintStethShares{value: userDeposit}(address(0), maxMintableShares); uint256 initialMinted = w.mintedStethSharesOf(USER1); assertEq(w.remainingMintingCapacitySharesOf(USER1, 0), 0, "No capacity initially"); @@ -1017,8 +1053,10 @@ contract StvStETHPoolTest is StvStETHPoolHarness { StvStETHPool w = stvStETHPool(ctx); // User deposits and mints + uint256 userDeposit = 50 ether; + uint256 maxMintableShares = _calcMaxMintableStShares(ctx, userDeposit); vm.prank(USER1); - w.depositETHAndMintStethShares{value: 50 ether}(USER1, address(0), _calcMaxMintableStShares(ctx, 50 ether)); + w.depositETHAndMintStethShares{value: userDeposit}(address(0), maxMintableShares); uint256 allStv = w.balanceOf(USER1); uint256 allShares = w.mintedStethSharesOf(USER1); diff --git a/test/integration/timelock-upgrade.test.sol b/test/integration/timelock-upgrade.test.sol index af88bee..03f738c 100644 --- a/test/integration/timelock-upgrade.test.sol +++ b/test/integration/timelock-upgrade.test.sol @@ -1,14 +1,14 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; import {Test} from "forge-std/Test.sol"; import {Factory} from "src/Factory.sol"; -import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; import {StvPool} from "src/StvPool.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; -import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; -import {ILidoLocator} from "src/interfaces/ILidoLocator.sol"; -import {IDashboard} from "src/interfaces/IDashboard.sol"; +import {IDashboard} from "src/interfaces/core/IDashboard.sol"; +import {ILidoLocator} from "src/interfaces/core/ILidoLocator.sol"; +import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; import {FactoryHelper} from "test/utils/FactoryHelper.sol"; contract TimelockUpgradeIntegrationTest is Test { @@ -18,7 +18,6 @@ contract TimelockUpgradeIntegrationTest is Test { // Deploy a fresh Factory using core addresses discovered from the locator string memory locatorAddressStr = vm.envString("CORE_LOCATOR_ADDRESS"); address locatorAddress = vm.parseAddress(locatorAddressStr); - ILidoLocator locator = ILidoLocator(locatorAddress); FactoryHelper helper = new FactoryHelper(); factory = helper.deployMainFactory(locatorAddress); @@ -264,4 +263,3 @@ contract TimelockUpgradeIntegrationTest is Test { // } } - diff --git a/test/mocks/MockDashboard.sol b/test/mocks/MockDashboard.sol index fd8fc3e..a49c569 100644 --- a/test/mocks/MockDashboard.sol +++ b/test/mocks/MockDashboard.sol @@ -1,18 +1,18 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; +import {IVaultHub} from "../../src/interfaces/core/IVaultHub.sol"; import {MockStETH} from "./MockStETH.sol"; -import {MockWstETH} from "./MockWstETH.sol"; -import {MockVaultHub} from "./MockVaultHub.sol"; import {MockStakingVault} from "./MockStakingVault.sol"; +import {MockVaultHub} from "./MockVaultHub.sol"; +import {MockWstETH} from "./MockWstETH.sol"; import {AccessControlEnumerable} from "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol"; -import {IVaultHub} from "../../src/interfaces/IVaultHub.sol"; contract MockDashboard is AccessControlEnumerable { MockStETH public immutable STETH; MockWstETH public immutable WSTETH; MockVaultHub public immutable VAULT_HUB; - address public immutable STAKING_VAULT; + address public immutable VAULT; event DashboardFunded(address sender, uint256 amount); @@ -28,12 +28,12 @@ contract MockDashboard is AccessControlEnumerable { STETH = MockStETH(_steth); WSTETH = MockWstETH(payable(_wsteth)); VAULT_HUB = MockVaultHub(payable(_vaultHub)); - STAKING_VAULT = _stakingVault; // Mock staking vault address + VAULT = _stakingVault; // Mock staking vault address _grantRole(DEFAULT_ADMIN_ROLE, _admin); // Set default report freshness to true - VAULT_HUB.mock_setReportFreshness(STAKING_VAULT, true); - VAULT_HUB.mock_setConnectionParameters(STAKING_VAULT, 10_00, 9_75); // 10% reserve, 9.75% forced rebalance + VAULT_HUB.mock_setReportFreshness(VAULT, true); + VAULT_HUB.mock_setConnectionParameters(VAULT, 10_00, 9_75); // 10% reserve, 9.75% forced rebalance } function initialize() external { @@ -42,19 +42,19 @@ contract MockDashboard is AccessControlEnumerable { function fund() external payable { emit DashboardFunded(msg.sender, msg.value); - VAULT_HUB.fund{value: msg.value}(STAKING_VAULT); + VAULT_HUB.fund{value: msg.value}(VAULT); } function withdrawableValue() external view returns (uint256) { - return address(STAKING_VAULT).balance - locked; + return address(VAULT).balance - locked; } function maxLockableValue() external view returns (uint256) { - return VAULT_HUB.totalValue(STAKING_VAULT); + return VAULT_HUB.totalValue(VAULT); } function withdraw(address recipient, uint256 etherAmount) external { - VAULT_HUB.withdraw(STAKING_VAULT, recipient, etherAmount); + VAULT_HUB.withdraw(VAULT, recipient, etherAmount); } function vaultHub() external view returns (MockVaultHub) { @@ -62,7 +62,7 @@ contract MockDashboard is AccessControlEnumerable { } function stakingVault() external view returns (address) { - return STAKING_VAULT; + return VAULT; } function mock_setLocked(uint256 _locked) external { @@ -70,24 +70,24 @@ contract MockDashboard is AccessControlEnumerable { } function mock_simulateRewards(int256 amount) external { - VAULT_HUB.mock_simulateRewards(STAKING_VAULT, amount); + VAULT_HUB.mock_simulateRewards(VAULT, amount); } function mock_increaseLiability(uint256 amount) external { - VAULT_HUB.mock_increaseLiability(STAKING_VAULT, amount); + VAULT_HUB.mock_increaseLiability(VAULT, amount); } function liabilityShares() external view returns (uint256) { - return VAULT_HUB.vaultLiabilityShares(STAKING_VAULT); + return VAULT_HUB.vaultLiabilityShares(VAULT); } // Mock implementation for minting stETH function mintShares(address to, uint256 amount) external { - VAULT_HUB.mintShares(STAKING_VAULT, to, amount); + VAULT_HUB.mintShares(VAULT, to, amount); } function mintWstETH(address to, uint256 amount) external { - VAULT_HUB.mintShares(STAKING_VAULT, address(this), amount); + VAULT_HUB.mintShares(VAULT, address(this), amount); uint256 mintedStETH = STETH.getPooledEthBySharesRoundUp(amount); uint256 wrappedWstETH = WSTETH.wrap(mintedStETH); WSTETH.transfer(to, wrappedWstETH); @@ -95,7 +95,7 @@ contract MockDashboard is AccessControlEnumerable { function burnShares(uint256 amount) external { STETH.transferSharesFrom(msg.sender, address(VAULT_HUB), amount); - VAULT_HUB.burnShares(STAKING_VAULT, amount); + VAULT_HUB.burnShares(VAULT, amount); } function burnWstETH(uint256 amount) external { @@ -104,10 +104,16 @@ contract MockDashboard is AccessControlEnumerable { uint256 unwrappedShares = STETH.getSharesByPooledEth(unwrappedStETH); STETH.transferShares(address(VAULT_HUB), unwrappedShares); - VAULT_HUB.burnShares(STAKING_VAULT, amount); + VAULT_HUB.burnShares(VAULT, amount); } - function remainingMintingCapacityShares(uint256 /* vaultId */) external pure returns (uint256) { + function remainingMintingCapacityShares( + uint256 /* vaultId */ + ) + external + pure + returns (uint256) + { return 1000 ether; // Mock large capacity } @@ -116,7 +122,7 @@ contract MockDashboard is AccessControlEnumerable { } function vaultConnection() external view returns (IVaultHub.VaultConnection memory) { - return VAULT_HUB.vaultConnection(STAKING_VAULT); + return VAULT_HUB.vaultConnection(VAULT); } function requestValidatorExit(bytes calldata pubkeys) external { @@ -137,11 +143,11 @@ contract MockDashboard is AccessControlEnumerable { function rebalanceVaultWithEther(uint256 _ether) external payable { _rebalanceVault(STETH.getSharesByPooledEth(_ether)); - VAULT_HUB.fund{value: msg.value}(STAKING_VAULT); + VAULT_HUB.fund{value: msg.value}(VAULT); } function _rebalanceVault(uint256 _shares) internal { - VAULT_HUB.rebalance(STAKING_VAULT, _shares); + VAULT_HUB.rebalance(VAULT, _shares); } function voluntaryDisconnect() external { @@ -160,13 +166,8 @@ contract MockDashboardFactory { steth.mock_setTotalPooled(1000 ether, 800 * 10 ** 18); - MockDashboard dashboard = new MockDashboard( - address(steth), - address(wsteth), - address(vaultHub), - address(stakingVault), - _owner - ); + MockDashboard dashboard = + new MockDashboard(address(steth), address(wsteth), address(vaultHub), address(stakingVault), _owner); dashboard.initialize(); diff --git a/test/mocks/MockERC20.sol b/test/mocks/MockERC20.sol index d730aad..d34cf58 100644 --- a/test/mocks/MockERC20.sol +++ b/test/mocks/MockERC20.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; diff --git a/test/mocks/MockLazyOracle.sol b/test/mocks/MockLazyOracle.sol index 61acc9f..ae5faee 100644 --- a/test/mocks/MockLazyOracle.sol +++ b/test/mocks/MockLazyOracle.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {ILazyOracle} from "../../src/interfaces/ILazyOracle.sol"; +import {ILazyOracle} from "../../src/interfaces/core/ILazyOracle.sol"; contract MockLazyOracle is ILazyOracle { uint256 private _latestReportTimestamp; @@ -50,11 +50,7 @@ contract MockLazyOracle is ILazyOracle { } function vaultQuarantine(address) external pure returns (QuarantineInfo memory) { - return QuarantineInfo({ - startTimestamp: 0, - totalValueBeforeQuarantine: 0, - totalValueDuringQuarantine: 0 - }); + return QuarantineInfo({startTimestamp: 0, totalValueBeforeQuarantine: 0, totalValueDuringQuarantine: 0}); } function vaultsCount() external pure returns (uint256) { diff --git a/test/mocks/MockLidoLocator.sol b/test/mocks/MockLidoLocator.sol index 4b605af..b76453e 100644 --- a/test/mocks/MockLidoLocator.sol +++ b/test/mocks/MockLidoLocator.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {ILidoLocator} from "src/interfaces/ILidoLocator.sol"; +import {ILidoLocator} from "src/interfaces/core/ILidoLocator.sol"; contract MockLidoLocator is ILidoLocator { address internal _accountingOracle; @@ -25,13 +25,7 @@ contract MockLidoLocator is ILidoLocator { address internal _lazyOracle; address internal _operatorGrid; - constructor( - address lido_, - address wsteth_, - address lazyOracle_, - address vaultHub_, - address vaultFactory_ - ) { + constructor(address lido_, address wsteth_, address lazyOracle_, address vaultHub_, address vaultFactory_) { _lido = lido_; _wstETH = wsteth_; _lazyOracle = lazyOracle_; @@ -230,4 +224,3 @@ contract MockLidoLocator is ILidoLocator { } } - diff --git a/test/mocks/MockStETH.sol b/test/mocks/MockStETH.sol index a557b01..fc27203 100644 --- a/test/mocks/MockStETH.sol +++ b/test/mocks/MockStETH.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; @@ -9,7 +9,6 @@ contract MockStETH is ERC20 { uint256 private totalPooledEth; mapping(address => uint256) private shares; - constructor() ERC20("Mock stETH", "stETH") { totalShares = 1e18; totalPooledEth = 1e18; @@ -20,16 +19,12 @@ contract MockStETH is ERC20 { // function getSharesByPooledEth(uint256 _ethAmount) public view returns (uint256) { - return _ethAmount - * totalShares // denominator in shares + return _ethAmount * totalShares // denominator in shares / totalPooledEth; // numerator in ether } - function getPooledEthByShares(uint256 _sharesAmount) public view returns (uint256) { - - return _sharesAmount - * totalPooledEth // numerator in ether + return _sharesAmount * totalPooledEth // numerator in ether / totalShares; // denominator in shares } @@ -45,7 +40,6 @@ contract MockStETH is ERC20 { require(allowance >= steth, "Not enough allowance"); _approve(from, msg.sender, allowance - steth); - shares[from] -= amount; shares[to] += amount; @@ -112,7 +106,4 @@ contract MockStETH is ERC20 { totalPooledEth = _pooledEthAmount; totalShares = _sharesAmount; } - - - -} \ No newline at end of file +} diff --git a/test/mocks/MockStakingVault.sol b/test/mocks/MockStakingVault.sol index 0e0ab31..e2820cb 100644 --- a/test/mocks/MockStakingVault.sol +++ b/test/mocks/MockStakingVault.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; /** * @title MockStakingVault @@ -34,7 +34,7 @@ contract MockStakingVault { function withdraw(address recipient, uint256 amount) external { // require(msg.sender == nodeOperator, "Not node operator"); - (bool success, ) = recipient.call{value: amount}(""); + (bool success,) = recipient.call{value: amount}(""); require(success, "Transfer failed"); } diff --git a/test/mocks/MockVaultFactory.sol b/test/mocks/MockVaultFactory.sol index 8a66884..350f5e0 100644 --- a/test/mocks/MockVaultFactory.sol +++ b/test/mocks/MockVaultFactory.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {IVaultFactory} from "../../src/interfaces/IVaultFactory.sol"; +import {IVaultFactory} from "../../src/interfaces/core/IVaultFactory.sol"; import {MockDashboard} from "./MockDashboard.sol"; import {MockStakingVault} from "./MockStakingVault.sol"; import {MockVaultHub} from "./MockVaultHub.sol"; @@ -28,10 +28,10 @@ contract MockVaultFactory is IVaultFactory { function createVaultWithDashboard( address _admin, - address /* _nodeOperator */, - address /* _nodeOperatorManager */, - uint256 /* _nodeOperatorFeeBP */, - uint256 /* _confirmExpiry */, + address, /* _nodeOperator */ + address, /* _nodeOperatorManager */ + uint256, /* _nodeOperatorFeeBP */ + uint256, /* _confirmExpiry */ IVaultFactory.RoleAssignment[] memory /* _roleAssignments */ ) external payable returns (address vault, address dashboard) { if (msg.value != 1 ether) { @@ -43,7 +43,7 @@ contract MockVaultFactory is IVaultFactory { dashboard = address(new MockDashboard(steth, wsteth, VAULT_HUB, vault, _admin)); // Send the connect deposit to the vault to simulate the real factory behavior - (bool success, ) = vault.call{value: msg.value}(""); + (bool success,) = vault.call{value: msg.value}(""); require(success, "Transfer to vault failed"); return (vault, dashboard); @@ -51,10 +51,10 @@ contract MockVaultFactory is IVaultFactory { function createVaultWithDashboardWithoutConnectingToVaultHub( address _admin, - address /* _nodeOperator */, - address /* _nodeOperatorManager */, - uint256 /* _nodeOperatorFeeBP */, - uint256 /* _confirmExpiry */, + address, /* _nodeOperator */ + address, /* _nodeOperatorManager */ + uint256, /* _nodeOperatorFeeBP */ + uint256, /* _confirmExpiry */ RoleAssignment[] calldata /* _roleAssignments */ ) external payable returns (address vault, address dashboard) { require(msg.value == 0 ether, "invalid value sent"); diff --git a/test/mocks/MockVaultHub.sol b/test/mocks/MockVaultHub.sol index 9f90bdd..a2827c6 100644 --- a/test/mocks/MockVaultHub.sol +++ b/test/mocks/MockVaultHub.sol @@ -1,14 +1,13 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; +import {IStakingVault} from "../../src/interfaces/core/IStakingVault.sol"; import {MockStETH} from "./MockStETH.sol"; -import {IVaultHub} from "src/interfaces/IVaultHub.sol"; -import {IStakingVault} from "../../src/interfaces/IStakingVault.sol"; +import {IVaultHub} from "src/interfaces/core/IVaultHub.sol"; contract MockVaultHub { // TODO: maybe inherit IVaultHub - uint256 public immutable RESERVE_RATIO_BP = 25_00; uint256 internal immutable TOTAL_BASIS_POINTS = 100_00; MockStETH public immutable LIDO; @@ -45,6 +44,7 @@ contract MockVaultHub { } event VaultHubFunded(address sender, address vault, uint256 amount); + function fund(address _vault) external payable { emit VaultHubFunded(msg.sender, _vault, msg.value); vaultBalances[_vault] += msg.value; @@ -67,7 +67,13 @@ contract MockVaultHub { return vaultBalances[_vault]; } - function withdrawableValue(address /* _vault */) external pure returns (uint256) { + function withdrawableValue( + address /* _vault */ + ) + external + pure + returns (uint256) + { return 0; // Dummy implementation - returns 0 for testing } @@ -75,7 +81,12 @@ contract MockVaultHub { return vaultLiabilityShares[_vault]; } - function requestValidatorExit(address /* _vault */, bytes calldata /* _pubkeys */) external { + function requestValidatorExit( + address, + /* _vault */ + bytes calldata /* _pubkeys */ + ) + external { // Mock implementation - just emit an event or do nothing // In real implementation, this would request node operators to exit validators } @@ -100,38 +111,48 @@ contract MockVaultHub { } function triggerValidatorWithdrawals( - address /* _vault */, - bytes calldata /* _pubkeys */, - uint64[] calldata /* _amountsInGwei */, + address, + /* _vault */ + bytes calldata, + /* _pubkeys */ + uint64[] calldata, + /* _amountsInGwei */ address /* _refundRecipient */ - ) external payable { - // // Mock implementation - simulate validator withdrawals - // // In real implementation, this would trigger EIP-7002 withdrawals - - // // For testing, we can simulate that validators were exited and funds are now available - // uint256 totalWithdrawn = 0; - // for (uint256 i = 0; i < _amountsInGwei.length; i++) { - // if (_amountsInGwei[i] == 0) { - // // Full withdrawal (32 ETH per validator) - // totalWithdrawn += 32 ether; - // } else { - // totalWithdrawn += _amountsInGwei[i]; - // } - // } - - // // Add withdrawn funds to withdrawable balance - // vaultWithdrawableBalances[_vault] += totalWithdrawn; - - // // Refund excess fee - // if (msg.value > 0 && _refundRecipient != address(0)) { - // payable(_refundRecipient).transfer(msg.value); - // } + ) + external + payable { + // // Mock implementation - simulate validator withdrawals + // // In real implementation, this would trigger EIP-7002 withdrawals + + // // For testing, we can simulate that validators were exited and funds are now available + // uint256 totalWithdrawn = 0; + // for (uint256 i = 0; i < _amountsInGwei.length; i++) { + // if (_amountsInGwei[i] == 0) { + // // Full withdrawal (32 ETH per validator) + // totalWithdrawn += 32 ether; + // } else { + // totalWithdrawn += _amountsInGwei[i]; + // } + // } + + // // Add withdrawn funds to withdrawable balance + // vaultWithdrawableBalances[_vault] += totalWithdrawn; + + // // Refund excess fee + // if (msg.value > 0 && _refundRecipient != address(0)) { + // payable(_refundRecipient).transfer(msg.value); + // } } /** * @notice Test-only function to simulate validator exits making funds withdrawable */ - function simulateValidatorExits(address /* _vault */, uint256 /* _amount */) external { + function simulateValidatorExits( + address, + /* _vault */ + uint256 /* _amount */ + ) + external { // vaultWithdrawableBalances[_vault] += _amount; // // Also increase the contract's actual balance to allow for withdrawals // payable(address(this)).transfer(_amount); @@ -141,7 +162,14 @@ contract MockVaultHub { return 1 ether; } - function transferVaultOwnership(address /* _vault */, address /* _newOwner */) external pure { + function transferVaultOwnership( + address, + /* _vault */ + address /* _newOwner */ + ) + external + pure + { revert("Not implemented"); } diff --git a/test/mocks/MockWstETH.sol b/test/mocks/MockWstETH.sol index 9c8496d..81af9a8 100644 --- a/test/mocks/MockWstETH.sol +++ b/test/mocks/MockWstETH.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import {IStETH} from "src/interfaces/IStETH.sol"; +import {IStETH} from "src/interfaces/core/IStETH.sol"; contract MockWstETH is ERC20 { IStETH public stETH; diff --git a/test/unit/StVaultWrapperV3.test.sol.todo b/test/unit/StVaultWrapperV3.test.sol.todo index a4c3da9..9a32485 100644 --- a/test/unit/StVaultWrapperV3.test.sol.todo +++ b/test/unit/StVaultWrapperV3.test.sol.todo @@ -1,10 +1,10 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Test, console} from "forge-std/Test.sol"; -import {ILidoLocator} from "src/interfaces/ILidoLocator.sol"; -import {IStETH} from "../src/interfaces/IStETH.sol"; +import {ILidoLocator} from "src/interfaces/core/ILidoLocator.sol"; +import {IStETH} from "../src/interfaces/core/IStETH.sol"; import {Wrapper} from "../src/Wrapper.sol"; import {MockDashboard} from "./mocks/MockDashboard.sol"; diff --git a/test/unit/allow-list/Deposits.test.sol b/test/unit/allow-list/Deposits.test.sol index 5491a5d..5593865 100644 --- a/test/unit/allow-list/Deposits.test.sol +++ b/test/unit/allow-list/Deposits.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupAllowList} from "./SetupAllowList.sol"; +import {Test} from "forge-std/Test.sol"; import {AllowList} from "src/AllowList.sol"; contract AllowListDepositsTest is Test, SetupAllowList { diff --git a/test/unit/allow-list/Management.sol b/test/unit/allow-list/Management.sol index 05931e4..b0b4254 100644 --- a/test/unit/allow-list/Management.sol +++ b/test/unit/allow-list/Management.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; -import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; import {SetupAllowList} from "./SetupAllowList.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; import {AllowList} from "src/AllowList.sol"; contract AllowListManagementTest is Test, SetupAllowList { diff --git a/test/unit/allow-list/Roles.test.sol b/test/unit/allow-list/Roles.test.sol index f43c097..c1a98db 100644 --- a/test/unit/allow-list/Roles.test.sol +++ b/test/unit/allow-list/Roles.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupAllowList} from "./SetupAllowList.sol"; +import {Test} from "forge-std/Test.sol"; contract AllowListRolesTest is Test, SetupAllowList { bytes32 ALLOW_LIST_MANAGER_ROLE; diff --git a/test/unit/allow-list/SetupAllowList.sol b/test/unit/allow-list/SetupAllowList.sol index 0e76404..2583196 100644 --- a/test/unit/allow-list/SetupAllowList.sol +++ b/test/unit/allow-list/SetupAllowList.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Test} from "forge-std/Test.sol"; import {StvPool} from "src/StvPool.sol"; import {MockDashboard, MockDashboardFactory} from "test/mocks/MockDashboard.sol"; import {MockStETH} from "test/mocks/MockStETH.sol"; @@ -44,7 +44,8 @@ abstract contract SetupAllowList is Test { _dashboard: address(dashboard), _allowListEnabled: false, _withdrawalQueue: address(0), - _distributor: address(0) + _distributor: address(0), + _poolType: bytes32("TestPool") }); ERC1967Proxy poolProxyWithoutAllowList = new ERC1967Proxy(address(implWithoutAllowList), ""); poolWithoutAllowList = StvPool(payable(poolProxyWithoutAllowList)); @@ -55,7 +56,8 @@ abstract contract SetupAllowList is Test { _dashboard: address(dashboard), _allowListEnabled: true, _withdrawalQueue: address(0), - _distributor: address(0) + _distributor: address(0), + _poolType: bytes32("TestPool") }); ERC1967Proxy poolProxyWithAllowList = new ERC1967Proxy(address(implWithAllowList), ""); poolWithAllowList = StvPool(payable(poolProxyWithAllowList)); diff --git a/test/unit/allow-list/Views.test.sol b/test/unit/allow-list/Views.test.sol index 742fd14..3dc06c5 100644 --- a/test/unit/allow-list/Views.test.sol +++ b/test/unit/allow-list/Views.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupAllowList} from "./SetupAllowList.sol"; +import {Test} from "forge-std/Test.sol"; contract AllowListViewsTest is Test, SetupAllowList { // ALLOW_LIST_ENABLED diff --git a/test/unit/distributor/Claiming.test.sol b/test/unit/distributor/Claiming.test.sol index 349c620..ccc21c5 100644 --- a/test/unit/distributor/Claiming.test.sol +++ b/test/unit/distributor/Claiming.test.sol @@ -1,14 +1,19 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupDistributor} from "./SetupDistributor.sol"; +import {Test} from "forge-std/Test.sol"; import {Distributor} from "src/Distributor.sol"; import {MerkleTree} from "test/utils/MerkleTree.sol"; contract ClaimingTest is Test, SetupDistributor { function setUp() public override { super.setUp(); + + vm.startPrank(manager); + distributor.addToken(address(token1)); + distributor.addToken(address(token2)); + vm.stopPrank(); } // ==================== Error Cases ==================== @@ -20,11 +25,11 @@ contract ClaimingTest is Test, SetupDistributor { bytes32[] memory proof = new bytes32[](0); vm.expectRevert(Distributor.RootNotSet.selector); - newDistributor.claim(userAlice, address(token1), 100 ether, proof); + newDistributor.claim(userAlice, address(token1), _tokens(100), proof); } function test_Claim_RevertsOnInvalidProof() public { - uint256 claimAmount = 100 ether; + uint256 claimAmount = _tokens(100); merkleTree.pushLeaf(_leafData(userAlice, address(token1), claimAmount)); bytes32 root = merkleTree.root(); @@ -42,7 +47,7 @@ contract ClaimingTest is Test, SetupDistributor { } function test_Claim_RevertsOnClaimableTooLow() public { - uint256 claimAmount = 100 ether; + uint256 claimAmount = _tokens(100); merkleTree.pushLeaf(_leafData(userAlice, address(token1), claimAmount)); bytes32 root = merkleTree.root(); @@ -62,7 +67,7 @@ contract ClaimingTest is Test, SetupDistributor { } function test_Claim_RevertsOnWrongRecipient() public { - uint256 claimAmount = 100 ether; + uint256 claimAmount = _tokens(100); merkleTree.pushLeaf(_leafData(userAlice, address(token1), claimAmount)); bytes32 root = merkleTree.root(); @@ -78,7 +83,7 @@ contract ClaimingTest is Test, SetupDistributor { } function test_Claim_RevertsOnWrongToken() public { - uint256 claimAmount = 100 ether; + uint256 claimAmount = _tokens(100); merkleTree.pushLeaf(_leafData(userAlice, address(token1), claimAmount)); bytes32 root = merkleTree.root(); @@ -94,7 +99,7 @@ contract ClaimingTest is Test, SetupDistributor { } function test_Claim_RevertsOnWrongAmount() public { - uint256 claimAmount = 100 ether; + uint256 claimAmount = _tokens(100); merkleTree.pushLeaf(_leafData(userAlice, address(token1), claimAmount)); bytes32 root = merkleTree.root(); @@ -106,14 +111,14 @@ contract ClaimingTest is Test, SetupDistributor { // Try to claim with wrong amount vm.prank(userAlice); vm.expectRevert(Distributor.InvalidProof.selector); - distributor.claim(userAlice, address(token1), 200 ether, proof); + distributor.claim(userAlice, address(token1), _tokens(200), proof); } // ==================== Basic Claiming Tests ==================== function test_Claim_SuccessfulClaim() public { // Setup: Create merkle tree with one claim - uint256 claimAmount = 100 ether; + uint256 claimAmount = _tokens(100); merkleTree.pushLeaf(_leafData(userAlice, address(token1), claimAmount)); bytes32 root = merkleTree.root(); @@ -135,7 +140,7 @@ contract ClaimingTest is Test, SetupDistributor { } function test_Claim_EmitsClaimedEvent() public { - uint256 claimAmount = 100 ether; + uint256 claimAmount = _tokens(100); merkleTree.pushLeaf(_leafData(userAlice, address(token1), claimAmount)); bytes32 root = merkleTree.root(); @@ -152,7 +157,7 @@ contract ClaimingTest is Test, SetupDistributor { } function test_Claim_AnyoneCanClaimOnBehalf() public { - uint256 claimAmount = 100 ether; + uint256 claimAmount = _tokens(100); merkleTree.pushLeaf(_leafData(userAlice, address(token1), claimAmount)); bytes32 root = merkleTree.root(); @@ -173,10 +178,9 @@ contract ClaimingTest is Test, SetupDistributor { function test_Claim_MultipleUsersCanClaim() public { // Setup three leaf tree - merkleTree.pushLeaf(_leafData(userAlice, address(token1), 100 ether)); - merkleTree.pushLeaf(_leafData(userBob, address(token1), 200 ether)); - merkleTree.pushLeaf(_leafData(userCharlie, address(token2), 300 ether)); - + merkleTree.pushLeaf(_leafData(userAlice, address(token1), _tokens(100))); + merkleTree.pushLeaf(_leafData(userBob, address(token1), _tokens(200))); + merkleTree.pushLeaf(_leafData(userCharlie, address(token2), _tokens(300))); bytes32 root = merkleTree.root(); @@ -187,27 +191,27 @@ contract ClaimingTest is Test, SetupDistributor { assertEq(token1.balanceOf(userBob), 0); assertEq(token2.balanceOf(userCharlie), 0); - // Alice claims 100 ether of token1 + // Alice claims 100 tokens of token1 vm.prank(userAlice); - distributor.claim(userAlice, address(token1), 100 ether, merkleTree.getProof(0)); - assertEq(token1.balanceOf(userAlice), 100 ether); + distributor.claim(userAlice, address(token1), _tokens(100), merkleTree.getProof(0)); + assertEq(token1.balanceOf(userAlice), _tokens(100)); - // Bob claims 200 ether of token1 + // Bob claims 200 tokens of token1 vm.prank(userBob); - distributor.claim(userBob, address(token1), 200 ether, merkleTree.getProof(1)); - assertEq(token1.balanceOf(userBob), 200 ether); + distributor.claim(userBob, address(token1), _tokens(200), merkleTree.getProof(1)); + assertEq(token1.balanceOf(userBob), _tokens(200)); - // Charlie claims 300 ether of token2 + // Charlie claims 300 tokens of token2 vm.prank(userCharlie); - distributor.claim(userCharlie, address(token2), 300 ether, merkleTree.getProof(2)); - assertEq(token2.balanceOf(userCharlie), 300 ether); + distributor.claim(userCharlie, address(token2), _tokens(300), merkleTree.getProof(2)); + assertEq(token2.balanceOf(userCharlie), _tokens(300)); } // ==================== Partial and Multiple Claims ==================== function test_Claim_PartialClaim() public { - // First claim with 50 ether - uint256 amount1 = 50 ether; + // First claim with 50 tokens (18 decimals) + uint256 amount1 = _tokens(50); merkleTree.pushLeaf(_leafData(userAlice, address(token1), amount1)); bytes32 root1 = merkleTree.root(); @@ -218,11 +222,11 @@ contract ClaimingTest is Test, SetupDistributor { vm.prank(userAlice); uint256 claimed1 = distributor.claim(userAlice, address(token1), amount1, proof1); - assertEq(claimed1, 50 ether); - assertEq(distributor.claimed(userAlice, address(token1)), 50 ether); + assertEq(claimed1, _tokens(50)); + assertEq(distributor.claimed(userAlice, address(token1)), _tokens(50)); - // Update root with higher amount (100 ether total) - uint256 amount2 = 100 ether; + // Update root with higher amount (100 tokens total) + uint256 amount2 = _tokens(100); merkleTree = new MerkleTree(); // Reset tree merkleTree.pushLeaf(_leafData(userAlice, address(token1), amount2)); @@ -235,15 +239,15 @@ contract ClaimingTest is Test, SetupDistributor { // Second claim - should get difference (50 more) vm.prank(userAlice); uint256 claimed2 = distributor.claim(userAlice, address(token1), amount2, proof2); - assertEq(claimed2, 50 ether); - assertEq(distributor.claimed(userAlice, address(token1)), 100 ether); - assertEq(token1.balanceOf(userAlice), 100 ether); + assertEq(claimed2, _tokens(50)); + assertEq(distributor.claimed(userAlice, address(token1)), _tokens(100)); + assertEq(token1.balanceOf(userAlice), _tokens(100)); } function test_Claim_MultipleDifferentTokens() public { // Setup claims for same user, different tokens - merkleTree.pushLeaf(_leafData(userAlice, address(token1), 100 ether)); - merkleTree.pushLeaf(_leafData(userAlice, address(token2), 200 ether)); + merkleTree.pushLeaf(_leafData(userAlice, address(token1), _tokens(100))); + merkleTree.pushLeaf(_leafData(userAlice, address(token2), _tokens(200))); bytes32 root = merkleTree.root(); @@ -252,12 +256,49 @@ contract ClaimingTest is Test, SetupDistributor { // Claim token1 vm.prank(userAlice); - distributor.claim(userAlice, address(token1), 100 ether, merkleTree.getProof(0)); - assertEq(token1.balanceOf(userAlice), 100 ether); + distributor.claim(userAlice, address(token1), _tokens(100), merkleTree.getProof(0)); + assertEq(token1.balanceOf(userAlice), _tokens(100)); // Claim token2 vm.prank(userAlice); - distributor.claim(userAlice, address(token2), 200 ether, merkleTree.getProof(1)); - assertEq(token2.balanceOf(userAlice), 200 ether); + distributor.claim(userAlice, address(token2), _tokens(200), merkleTree.getProof(1)); + assertEq(token2.balanceOf(userAlice), _tokens(200)); + } + + function test_PreviewClaim_ReturnsClaimableAmount() public { + // Setup: Add leaf for Alice, token1, 100 tokens (18 decimals) + uint256 claimAmount = _tokens(100); + merkleTree.pushLeaf(_leafData(userAlice, address(token1), claimAmount)); + bytes32 root = merkleTree.root(); + bytes32[] memory proof = merkleTree.getProof(0); + + vm.prank(manager); + distributor.setMerkleRoot(root, "QmPreviewTest"); + + // Preview before any claim; claimable should be 100 tokens + uint256 preview = distributor.previewClaim(userAlice, address(token1), claimAmount, proof); + assertEq(preview, _tokens(100)); + + // Claim the amount + vm.prank(userAlice); + distributor.claim(userAlice, address(token1), claimAmount, proof); + + // Preview after claim; should return 0 + uint256 previewAfterClaim = distributor.previewClaim(userAlice, address(token1), claimAmount, proof); + assertEq(previewAfterClaim, 0); + + // Set root with higher cumulative amount + uint256 newClaimAmount = _tokens(150); + MerkleTree newTree = new MerkleTree(); + newTree.pushLeaf(_leafData(userAlice, address(token1), newClaimAmount)); + bytes32 newRoot = newTree.root(); + bytes32[] memory newProof = newTree.getProof(0); + + vm.prank(manager); + distributor.setMerkleRoot(newRoot, "QmPreviewTest2"); + + // Preview claimable (should be 50 tokens, since 100 already claimed) + uint256 preview2 = distributor.previewClaim(userAlice, address(token1), newClaimAmount, newProof); + assertEq(preview2, _tokens(50)); } } diff --git a/test/unit/distributor/Constructor.test.sol b/test/unit/distributor/Constructor.test.sol index 48b125c..705b467 100644 --- a/test/unit/distributor/Constructor.test.sol +++ b/test/unit/distributor/Constructor.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupDistributor} from "./SetupDistributor.sol"; +import {Test} from "forge-std/Test.sol"; import {Distributor} from "src/Distributor.sol"; contract ConstructorTest is Test, SetupDistributor { diff --git a/test/unit/distributor/MerkleRoot.test.sol b/test/unit/distributor/MerkleRoot.test.sol index 3b15e6a..27bd46a 100644 --- a/test/unit/distributor/MerkleRoot.test.sol +++ b/test/unit/distributor/MerkleRoot.test.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupDistributor} from "./SetupDistributor.sol"; -import {Distributor} from "src/Distributor.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; +import {Distributor} from "src/Distributor.sol"; contract MerkleRootTest is Test, SetupDistributor { function setUp() public override { @@ -21,43 +21,38 @@ contract MerkleRootTest is Test, SetupDistributor { vm.prank(userAlice); vm.expectRevert( - abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, - userAlice, - managerRole - ) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, userAlice, managerRole) ); distributor.setMerkleRoot(newRoot, newCid); } - function test_SetMerkleRoot_RevertsOnSameRoot() public { - bytes32 newRoot = keccak256("testRoot"); - string memory cid1 = "QmTestCID1"; + function test_SetMerkleRoot_RevertsWhenNoChanges() public { + bytes32 initialRoot = keccak256("root"); + string memory initialCid = "QmCID"; - vm.prank(manager); - distributor.setMerkleRoot(newRoot, cid1); - - // Try to set the same root with different CID - string memory cid2 = "QmTestCID2"; + vm.startPrank(manager); + distributor.setMerkleRoot(initialRoot, initialCid); - vm.prank(manager); vm.expectRevert(Distributor.AlreadyProcessed.selector); - distributor.setMerkleRoot(newRoot, cid2); + distributor.setMerkleRoot(initialRoot, initialCid); + vm.stopPrank(); } - function test_SetMerkleRoot_RevertsOnSameCid() public { + function test_SetMerkleRoot_AllowsPartialUpdates() public { bytes32 root1 = keccak256("root1"); - string memory sameCid = "QmTestCID"; + bytes32 root2 = keccak256("root2"); + string memory cid1 = "QmCid1"; + string memory cid2 = "QmCid2"; - vm.prank(manager); - distributor.setMerkleRoot(root1, sameCid); + vm.startPrank(manager); + distributor.setMerkleRoot(root1, cid1); - // Try to set different root with same CID - bytes32 root2 = keccak256("root2"); + // Same root, new cid + distributor.setMerkleRoot(root1, cid2); - vm.prank(manager); - vm.expectRevert(Distributor.AlreadyProcessed.selector); - distributor.setMerkleRoot(root2, sameCid); + // Same cid, new root + distributor.setMerkleRoot(root2, cid2); + vm.stopPrank(); } // ==================== Successful Merkle Root Setting ==================== @@ -116,5 +111,3 @@ contract MerkleRootTest is Test, SetupDistributor { } } - - diff --git a/test/unit/distributor/SetupDistributor.sol b/test/unit/distributor/SetupDistributor.sol index 322e675..b1dfcff 100644 --- a/test/unit/distributor/SetupDistributor.sol +++ b/test/unit/distributor/SetupDistributor.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Test} from "forge-std/Test.sol"; import {Distributor} from "src/Distributor.sol"; @@ -24,7 +24,7 @@ abstract contract SetupDistributor is Test { event TokenAdded(address indexed token); event Claimed(address indexed recipient, address indexed token, uint256 amount); event MerkleRootUpdated( - bytes32 oldRoot, bytes32 newRoot, string oldCid, string newCid, uint256 oldBlock, uint256 newBlock + bytes32 oldRoot, bytes32 indexed newRoot, string oldCid, string newCid, uint256 oldBlock, uint256 newBlock ); function setUp() public virtual { @@ -49,14 +49,18 @@ abstract contract SetupDistributor is Test { distributor.grantRole(managerRole, manager); // Fund distributor with tokens - token1.mint(address(distributor), 1_000_000 ether); - token2.mint(address(distributor), 1_000_000 ether); - token3.mint(address(distributor), 1_000_000 ether); + token1.mint(address(distributor), _tokens(1_000_000)); + token2.mint(address(distributor), _tokens(1_000_000)); + token3.mint(address(distributor), _tokens(1_000_000)); // Deploy MerkleTree helper merkleTree = new MerkleTree(); } + function _tokens(uint256 amount) internal pure returns (uint256) { + return amount * 1e18; + } + // ==================== Merkle Tree Helpers ==================== /// @notice Creates leaf data for a claim (matches Distributor contract format) diff --git a/test/unit/distributor/TokenManagement.test.sol b/test/unit/distributor/TokenManagement.test.sol index f18235f..8e7a4cb 100644 --- a/test/unit/distributor/TokenManagement.test.sol +++ b/test/unit/distributor/TokenManagement.test.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupDistributor} from "./SetupDistributor.sol"; -import {Distributor} from "src/Distributor.sol"; import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; +import {Distributor} from "src/Distributor.sol"; contract TokenManagementTest is Test, SetupDistributor { function setUp() public override { @@ -14,16 +14,11 @@ contract TokenManagementTest is Test, SetupDistributor { // ==================== Error Cases ==================== function test_AddToken_RevertsIfNotManager() public { - bytes32 managerRole = distributor.MANAGER_ROLE(); vm.prank(userAlice); vm.expectRevert( - abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, - userAlice, - managerRole - ) + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, userAlice, managerRole) ); distributor.addToken(address(token1)); } @@ -100,5 +95,3 @@ contract TokenManagementTest is Test, SetupDistributor { } } - - diff --git a/test/unit/ggv-mock.test.sol b/test/unit/ggv-mock.test.sol index 4eb45b5..8dfb6cc 100644 --- a/test/unit/ggv-mock.test.sol +++ b/test/unit/ggv-mock.test.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Test} from "forge-std/Test.sol"; -import {GGVVaultMock} from "src/mock/ggv/GGVVaultMock.sol"; import {GGVMockTeller} from "src/mock/ggv/GGVMockTeller.sol"; import {GGVQueueMock} from "src/mock/ggv/GGVQueueMock.sol"; +import {GGVVaultMock} from "src/mock/ggv/GGVVaultMock.sol"; import {MockStETH} from "test/mocks/MockStETH.sol"; import {MockWstETH} from "test/mocks/MockWstETH.sol"; @@ -56,7 +56,7 @@ contract GGVMockTest is Test { vm.startPrank(admin); // add 1 steth to ggv balance for rebase - vault.rebase(1 ether); + vault.rebaseSteth(1 ether); uint256 newGgvUserAssets = vault.getAssetsByShares(ggvShares); assertEq(newGgvUserAssets > ggvUserAssets, true); } diff --git a/test/unit/minting-parameter.test.sol.todo b/test/unit/minting-parameter.test.sol.todo index 053f296..49dec47 100644 --- a/test/unit/minting-parameter.test.sol.todo +++ b/test/unit/minting-parameter.test.sol.todo @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Test} from "forge-std/Test.sol"; import {Wrapper, MintingMustBeAllowedForStrategy, ZeroStv} from "src/Wrapper.sol"; diff --git a/test/unit/stv-pool/Approvals.test.sol b/test/unit/stv-pool/Approvals.test.sol new file mode 100644 index 0000000..4b2bc64 --- /dev/null +++ b/test/unit/stv-pool/Approvals.test.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupStvPool} from "./SetupStvPool.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Test} from "forge-std/Test.sol"; + +contract ApprovalsTest is Test, SetupStvPool { + function setUp() public override { + super.setUp(); + + // Setup: deposit ETH for users + vm.prank(userAlice); + pool.depositETH{value: 10 ether}(userAlice, address(0)); + } + + function test_Approve_SetsAllowance() public { + uint256 amount = 5 * 10 ** pool.decimals(); + + vm.prank(userAlice); + pool.approve(userBob, amount); + + assertEq(pool.allowance(userAlice, userBob), amount); + } + + function test_Approve_OverwritesExisting() public { + uint256 firstAmount = 5 * 10 ** pool.decimals(); + uint256 secondAmount = 10 * 10 ** pool.decimals(); + + vm.startPrank(userAlice); + pool.approve(userBob, firstAmount); + assertEq(pool.allowance(userAlice, userBob), firstAmount); + + pool.approve(userBob, secondAmount); + assertEq(pool.allowance(userAlice, userBob), secondAmount); + vm.stopPrank(); + } + + function test_Approve_EmitsEvent() public { + uint256 amount = 5 * 10 ** pool.decimals(); + + vm.expectEmit(true, true, false, true); + emit IERC20.Approval(userAlice, userBob, amount); + + vm.prank(userAlice); + pool.approve(userBob, amount); + } + + function test_Approve_MaxUint256() public { + uint256 maxAmount = type(uint256).max; + + vm.prank(userAlice); + pool.approve(userBob, maxAmount); + + assertEq(pool.allowance(userAlice, userBob), maxAmount); + } + + function test_Approve_WorksWithUnassignedLiability() public { + // Add unassigned liability + dashboard.mock_increaseLiability(100); + + // Approve should still work even with unassigned liability + uint256 amount = 1 * 10 ** pool.decimals(); + + vm.prank(userAlice); + pool.approve(userBob, amount); + + assertEq(pool.allowance(userAlice, userBob), amount); + } +} diff --git a/test/unit/stv-pool/BadDebt.test.sol b/test/unit/stv-pool/BadDebt.test.sol new file mode 100644 index 0000000..8eb3c8a --- /dev/null +++ b/test/unit/stv-pool/BadDebt.test.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupStvPool} from "./SetupStvPool.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvPool} from "src/StvPool.sol"; + +contract BadDebtTest is Test, SetupStvPool { + function _simulateBadDebt() internal { + // Create bad debt + dashboard.mock_increaseLiability(steth.getSharesByPooledEth(pool.totalAssets()) + 1); + + _assertBadDebt(); + } + + function _getValueAndLiabilityShares() internal view returns (uint256 valueShares, uint256 liabilityShares) { + valueShares = steth.getSharesByPooledEth(vaultHub.totalValue(address(pool.VAULT()))); + liabilityShares = pool.totalLiabilityShares(); + } + + function _assertBadDebt() internal view { + (uint256 valueShares, uint256 liabilityShares) = _getValueAndLiabilityShares(); + assertLt(valueShares, liabilityShares); + } + + function _assertNoBadDebt() internal view { + (uint256 valueShares, uint256 liabilityShares) = _getValueAndLiabilityShares(); + assertGe(valueShares, liabilityShares); + } + + // Initial state tests + + function test_InitialState_NoBadDebt() public view { + _assertNoBadDebt(); + } + + // Bad debt tests + + function test_BadDebt_TransfersNotAllowed() public { + _simulateBadDebt(); + + vm.expectRevert(StvPool.VaultInBadDebt.selector); + pool.transfer(address(1), 1 ether); + } + + function test_BadDebt_DepositsNotAllowed() public { + _simulateBadDebt(); + + vm.expectRevert(StvPool.VaultInBadDebt.selector); + pool.depositETH{value: 1 ether}(address(this), address(0)); + } +} diff --git a/test/unit/stv-pool/Conversion.test.sol b/test/unit/stv-pool/Conversion.test.sol new file mode 100644 index 0000000..4a75e7b --- /dev/null +++ b/test/unit/stv-pool/Conversion.test.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupStvPool} from "./SetupStvPool.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Test} from "forge-std/Test.sol"; + +contract ConversionTest is Test, SetupStvPool { + function test_InitialDeployment_ExchangeRateIsOne() public view { + uint256 assets = 1 ether; + uint256 expectedStv = 10 ** pool.decimals(); + + assertEq(pool.previewDeposit(assets), expectedStv); + assertEq(pool.previewWithdraw(assets), expectedStv); + assertEq(pool.previewRedeem(expectedStv), assets); + } + + function test_PreviewDeposit_WhenAssetsZero() public { + dashboard.mock_simulateRewards(-1 ether); + + assertEq(pool.totalAssets(), 0); + assertGt(pool.totalSupply(), 0); + + // When totalAssets is zero, deposits yield zero STV + assertEq(pool.previewDeposit(1 ether), 0); + } + + function test_PreviewWithdraw_WhenAssetsZero() public { + dashboard.mock_simulateRewards(-1 ether); + + assertEq(pool.totalAssets(), 0); + assertGt(pool.totalSupply(), 0); + + assertEq(pool.previewWithdraw(1 ether), 0); + } + + function test_PreviewRedeem_WhenAssetsZero() public { + dashboard.mock_simulateRewards(-1 ether); + + assertEq(pool.totalAssets(), 0); + assertGt(pool.totalSupply(), 0); + + assertEq(pool.previewRedeem(10 ** pool.decimals()), 0); + } + + function test_Conversion_RateAboveOne() public { + pool.depositETH{value: 1 ether}(address(this), address(0)); + + dashboard.mock_simulateRewards(1 ether); + + assertEq(pool.totalAssets(), 3 ether); + assertEq(pool.totalSupply(), 2 * 10 ** pool.decimals()); + + uint256 assets = 1 ether; + uint256 expectedStv = assets * (2 * 10 ** pool.decimals()) / 3 ether; + assertEq(pool.previewDeposit(assets), expectedStv); + + uint256 stv = 10 ** pool.decimals(); + uint256 expectedAssets = stv * 3 ether / (2 * 10 ** pool.decimals()); + assertEq(pool.previewRedeem(stv), expectedAssets); + } + + function test_Conversion_RateBelowOne() public { + pool.depositETH{value: 2 ether}(address(this), address(0)); + + dashboard.mock_simulateRewards(-1 ether); + + assertEq(pool.totalAssets(), 2 ether); + assertEq(pool.totalSupply(), 3 * 10 ** pool.decimals()); + + uint256 assets = 1 ether; + uint256 expectedStv = assets * (3 * 10 ** pool.decimals()) / 2 ether; + assertEq(pool.previewDeposit(assets), expectedStv); + + uint256 stv = 1 * 10 ** pool.decimals(); + uint256 expectedAssets = stv * 2 ether / (3 * 10 ** pool.decimals()); + assertEq(pool.previewRedeem(stv), expectedAssets); + } + + function test_ExchangeRate_AfterMultipleDeposits() public { + vm.prank(userAlice); + pool.depositETH{value: 1 ether}(userAlice, address(0)); + + vm.prank(userBob); + pool.depositETH{value: 2 ether}(userBob, address(0)); + + assertEq(pool.totalAssets(), 4 ether); + assertEq(pool.totalSupply(), 4 * 10 ** 27); + + assertEq(pool.previewDeposit(1 ether), 1 * 10 ** 27); + assertEq(pool.previewRedeem(1 * 10 ** 27), 1 ether); + } + + function test_ExchangeRate_WithSmallRewards() public { + pool.depositETH{value: 1 ether}(address(this), address(0)); + + dashboard.mock_simulateRewards(1 wei); + + uint256 expectedStv = Math.mulDiv(1 ether, pool.totalSupply(), pool.totalAssets(), Math.Rounding.Floor); + assertEq(pool.previewDeposit(1 ether), expectedStv); + } + + function test_ExchangeRate_WithLargeRewards() public { + pool.depositETH{value: 1 ether}(address(this), address(0)); + + dashboard.mock_simulateRewards(100 ether); + + assertEq(pool.totalAssets(), 102 ether); + assertEq(pool.totalSupply(), 2 * 10 ** 27); + + uint256 expectedStv = Math.mulDiv(1 ether, 2 * 10 ** 27, 102 ether, Math.Rounding.Floor); + assertEq(pool.previewDeposit(1 ether), expectedStv); + } + + // Fuzz tests for rounding behavior + + function testFuzz_DepositRounding_RoundsDownForUser(uint96 amount, int64 rewards, uint96 assets) public { + vm.assume(amount > 0); + vm.assume(int256(pool.totalAssets()) + rewards >= 0); + + vm.deal(address(this), amount); + pool.depositETH{value: amount}(address(this), address(0)); + + dashboard.mock_simulateRewards(rewards); + + uint256 stv = pool.previewDeposit(assets); + uint256 actualAssets = stv * pool.totalAssets() / pool.totalSupply(); + assertLe(actualAssets, assets); + } + + function testFuzz_WithdrawRounding_RoundsUpForProtocol(uint96 amount, int64 rewards, uint96 assets) public { + vm.assume(amount > 0); + vm.assume(int256(pool.totalAssets()) + rewards >= 0); + + vm.deal(address(this), amount); + pool.depositETH{value: amount}(address(this), address(0)); + + dashboard.mock_simulateRewards(rewards); + + uint256 stv = pool.previewWithdraw(assets); + uint256 actualAssets = stv * pool.totalAssets() / pool.totalSupply(); + assertGe(actualAssets, assets); + } + + function testFuzz_RedeemRounding_RoundsDownForUser(uint96 amount, int64 rewards, uint96 stv) public { + vm.assume(amount > 0); + vm.assume(int256(pool.totalAssets()) + rewards >= 0); + + vm.deal(address(this), amount); + pool.depositETH{value: amount}(address(this), address(0)); + + dashboard.mock_simulateRewards(rewards); + + uint256 assets = pool.previewRedeem(stv); + uint256 actualStv = assets * pool.totalSupply() / pool.totalAssets(); + assertLe(actualStv, stv); + } + + function testFuzz_DepositWithdraw_Consistency(uint96 amount, uint96 assets) public { + vm.assume(amount > 0); + + vm.deal(address(this), amount); + pool.depositETH{value: amount}(address(this), address(0)); + + uint256 stvForDeposit = pool.previewDeposit(assets); + uint256 stvForWithdraw = pool.previewWithdraw(assets); + + assertGe(stvForWithdraw, stvForDeposit); + } +} diff --git a/test/unit/stv-pool/Deposit.test.sol b/test/unit/stv-pool/Deposit.test.sol new file mode 100644 index 0000000..aea1d32 --- /dev/null +++ b/test/unit/stv-pool/Deposit.test.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupStvPool} from "./SetupStvPool.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvPool} from "src/StvPool.sol"; + +contract DepositTest is Test, SetupStvPool { + // Base deposits + + function test_Deposit_EmitsCorrectEvent() public { + uint256 amount = 1 ether; + uint256 expectedStv = pool.previewDeposit(amount); + + vm.expectEmit(true, true, true, true); + emit StvPool.Deposit(userAlice, userAlice, address(0), amount, expectedStv); + + vm.prank(userAlice); + pool.depositETH{value: amount}(userAlice, address(0)); + } + + function test_Deposit_WithReferral_EmitsEventWithReferral() public { + uint256 amount = 1 ether; + uint256 expectedStv = pool.previewDeposit(amount); + address referral = makeAddr("referral"); + + vm.expectEmit(true, true, true, true); + emit StvPool.Deposit(userAlice, userAlice, referral, amount, expectedStv); + + vm.prank(userAlice); + pool.depositETH{value: amount}(userAlice, referral); + } + + function test_Deposit_ToAnotherRecipient_MintsToRecipient() public { + uint256 amount = 1 ether; + uint256 bobBalanceBefore = pool.balanceOf(userBob); + + vm.prank(userAlice); + pool.depositETH{value: amount}(userBob, address(0)); + + assertGt(pool.balanceOf(userBob), bobBalanceBefore); + assertEq(pool.balanceOf(userAlice), 0); + } + + function test_Deposit_CallsDashboardFund() public { + uint256 amount = 1 ether; + + vm.expectCall(address(dashboard), amount, abi.encodeWithSelector(dashboard.fund.selector)); + + vm.prank(userAlice); + pool.depositETH{value: amount}(userAlice, address(0)); + } + + function test_Deposit_FundsTransferredToDashboard() public { + uint256 amount = 1 ether; + address stakingVault = dashboard.stakingVault(); + uint256 vaultBalanceBefore = address(stakingVault).balance; + + vm.prank(userAlice); + pool.depositETH{value: amount}(userAlice, address(0)); + + assertEq(address(stakingVault).balance, vaultBalanceBefore + amount); + } + + // Edge cases + + function test_Deposit_MinimalAmount_OneWei() public { + uint256 amount = 1 wei; + uint256 balanceBefore = pool.balanceOf(userAlice); + + vm.prank(userAlice); + pool.depositETH{value: amount}(userAlice, address(0)); + + assertGt(pool.balanceOf(userAlice), balanceBefore); + } + + function test_Deposit_HugeAmount() public { + uint256 amount = 100_000 ether; + vm.deal(userAlice, amount); + + vm.prank(userAlice); + pool.depositETH{value: amount}(userAlice, address(0)); + + assertGt(pool.balanceOf(userAlice), 0); + } + + function test_Deposit_MultipleSmallDeposits_Accumulate() public { + uint256 depositCount = 10; + uint256 depositAmount = 0.1 ether; + uint256 balanceBefore = pool.balanceOf(userAlice); + + for (uint256 i = 0; i < depositCount; i++) { + vm.prank(userAlice); + pool.depositETH{value: depositAmount}(userAlice, address(0)); + } + + uint256 balanceAfter = pool.balanceOf(userAlice); + assertEq(balanceAfter - balanceBefore, pool.previewDeposit(depositAmount * depositCount)); + } + + // Revert cases + + function test_Deposit_RevertOn_ZeroAmount() public { + vm.prank(userAlice); + vm.expectRevert(StvPool.ZeroDeposit.selector); + pool.depositETH{value: 0}(userAlice, address(0)); + } + + function test_Deposit_RevertOn_ZeroRecipient() public { + vm.prank(userAlice); + vm.expectRevert(StvPool.InvalidRecipient.selector); + pool.depositETH{value: 1 ether}(address(0), address(0)); + } + + // Receive function + + function test_Receive_AutoDeposits() public { + uint256 amount = 1 ether; + uint256 balanceBefore = pool.balanceOf(userAlice); + + vm.prank(userAlice); + (bool success,) = address(pool).call{value: amount}(""); + assertTrue(success); + + assertGt(pool.balanceOf(userAlice), balanceBefore); + } + + function test_Receive_MintsToSender() public { + uint256 amount = 1 ether; + uint256 expectedStv = pool.previewDeposit(amount); + + vm.prank(userAlice); + (bool success,) = address(pool).call{value: amount}(""); + assertTrue(success); + + assertEq(pool.balanceOf(userAlice), expectedStv); + } + + function test_Receive_EmitsEvent() public { + uint256 amount = 1 ether; + uint256 expectedStv = pool.previewDeposit(amount); + + vm.expectEmit(true, true, true, true); + emit StvPool.Deposit(userAlice, userAlice, address(0), amount, expectedStv); + + vm.prank(userAlice); + (bool success,) = address(pool).call{value: amount}(""); + assertTrue(success); + } +} diff --git a/test/unit/stv-pool/DepositsPause.test.sol b/test/unit/stv-pool/DepositsPause.test.sol new file mode 100644 index 0000000..982c9eb --- /dev/null +++ b/test/unit/stv-pool/DepositsPause.test.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupStvPool} from "./SetupStvPool.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; +import {FeaturePausable} from "src/utils/FeaturePausable.sol"; + +contract DepositsPauseTest is Test, SetupStvPool { + address public depositsPauseRoleHolder; + address public depositsResumeRoleHolder; + + bytes32 depositsFeatureId; + + function setUp() public override { + super.setUp(); + + depositsPauseRoleHolder = makeAddr("depositsPauseRoleHolder"); + depositsResumeRoleHolder = makeAddr("depositsResumeRoleHolder"); + + vm.deal(depositsPauseRoleHolder, 10 ether); + vm.deal(depositsResumeRoleHolder, 10 ether); + + vm.startPrank(owner); + pool.grantRole(pool.DEPOSITS_PAUSE_ROLE(), depositsPauseRoleHolder); + pool.grantRole(pool.DEPOSITS_RESUME_ROLE(), depositsResumeRoleHolder); + vm.stopPrank(); + + depositsFeatureId = pool.DEPOSITS_FEATURE(); + vm.deal(address(this), 100 ether); + } + + function test_DepositETH_RevertWhenPaused() public { + vm.prank(depositsPauseRoleHolder); + pool.pauseDeposits(); + + vm.expectRevert(abi.encodeWithSelector(FeaturePausable.FeaturePauseEnforced.selector, depositsFeatureId)); + pool.depositETH{value: 1 ether}(address(this), address(0)); + } + + function test_PauseDeposits_RevertWhenCallerUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), pool.DEPOSITS_PAUSE_ROLE() + ) + ); + pool.pauseDeposits(); + } + + function test_ResumeDeposits_RevertWhenCallerUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), pool.DEPOSITS_RESUME_ROLE() + ) + ); + pool.resumeDeposits(); + } + + function test_ResumeDeposits_AllowsDepositsAfterPause() public { + vm.prank(depositsPauseRoleHolder); + pool.pauseDeposits(); + + vm.prank(depositsResumeRoleHolder); + pool.resumeDeposits(); + + uint256 mintedStv = pool.depositETH{value: 1 ether}(address(this), address(0)); + assertGt(mintedStv, 0); + } +} diff --git a/test/unit/stv-pool/RebalanceUnassignedWithEther.test.sol b/test/unit/stv-pool/RebalanceUnassignedWithEther.test.sol index 304979e..3a162a2 100644 --- a/test/unit/stv-pool/RebalanceUnassignedWithEther.test.sol +++ b/test/unit/stv-pool/RebalanceUnassignedWithEther.test.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Test} from "forge-std/Test.sol"; diff --git a/test/unit/stv-pool/RebalanceUnassignedWithShares.test.sol b/test/unit/stv-pool/RebalanceUnassignedWithShares.test.sol index a3545d6..0ae3a63 100644 --- a/test/unit/stv-pool/RebalanceUnassignedWithShares.test.sol +++ b/test/unit/stv-pool/RebalanceUnassignedWithShares.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupStvPool} from "./SetupStvPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvPool} from "src/StvPool.sol"; contract RebalanceUnassignedWithSharesTest is Test, SetupStvPool { diff --git a/test/unit/stv-pool/SetupStvPool.sol b/test/unit/stv-pool/SetupStvPool.sol index db46687..e64c11c 100644 --- a/test/unit/stv-pool/SetupStvPool.sol +++ b/test/unit/stv-pool/SetupStvPool.sol @@ -1,21 +1,25 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Test} from "forge-std/Test.sol"; import {StvPool} from "src/StvPool.sol"; import {MockDashboard, MockDashboardFactory} from "test/mocks/MockDashboard.sol"; import {MockStETH} from "test/mocks/MockStETH.sol"; +import {MockVaultHub} from "test/mocks/MockVaultHub.sol"; abstract contract SetupStvPool is Test { StvPool public pool; MockDashboard public dashboard; + MockVaultHub public vaultHub; MockStETH public steth; address public owner; address public userAlice; address public userBob; + address public withdrawalQueue; + uint256 public constant initialDeposit = 1 ether; function setUp() public virtual { @@ -23,6 +27,8 @@ abstract contract SetupStvPool is Test { userAlice = makeAddr("userAlice"); userBob = makeAddr("userBob"); + withdrawalQueue = makeAddr("withdrawalQueue"); + // Fund accounts vm.deal(owner, 100 ether); vm.deal(userAlice, 1000 ether); @@ -31,6 +37,7 @@ abstract contract SetupStvPool is Test { // Deploy mocks dashboard = new MockDashboardFactory().createMockDashboard(owner); steth = dashboard.STETH(); + vaultHub = dashboard.VAULT_HUB(); // Fund the dashboard with 1 ETH dashboard.fund{value: initialDeposit}(); @@ -39,8 +46,9 @@ abstract contract SetupStvPool is Test { StvPool poolImpl = new StvPool({ _dashboard: address(dashboard), _allowListEnabled: false, - _withdrawalQueue: address(0), - _distributor: address(0) + _withdrawalQueue: withdrawalQueue, + _distributor: address(0), + _poolType: bytes32("TestPool") }); ERC1967Proxy poolProxy = new ERC1967Proxy(address(poolImpl), ""); diff --git a/test/unit/stv-pool/Stv.test.sol b/test/unit/stv-pool/Stv.test.sol index eb2637d..f6f11ee 100644 --- a/test/unit/stv-pool/Stv.test.sol +++ b/test/unit/stv-pool/Stv.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupStvPool} from "./SetupStvPool.sol"; +import {Test} from "forge-std/Test.sol"; contract StvTest is Test, SetupStvPool { uint8 supplyDecimals = 27; diff --git a/test/unit/stv-pool/Transfers.test.sol b/test/unit/stv-pool/Transfers.test.sol new file mode 100644 index 0000000..bec2553 --- /dev/null +++ b/test/unit/stv-pool/Transfers.test.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupStvPool} from "./SetupStvPool.sol"; +import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvPool} from "src/StvPool.sol"; + +contract TransfersTest is Test, SetupStvPool { + function setUp() public override { + super.setUp(); + + // Setup: deposit ETH for users + vm.prank(userAlice); + pool.depositETH{value: 10 ether}(userAlice, address(0)); + + vm.prank(userBob); + pool.depositETH{value: 5 ether}(userBob, address(0)); + } + + // Basic transfers + + function test_Transfer_BasicTransfer_UpdatesBalances() public { + uint256 amount = 10 ** pool.decimals(); + uint256 aliceBalanceBefore = pool.balanceOf(userAlice); + uint256 bobBalanceBefore = pool.balanceOf(userBob); + + vm.prank(userAlice); + pool.transfer(userBob, amount); + + assertEq(pool.balanceOf(userAlice), aliceBalanceBefore - amount); + assertEq(pool.balanceOf(userBob), bobBalanceBefore + amount); + } + + function test_Transfer_ToSelf_NoChange() public { + uint256 amount = 10 ** pool.decimals(); + uint256 balanceBefore = pool.balanceOf(userAlice); + + vm.prank(userAlice); + pool.transfer(userAlice, amount); + + assertEq(pool.balanceOf(userAlice), balanceBefore); + } + + function test_Transfer_FullBalance_EmptiesSender() public { + uint256 aliceBalance = pool.balanceOf(userAlice); + + vm.prank(userAlice); + pool.transfer(userBob, aliceBalance); + + assertEq(pool.balanceOf(userAlice), 0); + } + + function test_Transfer_EmitsEvent() public { + uint256 amount = 10 ** pool.decimals(); + + vm.expectEmit(true, true, true, true); + emit IERC20.Transfer(userAlice, userBob, amount); + + vm.prank(userAlice); + pool.transfer(userBob, amount); + } + + // TransferFrom + + function test_TransferFrom_WithApproval_Success() public { + uint256 amount = 10 ** pool.decimals(); + + vm.prank(userAlice); + pool.approve(address(this), amount); + + uint256 aliceBalanceBefore = pool.balanceOf(userAlice); + uint256 bobBalanceBefore = pool.balanceOf(userBob); + + pool.transferFrom(userAlice, userBob, amount); + + assertEq(pool.balanceOf(userAlice), aliceBalanceBefore - amount); + assertEq(pool.balanceOf(userBob), bobBalanceBefore + amount); + } + + function test_TransferFrom_WithoutApproval_Reverts() public { + uint256 amount = 10 ** pool.decimals(); + + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientAllowance.selector, address(this), 0, amount) + ); + pool.transferFrom(userAlice, userBob, amount); + } + + function test_TransferFrom_ExceedsApproval_Reverts() public { + uint256 approvedAmount = 1 * 10 ** pool.decimals(); + uint256 transferAmount = 2 * 10 ** pool.decimals(); + + vm.prank(userAlice); + pool.approve(address(this), approvedAmount); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, address(this), approvedAmount, transferAmount + ) + ); + pool.transferFrom(userAlice, userBob, transferAmount); + } + + function test_TransferFrom_UpdatesAllowance() public { + uint256 approvedAmount = 5 * 10 ** pool.decimals(); + uint256 transferAmount = 2 * 10 ** pool.decimals(); + + vm.prank(userAlice); + pool.approve(address(this), approvedAmount); + + pool.transferFrom(userAlice, userBob, transferAmount); + + assertEq(pool.allowance(userAlice, address(this)), approvedAmount - transferAmount); + } + + // Bad debt blocking + + function test_Transfer_BlockedByBadDebt() public { + // Create bad debt + dashboard.mock_increaseLiability(steth.getSharesByPooledEth(pool.totalAssets()) + 1); + + vm.prank(userAlice); + vm.expectRevert(abi.encodeWithSelector(StvPool.VaultInBadDebt.selector)); + pool.transfer(userBob, 1); + } +} diff --git a/test/unit/stv-pool/UnassignedLiability.test.sol b/test/unit/stv-pool/UnassignedLiability.test.sol index bdd55e0..e080c30 100644 --- a/test/unit/stv-pool/UnassignedLiability.test.sol +++ b/test/unit/stv-pool/UnassignedLiability.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupStvPool} from "./SetupStvPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvPool} from "src/StvPool.sol"; contract UnassignedLiabilityTest is Test, SetupStvPool { diff --git a/test/unit/stv-pool/Views.test.sol b/test/unit/stv-pool/Views.test.sol new file mode 100644 index 0000000..4a7ec9c --- /dev/null +++ b/test/unit/stv-pool/Views.test.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupStvPool} from "./SetupStvPool.sol"; +import {Test} from "forge-std/Test.sol"; + +contract ViewsTest is Test, SetupStvPool { + function setUp() public override { + super.setUp(); + + // Setup: deposit some ETH for users + vm.prank(userAlice); + pool.depositETH{value: 5 ether}(userAlice, address(0)); + + vm.prank(userBob); + pool.depositETH{value: 10 ether}(userBob, address(0)); + } + + // totalAssets and totalNominalAssets + + function test_TotalAssets_AfterDeposit_Increases() public { + uint256 totalBefore = pool.totalAssets(); + + pool.depositETH{value: 1 ether}(address(this), address(0)); + + assertEq(pool.totalAssets(), totalBefore + 1 ether); + } + + function test_TotalAssets_AfterRewards_Increases() public { + uint256 totalBefore = pool.totalAssets(); + uint256 rewards = 2 ether; + + dashboard.mock_simulateRewards(int256(rewards)); + + assertEq(pool.totalAssets(), totalBefore + rewards); + } + + function test_TotalAssets_WithUnassignedLiability_Decreases() public { + uint256 totalBefore = pool.totalAssets(); + uint256 liabilityShares = 1000; + uint256 stethAmount = steth.getPooledEthBySharesRoundUp(liabilityShares); + + dashboard.mock_increaseLiability(liabilityShares); + + assertEq(pool.totalAssets(), totalBefore - stethAmount); + } + + function test_NominalAssetsOf_DifferentUsers_ProportionalToBalance() public view { + uint256 aliceBalance = pool.balanceOf(userAlice); + uint256 bobBalance = pool.balanceOf(userBob); + uint256 aliceAssets = pool.nominalAssetsOf(userAlice); + uint256 bobAssets = pool.nominalAssetsOf(userBob); + + // Bob has 2x balance of Alice + assertEq(bobBalance, aliceBalance * 2); + // Bob should have 2x assets of Alice + assertEq(bobAssets, aliceAssets * 2); + } + + // assetsOf and nominalAssetsOf + + function test_AssetsOf_MatchesNominalAssets_WhenNoLiability() public view { + // No liability added, so assets should match nominal assets + assertEq(pool.assetsOf(userAlice), pool.nominalAssetsOf(userAlice)); + assertEq(pool.assetsOf(userBob), pool.nominalAssetsOf(userBob)); + } + + function test_AssetsOf_MultipleUsers_SumEqualsTotal() public view { + uint256 poolAssets = pool.assetsOf(address(pool)); + uint256 aliceAssets = pool.assetsOf(userAlice); + uint256 bobAssets = pool.assetsOf(userBob); + uint256 totalAssets = pool.totalAssets(); + + assertEq(poolAssets + aliceAssets + bobAssets, totalAssets); + } + + // totalLiabilityShares + + function test_TotalLiabilityShares_InitiallyZero() public view { + assertEq(pool.totalLiabilityShares(), 0); + } + + function test_TotalLiabilityShares_AfterLiabilityIncrease() public { + uint256 liabilityShares = 5000; + + dashboard.mock_increaseLiability(liabilityShares); + + assertEq(pool.totalLiabilityShares(), liabilityShares); + } +} diff --git a/test/unit/stv-pool/WithdrawalQueue.test.sol b/test/unit/stv-pool/WithdrawalQueue.test.sol new file mode 100644 index 0000000..98e9032 --- /dev/null +++ b/test/unit/stv-pool/WithdrawalQueue.test.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupStvPool} from "./SetupStvPool.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvPool} from "src/StvPool.sol"; + +contract WithdrawalQueueTest is Test, SetupStvPool { + // transferFromForWithdrawalQueue tests + + function test_TransferFromForWQ_OnlyCallableByWQ() public { + pool.depositETH{value: 1 ether}(userAlice, address(0)); + + vm.prank(userAlice); + vm.expectRevert(StvPool.NotWithdrawalQueue.selector); + pool.transferFromForWithdrawalQueue(userAlice, 1 ether); + } + + function test_TransferFromForWQ_UpdatesBalances() public { + pool.depositETH{value: 1 ether}(userAlice, address(0)); + uint256 stvAmount = pool.balanceOf(userAlice); + + uint256 aliceBalanceBefore = pool.balanceOf(userAlice); + uint256 wqBalanceBefore = pool.balanceOf(withdrawalQueue); + + vm.prank(withdrawalQueue); + pool.transferFromForWithdrawalQueue(userAlice, stvAmount); + + assertEq(pool.balanceOf(userAlice), aliceBalanceBefore - stvAmount); + assertEq(pool.balanceOf(withdrawalQueue), wqBalanceBefore + stvAmount); + } + + function test_TransferFromForWQ_EmitsTransferEvent() public { + pool.depositETH{value: 1 ether}(userAlice, address(0)); + uint256 stvAmount = pool.balanceOf(userAlice); + + vm.expectEmit(true, true, true, true); + emit IERC20.Transfer(userAlice, withdrawalQueue, stvAmount); + + vm.prank(withdrawalQueue); + pool.transferFromForWithdrawalQueue(userAlice, stvAmount); + } + + // burnStvForWithdrawalQueue tests + + function test_BurnStvForWQ_OnlyCallableByWQ() public { + vm.prank(userAlice); + vm.expectRevert(StvPool.NotWithdrawalQueue.selector); + pool.burnStvForWithdrawalQueue(1 ether); + } + + function test_BurnStvForWQ_BurnsStv() public { + // Transfer some stv to WQ first + pool.depositETH{value: 1 ether}(userAlice, address(0)); + uint256 stvAmount = pool.balanceOf(userAlice); + + vm.prank(withdrawalQueue); + pool.transferFromForWithdrawalQueue(userAlice, stvAmount); + + uint256 wqBalanceBefore = pool.balanceOf(withdrawalQueue); + + vm.prank(withdrawalQueue); + pool.burnStvForWithdrawalQueue(stvAmount); + + assertEq(pool.balanceOf(withdrawalQueue), wqBalanceBefore - stvAmount); + } + + function test_BurnStvForWQ_DecreasesTotalSupply() public { + pool.depositETH{value: 1 ether}(userAlice, address(0)); + uint256 stvAmount = pool.balanceOf(userAlice); + + vm.prank(withdrawalQueue); + pool.transferFromForWithdrawalQueue(userAlice, stvAmount); + + uint256 totalSupplyBefore = pool.totalSupply(); + + vm.prank(withdrawalQueue); + pool.burnStvForWithdrawalQueue(stvAmount); + + assertEq(pool.totalSupply(), totalSupplyBefore - stvAmount); + } + + function test_BurnStvForWQ_RevertOn_NoBadDebt() public { + // Setup: deposit and transfer to WQ + pool.depositETH{value: 10 ether}(userAlice, address(0)); + uint256 stvAmount = pool.balanceOf(userAlice); + + vm.prank(withdrawalQueue); + pool.transferFromForWithdrawalQueue(userAlice, stvAmount); + + // Create bad debt + dashboard.mock_increaseLiability(steth.getSharesByPooledEth(pool.totalAssets()) + 1); + + vm.prank(withdrawalQueue); + vm.expectRevert(StvPool.VaultInBadDebt.selector); + pool.burnStvForWithdrawalQueue(stvAmount); + } + + function test_BurnStvForWQ_RevertOn_NoUnassignedLiability() public { + // Setup: deposit and transfer to WQ + pool.depositETH{value: 1 ether}(userAlice, address(0)); + uint256 stvAmount = pool.balanceOf(userAlice); + + vm.prank(withdrawalQueue); + pool.transferFromForWithdrawalQueue(userAlice, stvAmount); + + // Create unassigned liability + dashboard.mock_increaseLiability(100); + + vm.prank(withdrawalQueue); + vm.expectRevert(StvPool.UnassignedLiabilityOnVault.selector); + pool.burnStvForWithdrawalQueue(stvAmount); + } +} diff --git a/test/unit/stv-steth-pool/Assets.test.sol b/test/unit/stv-steth-pool/Assets.test.sol index 8f5a46d..08fd15c 100644 --- a/test/unit/stv-steth-pool/Assets.test.sol +++ b/test/unit/stv-steth-pool/Assets.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; contract AssetsTest is Test, SetupStvStETHPool { uint8 supplyDecimals = 27; diff --git a/test/unit/stv-steth-pool/BurningStethShares.test.sol b/test/unit/stv-steth-pool/BurningStethShares.test.sol index 2840e74..41f67d0 100644 --- a/test/unit/stv-steth-pool/BurningStethShares.test.sol +++ b/test/unit/stv-steth-pool/BurningStethShares.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; contract BurningStethSharesTest is Test, SetupStvStETHPool { diff --git a/test/unit/stv-steth-pool/BurningWsteth.test.sol b/test/unit/stv-steth-pool/BurningWsteth.test.sol index f377031..ec1919e 100644 --- a/test/unit/stv-steth-pool/BurningWsteth.test.sol +++ b/test/unit/stv-steth-pool/BurningWsteth.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; contract BurningWstethTest is Test, SetupStvStETHPool { @@ -336,4 +336,42 @@ contract BurningWstethTest is Test, SetupStvStETHPool { assertEq(pool.mintedStethSharesOf(address(this)), wstethToMint - expectedSharesToBurn); } + + // Rounding tests + + function test_BurnWsteth_AccountsForRoundingLoss() public { + // Set share rate to create rounding loss + steth.mock_setTotalPooled(1001 * 10 ** 18, 1000 * 10 ** 18); // share rate = 1.001 + + uint256 wstethToBurn = 123456789; // Odd number to trigger rounding + uint256 mintedBefore = pool.mintedStethSharesOf(address(this)); + + // Calculate what actually gets burned (simulating unwrap rounding) + uint256 unwrappedSteth = steth.getPooledEthByShares(wstethToBurn); + uint256 unwrappedStethShares = steth.getSharesByPooledEth(unwrappedSteth); + + pool.burnWsteth(wstethToBurn); + + // Should decrease by unwrapped shares, accounting for rounding loss + assertEq(pool.mintedStethSharesOf(address(this)), mintedBefore - unwrappedStethShares); + assertLe(unwrappedStethShares, wstethToBurn); // Loss due to rounding + } + + function test_BurnWsteth_DustAccumulatesOnWsteth() public { + // Set share rate to create dust + steth.mock_setTotalPooled(333 * 10 ** 18, 100 * 10 ** 18); // share rate = 3.33 + + uint256 wstethToBurn = 1000; + + // Unwrap simulation: wsteth -> steth -> shares + uint256 unwrappedSteth = steth.getPooledEthByShares(wstethToBurn); + uint256 unwrappedStethShares = steth.getSharesByPooledEth(unwrappedSteth); + + pool.burnWsteth(wstethToBurn); + + // Pool should not hold wsteth, dust goes elsewhere in the process + assertEq(wsteth.balanceOf(address(pool)), 0); + // Verify rounding occurred (shares decreased by less than or equal to wsteth burned) + assertLe(unwrappedStethShares, wstethToBurn); + } } diff --git a/test/unit/stv-steth-pool/DepositAndMint.test.sol b/test/unit/stv-steth-pool/DepositAndMint.test.sol index bc2cf3f..ba88245 100644 --- a/test/unit/stv-steth-pool/DepositAndMint.test.sol +++ b/test/unit/stv-steth-pool/DepositAndMint.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; contract DepositAndMintTest is Test, SetupStvStETHPool { @@ -10,7 +10,7 @@ contract DepositAndMintTest is Test, SetupStvStETHPool { function test_DepositAndMintShares_DepositIncreasesStvBalance() public { uint256 balanceBefore = pool.balanceOf(address(this)); - pool.depositETHAndMintStethShares{value: 1 ether}(address(this), address(0), 0); + pool.depositETHAndMintStethShares{value: 1 ether}(address(0), 0); uint256 balanceAfter = pool.balanceOf(address(this)); assertGt(balanceAfter, balanceBefore, "stv balance increased"); @@ -18,7 +18,7 @@ contract DepositAndMintTest is Test, SetupStvStETHPool { function test_DepositAndMintShares_DepositWithMintingIncreasesWstethBalance() public { uint256 balanceBefore = steth.balanceOf(address(this)); - pool.depositETHAndMintStethShares{value: 2 ether}(address(this), address(0), 1e18); + pool.depositETHAndMintStethShares{value: 2 ether}(address(0), 1e18); uint256 balanceAfter = steth.balanceOf(address(this)); assertGt(balanceAfter, balanceBefore, "stETH balance increased"); @@ -31,13 +31,13 @@ contract DepositAndMintTest is Test, SetupStvStETHPool { uint256 balanceBefore = pool.balanceOf(address(this)); uint256 userSharesBefore = pool.mintedStethSharesOf(address(this)); - pool.depositETHAndMintStethShares{value: depositAmount}(address(this), address(0), 0); + pool.depositETHAndMintStethShares{value: depositAmount}(address(0), 0); assertEq(pool.balanceOf(address(this)), balanceBefore + expectedStv, "stv minted"); assertEq(pool.mintedStethSharesOf(address(this)), userSharesBefore, "no shares minted for user"); } - function test_DepositAndMintShares_SelfMinting() public { + function test_DepositAndMintShares_Minting() public { uint256 depositAmount = 10 ether; uint256 expectedStv = pool.previewDeposit(depositAmount); uint256 maxMintable = pool.remainingMintingCapacitySharesOf(address(this), depositAmount); @@ -46,46 +46,27 @@ contract DepositAndMintTest is Test, SetupStvStETHPool { uint256 userSharesBefore = pool.mintedStethSharesOf(address(this)); uint256 balanceBefore = pool.balanceOf(address(this)); - pool.depositETHAndMintStethShares{value: depositAmount}(address(this), address(0), stethSharesToMint); + pool.depositETHAndMintStethShares{value: depositAmount}(address(0), stethSharesToMint); assertGt(stethSharesToMint, 0); assertEq(pool.balanceOf(address(this)), balanceBefore + expectedStv, "stv minted"); assertEq(pool.mintedStethSharesOf(address(this)), userSharesBefore + stethSharesToMint, "user shares minted"); } - function test_DepositAndMintShares_OnBehalfMinting() public { - uint256 depositAmount = 8 ether; - uint256 expectedStv = pool.previewDeposit(depositAmount); - uint256 maxMintable = pool.remainingMintingCapacitySharesOf(userBob, depositAmount); - uint256 stethSharesToMint = maxMintable / 2; - - uint256 userSharesBefore = pool.mintedStethSharesOf(userBob); - uint256 recipientBalanceBefore = pool.balanceOf(userBob); - - vm.prank(userAlice); - pool.depositETHAndMintStethShares{value: depositAmount}(userBob, address(0), stethSharesToMint); - - assertGt(stethSharesToMint, 0); - assertEq(pool.balanceOf(userBob), recipientBalanceBefore + expectedStv, "stv minted for recipient"); - assertEq(pool.mintedStethSharesOf(userBob), userSharesBefore + stethSharesToMint, "recipient shares minted"); - assertEq(pool.mintedStethSharesOf(userAlice), 0, "sender shares unchanged"); - } - - function test_DepositAndMintShares_RevertWhenInsufficientStv() public { + function test_DepositAndMintShares_RevertWhenInsufficientMintingCapacity() public { uint256 depositAmount = 5 ether; uint256 mintedStv = pool.previewDeposit(depositAmount); uint256 sharesToMint = pool.calcStethSharesToMintForStv(mintedStv) + 1; assertGt(sharesToMint, 0); - vm.prank(userAlice); - vm.expectRevert(StvStETHPool.InsufficientStv.selector); - pool.depositETHAndMintStethShares{value: depositAmount}(userBob, address(0), sharesToMint); + vm.expectRevert(StvStETHPool.InsufficientMintingCapacity.selector); + pool.depositETHAndMintStethShares{value: depositAmount}(address(0), sharesToMint); } - function test_DepositAndMintShares_MintingForPreviousDepositedAssets_PassForDirectDeposits() public { + function test_DepositAndMintShares_MintingForPreviousDepositedAssets() public { // 1. Deposit without minting uint256 firstDeposit = 5 ether; - pool.depositETHAndMintStethShares{value: firstDeposit}(address(this), address(0), 0); + pool.depositETHAndMintStethShares{value: firstDeposit}(address(0), 0); // 2. Deposit again and mint steth up to total deposited assets in one tx // Should PASS for since all previously deposited assets are still unlocked @@ -100,7 +81,7 @@ contract DepositAndMintTest is Test, SetupStvStETHPool { uint256 expectedStv = pool.previewDeposit(secondDeposit); uint256 balanceBefore = pool.balanceOf(address(this)); - pool.depositETHAndMintStethShares{value: secondDeposit}(address(this), address(0), mintable); + pool.depositETHAndMintStethShares{value: secondDeposit}(address(0), mintable); assertEq(pool.balanceOf(address(this)), balanceBefore + expectedStv, "stv minted"); assertEq(pool.mintedStethSharesOf(address(this)), mintable, "shares minted"); @@ -109,30 +90,11 @@ contract DepositAndMintTest is Test, SetupStvStETHPool { assertEq(remainingAfter, 0, "residual capacity too high"); } - function test_DepositAndMintShares_MintingForPreviousDepositedAssets_RevertsForDepositsToSomeoneElse() public { - // 1. Deposit without minting - uint256 firstDeposit = 5 ether; - pool.depositETHAndMintStethShares{value: firstDeposit}(userBob, address(0), 0); - - // 2. Deposit again and mint steth up to total deposited assets in one tx - // Should REVERT for deposits to another address since the debt in the tx is not collateralized by assets - uint256 secondDeposit = 7 ether; - uint256 mintable = pool.remainingMintingCapacitySharesOf(userBob, secondDeposit); - assertEq( - mintable, - pool.calcStethSharesToMintForAssets(5 ether + 7 ether), - "mintable should match total deposited assets" - ); - - vm.expectRevert(StvStETHPool.InsufficientStv.selector); - pool.depositETHAndMintStethShares{value: secondDeposit}(userBob, address(0), mintable); - } - // Wsteth function test_DepositAndMintWsteth_DepositIncreasesStvBalance() public { uint256 balanceBefore = pool.balanceOf(address(this)); - pool.depositETHAndMintWsteth{value: 1 ether}(address(this), address(0), 0); + pool.depositETHAndMintWsteth{value: 1 ether}(address(0), 0); uint256 balanceAfter = pool.balanceOf(address(this)); assertGt(balanceAfter, balanceBefore, "stv balance increased"); @@ -140,7 +102,7 @@ contract DepositAndMintTest is Test, SetupStvStETHPool { function test_DepositAndMintWsteth_DepositWithMintingIncreasesWstethBalance() public { uint256 balanceBefore = wsteth.balanceOf(address(this)); - pool.depositETHAndMintWsteth{value: 2 ether}(address(this), address(0), 1e18); + pool.depositETHAndMintWsteth{value: 2 ether}(address(0), 1e18); uint256 balanceAfter = wsteth.balanceOf(address(this)); assertGt(balanceAfter, balanceBefore, "wstETH balance increased"); @@ -153,13 +115,13 @@ contract DepositAndMintTest is Test, SetupStvStETHPool { uint256 balanceBefore = pool.balanceOf(address(this)); uint256 wstethBefore = wsteth.balanceOf(address(this)); - pool.depositETHAndMintWsteth{value: depositAmount}(address(this), address(0), 0); + pool.depositETHAndMintWsteth{value: depositAmount}(address(0), 0); assertEq(pool.balanceOf(address(this)), balanceBefore + expectedStv, "stv minted"); assertEq(wsteth.balanceOf(address(this)), wstethBefore, "no wstETH minted"); } - function test_DepositAndMintWsteth_SelfMinting() public { + function test_DepositAndMintWsteth_Minting() public { uint256 depositAmount = 9 ether; uint256 expectedStv = pool.previewDeposit(depositAmount); uint256 maxMintable = pool.remainingMintingCapacitySharesOf(address(this), depositAmount); @@ -169,7 +131,7 @@ contract DepositAndMintTest is Test, SetupStvStETHPool { uint256 balanceBefore = pool.balanceOf(address(this)); uint256 wstethBefore = wsteth.balanceOf(address(this)); - pool.depositETHAndMintWsteth{value: depositAmount}(address(this), address(0), wstethToMint); + pool.depositETHAndMintWsteth{value: depositAmount}(address(0), wstethToMint); assertGt(wstethToMint, 0); assertEq(pool.balanceOf(address(this)), balanceBefore + expectedStv, "stv minted"); @@ -177,43 +139,20 @@ contract DepositAndMintTest is Test, SetupStvStETHPool { assertEq(pool.mintedStethSharesOf(address(this)), userSharesBefore + wstethToMint, "user shares minted"); } - function test_DepositAndMintWsteth_OnBehalfMinting() public { - uint256 depositAmount = 6 ether; - uint256 expectedStv = pool.previewDeposit(depositAmount); - uint256 maxMintable = pool.remainingMintingCapacitySharesOf(userBob, depositAmount); - uint256 wstethToMint = maxMintable / 2; - - uint256 userSharesBefore = pool.mintedStethSharesOf(userBob); - uint256 wstethBefore = wsteth.balanceOf(userBob); - uint256 balanceBefore = pool.balanceOf(userBob); - - vm.expectCall(address(dashboard), abi.encodeWithSelector(dashboard.mintWstETH.selector, userBob, wstethToMint)); - - vm.prank(userAlice); - pool.depositETHAndMintWsteth{value: depositAmount}(userBob, address(0), wstethToMint); - - assertGt(wstethToMint, 0); - assertEq(pool.balanceOf(userBob), balanceBefore + expectedStv, "stv minted for recipient"); - assertEq(wsteth.balanceOf(userBob), wstethBefore + wstethToMint, "wstETH minted for recipient"); - assertEq(pool.mintedStethSharesOf(userBob), userSharesBefore + wstethToMint, "recipient shares updated"); - assertEq(pool.mintedStethSharesOf(userAlice), 0, "sender shares unchanged"); - } - - function test_DepositAndMintWsteth_RevertWhenInsufficientStv() public { + function test_DepositAndMintWsteth_RevertWhenInsufficientMintingCapacity() public { uint256 depositAmount = 4 ether; uint256 mintedStv = pool.previewDeposit(depositAmount); uint256 wstethToMint = pool.calcStethSharesToMintForStv(mintedStv) + 1; assertGt(wstethToMint, 0); - vm.prank(userAlice); - vm.expectRevert(StvStETHPool.InsufficientStv.selector); - pool.depositETHAndMintWsteth{value: depositAmount}(userBob, address(0), wstethToMint); + vm.expectRevert(StvStETHPool.InsufficientMintingCapacity.selector); + pool.depositETHAndMintWsteth{value: depositAmount}(address(0), wstethToMint); } function test_DepositAndMintWsteth_MintingForPreviousDepositedAssets_PassForDirectDeposits() public { // 1. Deposit without minting uint256 firstDeposit = 5 ether; - pool.depositETHAndMintWsteth{value: firstDeposit}(address(this), address(0), 0); + pool.depositETHAndMintWsteth{value: firstDeposit}(address(0), 0); // 2. Deposit again and mint wsteth up to total deposited assets in one tx // Should PASS for since all previously deposited assets are still unlocked @@ -228,7 +167,7 @@ contract DepositAndMintTest is Test, SetupStvStETHPool { uint256 expectedStv = pool.previewDeposit(secondDeposit); uint256 balanceBefore = pool.balanceOf(address(this)); - pool.depositETHAndMintWsteth{value: secondDeposit}(address(this), address(0), mintable); + pool.depositETHAndMintWsteth{value: secondDeposit}(address(0), mintable); assertEq(pool.balanceOf(address(this)), balanceBefore + expectedStv, "stv minted"); assertEq(pool.mintedStethSharesOf(address(this)), mintable, "shares minted"); @@ -236,23 +175,4 @@ contract DepositAndMintTest is Test, SetupStvStETHPool { uint256 remainingAfter = pool.remainingMintingCapacitySharesOf(address(this), 0); assertEq(remainingAfter, 0, "residual capacity too high"); } - - function test_DepositAndMintWsteth_MintingForPreviousDepositedAssets_RevertsForDepositsToSomeoneElse() public { - // 1. Deposit without minting - uint256 firstDeposit = 5 ether; - pool.depositETHAndMintWsteth{value: firstDeposit}(userBob, address(0), 0); - - // 2. Deposit again and mint wsteth up to total deposited assets in one tx - // Should REVERT for deposits to another address since the debt in the tx is not collateralized by assets - uint256 secondDeposit = 7 ether; - uint256 mintable = pool.remainingMintingCapacitySharesOf(userBob, secondDeposit); - assertEq( - mintable, - pool.calcStethSharesToMintForAssets(5 ether + 7 ether), - "mintable should match total deposited assets" - ); - - vm.expectRevert(StvStETHPool.InsufficientStv.selector); - pool.depositETHAndMintWsteth{value: secondDeposit}(userBob, address(0), mintable); - } } diff --git a/test/unit/stv-steth-pool/ExceedingMintedSteth.test.sol b/test/unit/stv-steth-pool/ExceedingMintedSteth.test.sol index 2a0e343..9679232 100644 --- a/test/unit/stv-steth-pool/ExceedingMintedSteth.test.sol +++ b/test/unit/stv-steth-pool/ExceedingMintedSteth.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; contract ExceedingMintedStethTest is Test, SetupStvStETHPool { uint8 supplyDecimals = 27; @@ -27,4 +27,93 @@ contract ExceedingMintedStethTest is Test, SetupStvStETHPool { dashboard.rebalanceVaultWithShares(sharesToRebalance); assertEq(pool.totalExceedingMintedStethShares(), sharesToRebalance); } + + function test_ExceedingShares_PartialRebalance() public { + uint256 partialShares = initialMintedStethShares / 2; + + dashboard.rebalanceVaultWithShares(partialShares); + assertEq(pool.totalExceedingMintedStethShares(), partialShares); + } + + function test_ExceedingShares_MultipleRebalances() public { + uint256 firstRebalance = initialMintedStethShares / 3; + uint256 secondRebalance = initialMintedStethShares / 3; + + dashboard.rebalanceVaultWithShares(firstRebalance); + assertEq(pool.totalExceedingMintedStethShares(), firstRebalance); + + dashboard.rebalanceVaultWithShares(secondRebalance); + assertEq(pool.totalExceedingMintedStethShares(), firstRebalance + secondRebalance); + } + + function test_ExceedingShares_NeverExceedsTotalMinted() public { + uint256 totalMinted = pool.totalMintedStethShares(); + + dashboard.rebalanceVaultWithShares(totalMinted); + assertLe(pool.totalExceedingMintedStethShares(), totalMinted); + } + + function test_ExceedingSteth_ZeroInitially() public view { + assertEq(pool.totalExceedingMintedSteth(), 0); + } + + function test_ExceedingSteth_ConvertsFromShares() public { + uint256 sharesToRebalance = initialMintedStethShares; + + dashboard.rebalanceVaultWithShares(sharesToRebalance); + + uint256 expectedSteth = steth.getPooledEthByShares(sharesToRebalance); + assertEq(pool.totalExceedingMintedSteth(), expectedSteth); + } + + function test_ExceedingSteth_AffectsTotalAssets() public { + uint256 assetsBefore = pool.totalAssets(); + + uint256 sharesToRebalance = initialMintedStethShares; + dashboard.rebalanceVaultWithShares(sharesToRebalance); + + // Exceeding steth compensates for reduced vault balance + assertEq(pool.totalAssets(), assetsBefore); + } + + function test_TotalAssets_IncreasesWithExceeding() public { + // Mint additional shares to create difference + pool.depositETH{value: 1 ether}(address(this), address(0)); + pool.mintStethShares(initialMintedStethShares / 2); + + uint256 assetsBefore = pool.totalAssets(); + + dashboard.rebalanceVaultWithShares(initialMintedStethShares / 2); + + // After rebalance, exceeding compensates vault balance reduction + assertEq(pool.totalAssets(), assetsBefore); + } + + // Fuzz test for mutually exclusive exceeding vs unassigned liability + + function testFuzz_ExceedingVsUnassigned(uint128 assets, uint128 transferredLiability, uint128 sharesToRebalance) + public + { + if (assets > 0) { + vm.deal(address(this), uint256(assets)); + pool.depositETH{value: uint256(assets)}(address(this), address(0)); + } + + uint256 maxSharesToMintOnPool = pool.remainingMintingCapacitySharesOf(address(this), 0); + pool.mintStethShares(maxSharesToMintOnPool); + + vm.assume(sharesToRebalance <= pool.totalLiabilityShares()); + + dashboard.rebalanceVaultWithShares(sharesToRebalance); + dashboard.mock_increaseLiability(transferredLiability); + + uint256 exceeding = pool.totalExceedingMintedStethShares(); + uint256 unassigned = pool.totalUnassignedLiabilityShares(); + + // Only one can be non-zero at a time + assertTrue( + (exceeding > 0 && unassigned == 0) || (exceeding == 0 && unassigned > 0) + || (exceeding == 0 && unassigned == 0) + ); + } } diff --git a/test/unit/stv-steth-pool/ForceRebalance.test.sol b/test/unit/stv-steth-pool/ForceRebalance.test.sol index 05f52c0..15193d1 100644 --- a/test/unit/stv-steth-pool/ForceRebalance.test.sol +++ b/test/unit/stv-steth-pool/ForceRebalance.test.sol @@ -1,10 +1,11 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; - -import {Test} from "forge-std/Test.sol"; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +pragma solidity 0.8.30; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvPool} from "src/StvPool.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; import {MockVaultHub} from "test/mocks/MockVaultHub.sol"; @@ -45,9 +46,7 @@ contract ForceRebalanceTest is Test, SetupStvStETHPool { // liability / (assets - x) = (1 - threshold) // x = assets - liability / (1 - threshold) lossToBreachThreshold = - assets - - (mintedSteth * pool.TOTAL_BASIS_POINTS()) / - (pool.TOTAL_BASIS_POINTS() - threshold); + assets - (mintedSteth * pool.TOTAL_BASIS_POINTS()) / (pool.TOTAL_BASIS_POINTS() - threshold); // scale loss to user's share of the pool lossToBreachThreshold = (lossToBreachThreshold * pool.totalAssets()) / assets; @@ -56,14 +55,14 @@ contract ForceRebalanceTest is Test, SetupStvStETHPool { function test_ForceRebalance_RevertWhenReportStale() public { dashboard.VAULT_HUB().mock_setReportFreshness(dashboard.stakingVault(), false); - vm.expectRevert(StvStETHPool.VaultReportStale.selector); + vm.expectRevert(StvPool.VaultReportStale.selector); pool.forceRebalance(userAlice); } function test_ForceRebalance_RevertWhenThresholdNotBreached() public { _mintMaxStethShares(userAlice); - vm.expectRevert(StvStETHPool.NothingToRebalance.selector); + vm.expectRevert(StvStETHPool.ZeroArgument.selector); pool.forceRebalance(userAlice); } @@ -71,7 +70,7 @@ contract ForceRebalanceTest is Test, SetupStvStETHPool { _mintMaxStethShares(userAlice); _simulateLoss(_calcLossToBreachThreshold(userAlice) - 1); - vm.expectRevert(StvStETHPool.NothingToRebalance.selector); + vm.expectRevert(StvStETHPool.ZeroArgument.selector); pool.forceRebalance(userAlice); } @@ -150,13 +149,163 @@ contract ForceRebalanceTest is Test, SetupStvStETHPool { } function test_ForceRebalanceAndSocializeLoss_DoNotRevertIfAccountIsUndercollateralized() public { + // Enable loss socialization + vm.prank(owner); + pool.setMaxLossSocializationBP(100_00); // 100% + _mintMaxStethShares(userAlice); + _simulateLoss(4 ether); - uint256 totalValue = dashboard.maxLockableValue(); - assertGt(totalValue, 1 ether, "unexpected vault value"); - _simulateLoss(totalValue - 1 ether); + vm.prank(socializer); + pool.forceRebalanceAndSocializeLoss(userAlice); + } + + function test_ForceRebalance_PermissionlessExecution() public { + _mintMaxStethShares(userAlice); + _simulateLoss(_calcLossToBreachThreshold(userAlice)); + + // Anyone can call forceRebalance + vm.prank(userBob); + pool.forceRebalance(userAlice); + } + + function test_ForceRebalance_UpdatesMintedShares() public { + _mintMaxStethShares(userAlice); + uint256 mintedBefore = pool.mintedStethSharesOf(userAlice); + _simulateLoss(_calcLossToBreachThreshold(userAlice)); + + pool.forceRebalance(userAlice); + + uint256 mintedAfter = pool.mintedStethSharesOf(userAlice); + assertLt(mintedAfter, mintedBefore); + } + + function test_ForceRebalance_BurnsCorrectStv() public { + _mintMaxStethShares(userAlice); + uint256 balanceBefore = pool.balanceOf(userAlice); + _simulateLoss(_calcLossToBreachThreshold(userAlice)); + + uint256 stvBurned = pool.forceRebalance(userAlice); + + assertEq(pool.balanceOf(userAlice), balanceBefore - stvBurned); + assertGt(stvBurned, 0); + } + + function test_ForceRebalance_EmitsCorrectEvent() public { + _mintMaxStethShares(userAlice); + _simulateLoss(_calcLossToBreachThreshold(userAlice)); + + (uint256 expectedShares, uint256 expectedStv,) = pool.previewForceRebalance(userAlice); + + vm.expectEmit(true, true, true, true); + emit StvStETHPool.StethSharesRebalanced(userAlice, expectedShares, expectedStv); + + pool.forceRebalance(userAlice); + } + + function test_ForceRebalance_RestoresHealthStatus() public { + _mintMaxStethShares(userAlice); + _simulateLoss(_calcLossToBreachThreshold(userAlice)); + + assertFalse(pool.isHealthyOf(userAlice)); + + pool.forceRebalance(userAlice); + + assertTrue(pool.isHealthyOf(userAlice)); + } + + function test_ForceRebalance_WithMinimalThresholdBreach() public { + _mintMaxStethShares(userAlice); + uint256 loss = _calcLossToBreachThreshold(userAlice); + _simulateLoss(loss); + + uint256 stvBurned = pool.forceRebalance(userAlice); + assertGt(stvBurned, 0); + } + + function test_ForceRebalance_MultipleTimesForSameUser() public { + _mintMaxStethShares(userAlice); + _simulateLoss(_calcLossToBreachThreshold(userAlice)); + + pool.forceRebalance(userAlice); + assertTrue(pool.isHealthyOf(userAlice)); + + // Simulate another loss + _simulateLoss(_calcLossToBreachThreshold(userAlice)); + assertFalse(pool.isHealthyOf(userAlice)); + + pool.forceRebalance(userAlice); + assertTrue(pool.isHealthyOf(userAlice)); + } + + function test_ForceRebalance_DifferentUsers_Independent() public { + vm.prank(userBob); + pool.depositETH{value: DEPOSIT_AMOUNT}(userBob, address(0)); + + _mintMaxStethShares(userAlice); + _mintMaxStethShares(userBob); + + _simulateLoss(_calcLossToBreachThreshold(userAlice)); + + assertFalse(pool.isHealthyOf(userAlice)); + assertFalse(pool.isHealthyOf(userBob)); + + uint256 bobMintedBefore = pool.mintedStethSharesOf(userBob); + + pool.forceRebalance(userAlice); + + assertTrue(pool.isHealthyOf(userAlice)); + assertFalse(pool.isHealthyOf(userBob)); + assertEq(pool.mintedStethSharesOf(userBob), bobMintedBefore); + } + + function test_SocializeLoss_OnlyByRole() public { + _mintMaxStethShares(userAlice); + _simulateLoss(4 ether); + + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), pool.LOSS_SOCIALIZER_ROLE() + ) + ); + pool.forceRebalanceAndSocializeLoss(userAlice); + } + + function test_SocializeLoss_RevertWhenHealthy() public { + _mintMaxStethShares(userAlice); + + vm.prank(socializer); + vm.expectRevert(StvStETHPool.CollateralizedAccount.selector); + pool.forceRebalanceAndSocializeLoss(userAlice); + } + + function test_SocializeLoss_BurnsAvailableStv() public { + // Enable loss socialization + vm.prank(owner); + pool.setMaxLossSocializationBP(100_00); // 100% + + _mintMaxStethShares(userAlice); + uint256 balanceBefore = pool.balanceOf(userAlice); + _simulateLoss(4 ether); + + vm.prank(socializer); + uint256 stvBurned = pool.forceRebalanceAndSocializeLoss(userAlice); + + assertEq(stvBurned, balanceBefore); + assertEq(pool.balanceOf(userAlice), 0); + } + + function test_SocializeLoss_ClearsAllMintedShares() public { + // Enable loss socialization + vm.prank(owner); + pool.setMaxLossSocializationBP(100_00); // 100% + + _mintMaxStethShares(userAlice); + _simulateLoss(4 ether); vm.prank(socializer); pool.forceRebalanceAndSocializeLoss(userAlice); + + assertEq(pool.mintedStethSharesOf(userAlice), 0); } } diff --git a/test/unit/stv-steth-pool/HealthCheck.test.sol b/test/unit/stv-steth-pool/HealthCheck.test.sol new file mode 100644 index 0000000..7c3329a --- /dev/null +++ b/test/unit/stv-steth-pool/HealthCheck.test.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Test} from "forge-std/Test.sol"; + +contract HealthCheckTest is Test, SetupStvStETHPool { + uint256 ethToDeposit = 10 ether; + + function setUp() public override { + super.setUp(); + pool.depositETH{value: ethToDeposit}(address(this), address(0)); + } + + // isHealthyOf tests + + function test_IsHealthy_TrueWithNoMinting() public view { + assertEq(pool.mintedStethSharesOf(address(this)), 0); + assertTrue(pool.isHealthyOf(address(this))); + } + + function test_IsHealthy_TrueWithinThreshold() public { + // Mint some shares but not enough to breach threshold + uint256 capacity = pool.remainingMintingCapacitySharesOf(address(this), 0); + uint256 sharesToMint = capacity / 2; + + pool.mintStethShares(sharesToMint); + + assertTrue(pool.isHealthyOf(address(this))); + } + + function test_IsHealthy_FalseWhenThresholdBreached() public { + // Mint max capacity + uint256 capacity = pool.remainingMintingCapacitySharesOf(address(this), 0); + pool.mintStethShares(capacity); + + // Simulate loss to breach threshold + uint256 lossToBreachThreshold = _calcLossToBreachThreshold(address(this)); + dashboard.mock_simulateRewards(-int256(lossToBreachThreshold)); + + assertFalse(pool.isHealthyOf(address(this))); + } + + function test_IsHealthy_AfterRewards_Improves() public { + // Mint max capacity + uint256 capacity = pool.remainingMintingCapacitySharesOf(address(this), 0); + pool.mintStethShares(capacity); + + // Simulate loss to breach threshold + uint256 lossToBreachThreshold = _calcLossToBreachThreshold(address(this)); + dashboard.mock_simulateRewards(-int256(lossToBreachThreshold)); + + assertFalse(pool.isHealthyOf(address(this))); + + // New rewards restore health + dashboard.mock_simulateRewards(int256(lossToBreachThreshold)); + + assertTrue(pool.isHealthyOf(address(this))); + } + + function test_IsHealthy_EdgeCase_ExactlyAtThreshold() public { + // Mint max capacity + uint256 capacity = pool.remainingMintingCapacitySharesOf(address(this), 0); + pool.mintStethShares(capacity); + + // Simulate loss exactly at threshold (just before breach) + uint256 lossToBreachThreshold = _calcLossToBreachThreshold(address(this)); + dashboard.mock_simulateRewards(-int256(lossToBreachThreshold - 1)); + + assertTrue(pool.isHealthyOf(address(this))); + + // One more wei of loss breaches + dashboard.mock_simulateRewards(-1); + + assertFalse(pool.isHealthyOf(address(this))); + } + + // Helper functions + + function _calcLossToBreachThreshold(address _account) internal view returns (uint256 lossToBreachThreshold) { + uint256 mintedSteth = steth.getPooledEthByShares(pool.mintedStethSharesOf(_account)); + uint256 assets = pool.assetsOf(_account); + uint256 threshold = pool.forcedRebalanceThresholdBP(); + + // liability / (assets - x) = (1 - threshold) + // x = assets - liability / (1 - threshold) + lossToBreachThreshold = + assets - (mintedSteth * pool.TOTAL_BASIS_POINTS()) / (pool.TOTAL_BASIS_POINTS() - threshold); + + // scale loss to user's share of the pool + lossToBreachThreshold = (lossToBreachThreshold * pool.totalAssets()) / assets; + } +} diff --git a/test/unit/stv-steth-pool/LockCalculations.test.sol b/test/unit/stv-steth-pool/LockCalculations.test.sol index 2455c1f..d2a4ef8 100644 --- a/test/unit/stv-steth-pool/LockCalculations.test.sol +++ b/test/unit/stv-steth-pool/LockCalculations.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; diff --git a/test/unit/stv-steth-pool/LossSocializationLimiter.test.sol b/test/unit/stv-steth-pool/LossSocializationLimiter.test.sol new file mode 100644 index 0000000..74c70a5 --- /dev/null +++ b/test/unit/stv-steth-pool/LossSocializationLimiter.test.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; + +contract LossSocializationLimiterTest is Test, SetupStvStETHPool { + function test_MaxLossSocializationBP_DefaultsToZero() public view { + assertEq(pool.maxLossSocializationBP(), 0); + } + + function test_SetMaxLossSocializationBP_UpdatesStoredValue() public { + uint16 newLimit = 2_500; + + vm.prank(owner); + pool.setMaxLossSocializationBP(newLimit); + + assertEq(pool.maxLossSocializationBP(), newLimit); + } + + function test_SetMaxLossSocializationBP_EmitsEvent() public { + uint16 newLimit = 1_000; + + vm.expectEmit(false, false, false, true, address(pool)); + emit StvStETHPool.MaxLossSocializationUpdated(newLimit); + + vm.prank(owner); + pool.setMaxLossSocializationBP(newLimit); + } + + function test_SetMaxLossSocializationBP_RevertWhenCallerUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), pool.DEFAULT_ADMIN_ROLE() + ) + ); + pool.setMaxLossSocializationBP(100); + } + + function test_SetMaxLossSocializationBP_RevertWhenValueTooHigh() public { + uint16 invalidLimit = uint16(pool.TOTAL_BASIS_POINTS() + 1); + + vm.startPrank(owner); + vm.expectRevert(StvStETHPool.InvalidValue.selector); + pool.setMaxLossSocializationBP(invalidLimit); + vm.stopPrank(); + } + + function test_SetMaxLossSocializationBP_RevertWhenValueUnchanged() public { + uint16 limit = 3_000; + + vm.startPrank(owner); + pool.setMaxLossSocializationBP(limit); + vm.expectRevert(StvStETHPool.SameValue.selector); + pool.setMaxLossSocializationBP(limit); + vm.stopPrank(); + } +} diff --git a/test/unit/stv-steth-pool/MintingCapacity.test.sol b/test/unit/stv-steth-pool/MintingCapacity.test.sol new file mode 100644 index 0000000..6a3e5bb --- /dev/null +++ b/test/unit/stv-steth-pool/MintingCapacity.test.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Test} from "forge-std/Test.sol"; + +contract MintingCapacityTest is Test, SetupStvStETHPool { + uint256 ethToDeposit = 4 ether; + + function setUp() public override { + super.setUp(); + pool.depositETH{value: ethToDeposit}(address(this), address(0)); + } + + // totalMintingCapacitySharesOf tests + + function test_TotalMintingCapacity_BasedOnAssets() public view { + uint256 assets = pool.assetsOf(address(this)); + uint256 expectedCapacity = pool.calcStethSharesToMintForAssets(assets); + + assertEq(pool.totalMintingCapacitySharesOf(address(this)), expectedCapacity); + } + + function test_TotalMintingCapacity_IncreasesWithDeposits() public { + uint256 capacityBefore = pool.totalMintingCapacitySharesOf(address(this)); + + pool.depositETH{value: 1 ether}(address(this), address(0)); + + uint256 capacityAfter = pool.totalMintingCapacitySharesOf(address(this)); + assertGt(capacityAfter, capacityBefore); + } + + function test_TotalMintingCapacity_IncreasesWithRewards() public { + uint256 capacityBefore = pool.totalMintingCapacitySharesOf(address(this)); + + dashboard.mock_simulateRewards(int256(1 ether)); + + uint256 capacityAfter = pool.totalMintingCapacitySharesOf(address(this)); + assertGt(capacityAfter, capacityBefore); + } + + function test_TotalMintingCapacity_DecreasesWithLoss() public { + uint256 capacityBefore = pool.totalMintingCapacitySharesOf(address(this)); + + dashboard.mock_simulateRewards(int256(-0.5 ether)); + + uint256 capacityAfter = pool.totalMintingCapacitySharesOf(address(this)); + assertLt(capacityAfter, capacityBefore); + } + + function test_TotalMintingCapacity_ZeroForZeroAssets() public view { + assertEq(pool.totalMintingCapacitySharesOf(userAlice), 0); + } + + // remainingMintingCapacitySharesOf tests + + function test_RemainingCapacity_EqualsTotal_WhenNoMinted() public view { + uint256 totalCapacity = pool.totalMintingCapacitySharesOf(address(this)); + uint256 remainingCapacity = pool.remainingMintingCapacitySharesOf(address(this), 0); + + assertEq(remainingCapacity, totalCapacity); + } + + function test_RemainingCapacity_DecreasesAfterMint() public { + uint256 remainingBefore = pool.remainingMintingCapacitySharesOf(address(this), 0); + uint256 wstethToMint = 1 * 10 ** 18; + + pool.mintWsteth(wstethToMint); + + uint256 remainingAfter = pool.remainingMintingCapacitySharesOf(address(this), 0); + assertEq(remainingAfter, remainingBefore - wstethToMint); + } + + function test_RemainingCapacity_ZeroAfterFullMint() public { + uint256 fullCapacity = pool.remainingMintingCapacitySharesOf(address(this), 0); + + pool.mintWsteth(fullCapacity); + + assertEq(pool.remainingMintingCapacitySharesOf(address(this), 0), 0); + } + + function test_RemainingCapacity_WithFutureDeposit_IncludesIt() public view { + uint256 futureDeposit = 2 ether; + + uint256 capacityWithoutDeposit = pool.remainingMintingCapacitySharesOf(address(this), 0); + uint256 capacityWithDeposit = pool.remainingMintingCapacitySharesOf(address(this), futureDeposit); + + assertGt(capacityWithDeposit, capacityWithoutDeposit); + } + + function test_RemainingCapacity_AfterTransfer_Recalculated() public { + uint256 transferAmount = pool.balanceOf(address(this)) / 2; + uint256 aliceCapacityBefore = pool.remainingMintingCapacitySharesOf(userAlice, 0); + + pool.transfer(userAlice, transferAmount); + + uint256 aliceCapacityAfter = pool.remainingMintingCapacitySharesOf(userAlice, 0); + assertGt(aliceCapacityAfter, aliceCapacityBefore); + } +} diff --git a/test/unit/stv-steth-pool/MintingPause.test.sol b/test/unit/stv-steth-pool/MintingPause.test.sol new file mode 100644 index 0000000..c70ba12 --- /dev/null +++ b/test/unit/stv-steth-pool/MintingPause.test.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; +import {FeaturePausable} from "src/utils/FeaturePausable.sol"; + +contract MintingPauseTest is Test, SetupStvStETHPool { + address public mintingPauseRoleHolder; + address public mintingResumeRoleHolder; + + bytes32 mintingFeatureId; + + function setUp() public override { + super.setUp(); + + mintingPauseRoleHolder = makeAddr("mintingPauseRoleHolder"); + mintingResumeRoleHolder = makeAddr("mintingResumeRoleHolder"); + + vm.deal(mintingPauseRoleHolder, 10 ether); + vm.deal(mintingResumeRoleHolder, 10 ether); + + vm.startPrank(owner); + pool.grantRole(pool.MINTING_PAUSE_ROLE(), mintingPauseRoleHolder); + pool.grantRole(pool.MINTING_RESUME_ROLE(), mintingResumeRoleHolder); + vm.stopPrank(); + + mintingFeatureId = pool.MINTING_FEATURE(); + vm.deal(address(this), 100 ether); + + pool.depositETH{value: 10 ether}(address(this), address(0)); + } + + function test_MintStethShares_RevertWhenPaused() public { + vm.prank(mintingPauseRoleHolder); + pool.pauseMinting(); + + vm.expectRevert(abi.encodeWithSelector(FeaturePausable.FeaturePauseEnforced.selector, mintingFeatureId)); + pool.mintStethShares(10 ** 18); + } + + function test_MintWsteth_RevertWhenPaused() public { + vm.prank(mintingPauseRoleHolder); + pool.pauseMinting(); + + vm.expectRevert(abi.encodeWithSelector(FeaturePausable.FeaturePauseEnforced.selector, mintingFeatureId)); + pool.mintWsteth(10 ** 18); + } + + function test_PauseMinting_RevertWhenCallerUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), pool.MINTING_PAUSE_ROLE() + ) + ); + pool.pauseMinting(); + } + + function test_ResumeMinting_RevertWhenCallerUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), pool.MINTING_RESUME_ROLE() + ) + ); + pool.resumeMinting(); + } + + function test_ResumeMinting_AllowsMintAfterPause() public { + vm.prank(mintingPauseRoleHolder); + pool.pauseMinting(); + + vm.prank(mintingResumeRoleHolder); + pool.resumeMinting(); + + pool.mintStethShares(10 ** 18); + assertEq(pool.mintedStethSharesOf(address(this)), 10 ** 18); + } + + function test_DepositAndMintSteth_RevertWhenMintingPaused() public { + vm.prank(mintingPauseRoleHolder); + pool.pauseMinting(); + + vm.expectRevert(abi.encodeWithSelector(FeaturePausable.FeaturePauseEnforced.selector, mintingFeatureId)); + pool.depositETHAndMintStethShares{value: 1 ether}(address(0), 10 ** 18); + } + + function test_DepositAndMintWsteth_RevertWhenMintingPaused() public { + vm.prank(mintingPauseRoleHolder); + pool.pauseMinting(); + + vm.expectRevert(abi.encodeWithSelector(FeaturePausable.FeaturePauseEnforced.selector, mintingFeatureId)); + pool.depositETHAndMintWsteth{value: 1 ether}(address(0), 10 ** 18); + } +} diff --git a/test/unit/stv-steth-pool/MintingStethShares.test.sol b/test/unit/stv-steth-pool/MintingStethShares.test.sol index e62936a..e555819 100644 --- a/test/unit/stv-steth-pool/MintingStethShares.test.sol +++ b/test/unit/stv-steth-pool/MintingStethShares.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; contract MintingStethSharesTest is Test, SetupStvStETHPool { @@ -72,8 +72,7 @@ contract MintingStethSharesTest is Test, SetupStvStETHPool { function test_MintStethShares_CallsDashboardMintShares() public { // Check that dashboard's mint function is called with correct parameters vm.expectCall( - address(dashboard), - abi.encodeWithSelector(dashboard.mintShares.selector, address(this), stethSharesToMint) + address(dashboard), abi.encodeWithSelector(dashboard.mintShares.selector, address(this), stethSharesToMint) ); pool.mintStethShares(stethSharesToMint); diff --git a/test/unit/stv-steth-pool/MintingWsteth.test.sol b/test/unit/stv-steth-pool/MintingWsteth.test.sol index de21628..6eef8f5 100644 --- a/test/unit/stv-steth-pool/MintingWsteth.test.sol +++ b/test/unit/stv-steth-pool/MintingWsteth.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; contract MintingWstethTest is Test, SetupStvStETHPool { @@ -57,8 +57,7 @@ contract MintingWstethTest is Test, SetupStvStETHPool { function test_MintWsteth_CallsDashboardMintShares() public { // Check that dashboard's mint function is called with correct parameters vm.expectCall( - address(dashboard), - abi.encodeWithSelector(dashboard.mintWstETH.selector, address(this), wstethToMint) + address(dashboard), abi.encodeWithSelector(dashboard.mintWstETH.selector, address(this), wstethToMint) ); pool.mintWsteth(wstethToMint); diff --git a/test/unit/stv-steth-pool/RebalanceMintedStethShares.test.sol b/test/unit/stv-steth-pool/RebalanceMintedStethShares.test.sol index 5acbe47..2bebce9 100644 --- a/test/unit/stv-steth-pool/RebalanceMintedStethShares.test.sol +++ b/test/unit/stv-steth-pool/RebalanceMintedStethShares.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; import {StvPool} from "src/StvPool.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; @@ -21,12 +21,17 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { pool.mintStethShares(_amount); } + function _previewStvToRebalance(uint256 _stethShares) internal view returns (uint256 stvToRebalance) { + uint256 ethToRebalance = steth.getPooledEthBySharesRoundUp(_stethShares); + stvToRebalance = pool.previewWithdraw(ethToRebalance); + } + // Access control tests function test_RebalanceMintedStethShares_RevertOnCallFromStranger() public { vm.prank(userAlice); vm.expectRevert(StvPool.NotWithdrawalQueue.selector); - pool.rebalanceMintedStethShares(1, unlimitedStvToBurn); + pool.rebalanceMintedStethSharesForWithdrawalQueue(1, unlimitedStvToBurn); } function test_RebalanceMintedStethShares_SuccessfulCallFromWithdrawalQueue() public { @@ -38,7 +43,7 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { // Call from withdrawal queue vm.prank(withdrawalQueue); - pool.rebalanceMintedStethShares(sharesToMint, unlimitedStvToBurn); + pool.rebalanceMintedStethSharesForWithdrawalQueue(sharesToMint, unlimitedStvToBurn); // Verify withdrawal queue's shares were rebalanced assertEq(pool.mintedStethSharesOf(withdrawalQueue), wqMintedBefore - sharesToMint); @@ -50,7 +55,7 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { function test_RebalanceMintedStethShares_RevertOnZeroAmount() public { vm.prank(withdrawalQueue); vm.expectRevert(StvStETHPool.ZeroArgument.selector); - pool.rebalanceMintedStethShares(0, unlimitedStvToBurn); + pool.rebalanceMintedStethSharesForWithdrawalQueue(0, unlimitedStvToBurn); } function test_RebalanceMintedStethShares_RevertOnInsufficientMintedShares() public { @@ -59,7 +64,7 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { vm.prank(withdrawalQueue); vm.expectRevert(StvStETHPool.InsufficientMintedShares.selector); - pool.rebalanceMintedStethShares(sharesToMint + 1, unlimitedStvToBurn); + pool.rebalanceMintedStethSharesForWithdrawalQueue(sharesToMint + 1, unlimitedStvToBurn); } function test_RebalanceMintedStethShares_RevertOnNoMintedShares() public { @@ -67,7 +72,7 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { vm.prank(withdrawalQueue); vm.expectRevert(StvStETHPool.InsufficientMintedShares.selector); - pool.rebalanceMintedStethShares(10 ** 18, unlimitedStvToBurn); + pool.rebalanceMintedStethSharesForWithdrawalQueue(10 ** 18, unlimitedStvToBurn); } // Basic functionality test @@ -81,7 +86,7 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { uint256 totalSupplyBefore = pool.totalSupply(); vm.prank(withdrawalQueue); - pool.rebalanceMintedStethShares(sharesToMint, unlimitedStvToBurn); + pool.rebalanceMintedStethSharesForWithdrawalQueue(sharesToMint, unlimitedStvToBurn); assertEq(pool.mintedStethSharesOf(withdrawalQueue), wqMintedSharesBefore - sharesToMint); assertLt(pool.balanceOf(withdrawalQueue), wqBalanceBefore); @@ -97,7 +102,7 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { emit StvStETHPool.StethSharesRebalanced(withdrawalQueue, sharesToMint, 0); vm.prank(withdrawalQueue); - pool.rebalanceMintedStethShares(sharesToMint, unlimitedStvToBurn); + pool.rebalanceMintedStethSharesForWithdrawalQueue(sharesToMint, unlimitedStvToBurn); } // Exceeding shares scenarios @@ -113,7 +118,7 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { assertGt(exceedingBefore, 0); // Should have exceeding shares vm.prank(withdrawalQueue); - pool.rebalanceMintedStethShares(sharesToMint, unlimitedStvToBurn); + pool.rebalanceMintedStethSharesForWithdrawalQueue(sharesToMint, unlimitedStvToBurn); // Should rebalance shares assertEq(pool.mintedStethSharesOf(withdrawalQueue), 0); @@ -122,6 +127,10 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { // Socialization scenarios function test_RebalanceMintedStethShares_SocializationWhenMaxStvExceeded() public { + // Enable loss socialization + vm.prank(owner); + pool.setMaxLossSocializationBP(100_00); // 100% + uint256 sharesToMint = pool.remainingMintingCapacitySharesOf(withdrawalQueue, 0) / 4; _mintStethSharesToWQ(sharesToMint); @@ -130,10 +139,10 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { // Only check that SocializedLoss event is emitted (without exact amounts) vm.expectEmit(false, false, false, false); - emit StvStETHPool.SocializedLoss(0, 0); + emit StvStETHPool.SocializedLoss(0, 0, 0); vm.prank(withdrawalQueue); - pool.rebalanceMintedStethShares(sharesToMint, maxStvToBurn); + pool.rebalanceMintedStethSharesForWithdrawalQueue(sharesToMint, maxStvToBurn); // Verify shares were still rebalanced assertEq(pool.mintedStethSharesOf(withdrawalQueue), 0); @@ -146,12 +155,16 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { uint256 maxStvToBurn = 0; // No burning allowed uint256 wqBalanceBefore = pool.balanceOf(withdrawalQueue); + // Enable socialization + vm.prank(owner); + pool.setMaxLossSocializationBP(100_00); // 100% + // Only check that SocializedLoss event is emitted (without exact amounts) vm.expectEmit(false, false, false, false); - emit StvStETHPool.SocializedLoss(0, 0); + emit StvStETHPool.SocializedLoss(0, 0, 0); vm.prank(withdrawalQueue); - pool.rebalanceMintedStethShares(sharesToMint, maxStvToBurn); + pool.rebalanceMintedStethSharesForWithdrawalQueue(sharesToMint, maxStvToBurn); // No STV should be burned assertEq(pool.balanceOf(withdrawalQueue), wqBalanceBefore); @@ -159,6 +172,80 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { assertEq(pool.mintedStethSharesOf(withdrawalQueue), 0); } + function test_RebalanceMintedStethShares_AllowsFullSocializationAtHundredPercentLimit() public { + uint256 sharesToMint = pool.remainingMintingCapacitySharesOf(withdrawalQueue, 0) / 4; + _mintStethSharesToWQ(sharesToMint); + + uint256 stvInWQBefore = pool.balanceOf(withdrawalQueue); + + vm.prank(owner); + pool.setMaxLossSocializationBP(100_00); + + vm.prank(withdrawalQueue); + pool.rebalanceMintedStethSharesForWithdrawalQueue(sharesToMint, 0); + + assertEq(pool.mintedStethSharesOf(withdrawalQueue), 0); + assertEq(pool.balanceOf(withdrawalQueue), stvInWQBefore); + } + + function test_RebalanceMintedStethShares_PartialSocializationWithinLimit() public { + vm.prank(owner); + pool.setMaxLossSocializationBP(2_500); // 25% + + uint256 sharesToMint = pool.remainingMintingCapacitySharesOf(withdrawalQueue, 0) / 4; + _mintStethSharesToWQ(sharesToMint); + + uint256 stvRequired = _previewStvToRebalance(sharesToMint); + uint256 shortfall = stvRequired / 5; // 20% + assertGt(shortfall, 0); + + vm.prank(withdrawalQueue); + pool.rebalanceMintedStethSharesForWithdrawalQueue(sharesToMint, stvRequired - shortfall); + + assertEq(pool.mintedStethSharesOf(withdrawalQueue), 0); + } + + function test_RebalanceMintedStethShares_PartialSocializationAboveLimitReverts() public { + vm.prank(owner); + pool.setMaxLossSocializationBP(1_000); // 10% + + uint256 sharesToMint = pool.remainingMintingCapacitySharesOf(withdrawalQueue, 0) / 4; + _mintStethSharesToWQ(sharesToMint); + + uint256 stvRequired = _previewStvToRebalance(sharesToMint); + uint256 shortfall = stvRequired / 5; // 20% + assertGt(shortfall, 0); + + vm.prank(withdrawalQueue); + vm.expectRevert(StvStETHPool.ExcessiveLossSocialization.selector); + pool.rebalanceMintedStethSharesForWithdrawalQueue(sharesToMint, stvRequired - shortfall); + } + + function test_RebalanceMintedStethShares_DefaultLimitAllowsWhenNoSocialization() public { + uint256 sharesToMint = pool.remainingMintingCapacitySharesOf(withdrawalQueue, 0) / 4; + _mintStethSharesToWQ(sharesToMint); + + uint256 stvRequired = _previewStvToRebalance(sharesToMint); + assertGt(stvRequired, 0); + + vm.prank(withdrawalQueue); + pool.rebalanceMintedStethSharesForWithdrawalQueue(sharesToMint, stvRequired); + + assertEq(pool.mintedStethSharesOf(withdrawalQueue), 0); + } + + function test_RebalanceMintedStethShares_DefaultLimitRevertsWhenSocializationNeeded() public { + uint256 sharesToMint = pool.remainingMintingCapacitySharesOf(withdrawalQueue, 0) / 4; + _mintStethSharesToWQ(sharesToMint); + + uint256 stvRequired = _previewStvToRebalance(sharesToMint); + assertGt(stvRequired, 1); + + vm.prank(withdrawalQueue); + vm.expectRevert(StvStETHPool.ExcessiveLossSocialization.selector); + pool.rebalanceMintedStethSharesForWithdrawalQueue(sharesToMint, stvRequired - 1); + } + // Partial rebalance scenarios function test_RebalanceMintedStethShares_PartialRebalance() public { @@ -170,7 +257,7 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { uint256 wqMintedBefore = pool.mintedStethSharesOf(withdrawalQueue); vm.prank(withdrawalQueue); - pool.rebalanceMintedStethShares(sharesToRebalance, unlimitedStvToBurn); + pool.rebalanceMintedStethSharesForWithdrawalQueue(sharesToRebalance, unlimitedStvToBurn); assertEq(pool.mintedStethSharesOf(withdrawalQueue), wqMintedBefore - sharesToRebalance); assertLt(pool.balanceOf(withdrawalQueue), wqBalanceBefore); // Some STV burned @@ -184,7 +271,7 @@ contract RebalanceMintedStethSharesTest is Test, SetupStvStETHPool { // Rebalance minimal amount (1 wei) vm.prank(withdrawalQueue); - pool.rebalanceMintedStethShares(1, unlimitedStvToBurn); + pool.rebalanceMintedStethSharesForWithdrawalQueue(1, unlimitedStvToBurn); assertEq(pool.mintedStethSharesOf(withdrawalQueue), sharesToMint - 1); assertLt(pool.balanceOf(withdrawalQueue), wqBalanceBefore); diff --git a/test/unit/stv-steth-pool/SetupStvStETHPool.sol b/test/unit/stv-steth-pool/SetupStvStETHPool.sol index 0f76d46..9792ffe 100644 --- a/test/unit/stv-steth-pool/SetupStvStETHPool.sol +++ b/test/unit/stv-steth-pool/SetupStvStETHPool.sol @@ -1,13 +1,13 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Test} from "forge-std/Test.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; import {MockDashboard, MockDashboardFactory} from "test/mocks/MockDashboard.sol"; -import {MockVaultHub} from "test/mocks/MockVaultHub.sol"; import {MockStETH} from "test/mocks/MockStETH.sol"; +import {MockVaultHub} from "test/mocks/MockVaultHub.sol"; import {MockWstETH} from "test/mocks/MockWstETH.sol"; abstract contract SetupStvStETHPool is Test { @@ -47,12 +47,7 @@ abstract contract SetupStvStETHPool is Test { // Deploy the pool with mock withdrawal queue StvStETHPool poolImpl = new StvStETHPool( - address(dashboard), - false, - reserveRatioGapBP, - withdrawalQueue, - address(0), - keccak256("test.stv.steth.pool") + address(dashboard), false, reserveRatioGapBP, withdrawalQueue, address(0), keccak256("test.stv.steth.pool") ); ERC1967Proxy poolProxy = new ERC1967Proxy(address(poolImpl), ""); diff --git a/test/unit/stv-steth-pool/SyncVaultParameters.test.sol b/test/unit/stv-steth-pool/SyncVaultParameters.test.sol new file mode 100644 index 0000000..4c21e07 --- /dev/null +++ b/test/unit/stv-steth-pool/SyncVaultParameters.test.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {MockVaultHub} from "../../mocks/MockVaultHub.sol"; +import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; + +contract SyncVaultParametersTest is Test, SetupStvStETHPool { + address public randomUser; + + function setUp() public override { + super.setUp(); + randomUser = makeAddr("randomUser"); + } + + function test_SyncVaultParameters_Permissionless() public { + vm.prank(randomUser); + pool.syncVaultParameters(); + } + + function test_SyncVaultParameters_AddsGapToReserveRatio() public { + address stakingVault = dashboard.stakingVault(); + uint16 baseReserveRatioBP = 2000; + uint16 baseForcedThresholdBP = 1500; + vaultHub.mock_setConnectionParameters(stakingVault, baseReserveRatioBP, baseForcedThresholdBP); + + pool.syncVaultParameters(); + + assertEq(pool.reserveRatioBP(), baseReserveRatioBP + reserveRatioGapBP); + } + + function test_SyncVaultParameters_AddsGapToThreshold() public { + address stakingVault = dashboard.stakingVault(); + uint16 baseReserveRatioBP = 2000; + uint16 baseForcedThresholdBP = 1500; + vaultHub.mock_setConnectionParameters(stakingVault, baseReserveRatioBP, baseForcedThresholdBP); + + pool.syncVaultParameters(); + + assertEq(pool.forcedRebalanceThresholdBP(), baseForcedThresholdBP + reserveRatioGapBP); + } + + function test_SyncVaultParameters_CapsAtMaximum() public { + address stakingVault = dashboard.stakingVault(); + uint16 baseReserveRatioBP = 9900; + uint16 baseForcedThresholdBP = 9800; + vaultHub.mock_setConnectionParameters(stakingVault, baseReserveRatioBP, baseForcedThresholdBP); + + pool.syncVaultParameters(); + + assertEq(pool.reserveRatioBP(), 9999); + assertEq(pool.forcedRebalanceThresholdBP(), 9998); + } + + function test_SyncVaultParameters_AfterVaultUpdate_Syncs() public { + address stakingVault = dashboard.stakingVault(); + uint16 initialReserveRatioBP = 2000; + uint16 initialForcedThresholdBP = 1500; + vaultHub.mock_setConnectionParameters(stakingVault, initialReserveRatioBP, initialForcedThresholdBP); + pool.syncVaultParameters(); + + uint16 newReserveRatioBP = 3000; + uint16 newForcedThresholdBP = 2500; + vaultHub.mock_setConnectionParameters(stakingVault, newReserveRatioBP, newForcedThresholdBP); + + pool.syncVaultParameters(); + + assertEq(pool.reserveRatioBP(), newReserveRatioBP + reserveRatioGapBP); + assertEq(pool.forcedRebalanceThresholdBP(), newForcedThresholdBP + reserveRatioGapBP); + } + + function test_SyncVaultParameters_AffectsMintingCapacity() public { + vm.prank(userAlice); + pool.depositETH{value: 10 ether}(userAlice, address(0)); + + uint256 capacityBefore = pool.remainingMintingCapacitySharesOf(userAlice, 0); + + address stakingVault = dashboard.stakingVault(); + uint16 lowerReserveRatioBP = 500; + uint16 lowerForcedThresholdBP = 400; + vaultHub.mock_setConnectionParameters(stakingVault, lowerReserveRatioBP, lowerForcedThresholdBP); + + pool.syncVaultParameters(); + + uint256 capacityAfter = pool.remainingMintingCapacitySharesOf(userAlice, 0); + + assertGt(capacityAfter, capacityBefore); + } +} diff --git a/test/unit/stv-steth-pool/TransferBlocking.test.sol b/test/unit/stv-steth-pool/TransferBlocking.test.sol index 1135a0e..82925e6 100644 --- a/test/unit/stv-steth-pool/TransferBlocking.test.sol +++ b/test/unit/stv-steth-pool/TransferBlocking.test.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; -import {StvStETHPool} from "src/StvStETHPool.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; contract TransferBlockingTest is Test, SetupStvStETHPool { uint256 ethToDeposit = 10 ether; @@ -246,12 +246,8 @@ contract TransferBlockingTest is Test, SetupStvStETHPool { // Verify calculation logic uint256 stethAmount = steth.getPooledEthBySharesRoundUp(testShares); - uint256 expectedAssetsToLock = Math.mulDiv( - stethAmount, - totalBasisPoints, - totalBasisPoints - reserveRatio, - Math.Rounding.Ceil - ); + uint256 expectedAssetsToLock = + Math.mulDiv(stethAmount, totalBasisPoints, totalBasisPoints - reserveRatio, Math.Rounding.Ceil); uint256 calculatedAssetsToLock = pool.calcAssetsToLockForStethShares(testShares); assertEq(calculatedAssetsToLock, expectedAssetsToLock); diff --git a/test/unit/stv-steth-pool/TransferWithLiability.test.sol b/test/unit/stv-steth-pool/TransferWithLiability.test.sol index feb052b..996484a 100644 --- a/test/unit/stv-steth-pool/TransferWithLiability.test.sol +++ b/test/unit/stv-steth-pool/TransferWithLiability.test.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; - -import {Test} from "forge-std/Test.sol"; +pragma solidity 0.8.30; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvPool} from "src/StvPool.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; contract TransferWithLiabilityTest is Test, SetupStvStETHPool { @@ -62,11 +62,109 @@ contract TransferWithLiabilityTest is Test, SetupStvStETHPool { pool.transferWithLiability(userAlice, stvBalance, mintedRecorded + 1); } - function test_TransferWithLiability_RevertsWhenZeroStvButHasShares() public { + function test_TransferWithLiability_RevertsWhenInsufficientStvButHasShares() public { uint256 sharesToTransfer = pool.remainingMintingCapacitySharesOf(address(this), 0) / 5; pool.mintStethShares(sharesToTransfer); vm.expectRevert(StvStETHPool.InsufficientStv.selector); pool.transferWithLiability(userAlice, 0, sharesToTransfer); } + + // transferFromWithLiabilityForWithdrawalQueue tests + + function test_TransferWithLiabilityForWQ_OnlyCallableByWQ() public { + uint256 sharesToTransfer = pool.remainingMintingCapacitySharesOf(address(this), 0) / 2; + pool.mintStethShares(sharesToTransfer); + uint256 stvToTransfer = pool.balanceOf(address(this)); + + vm.prank(userAlice); + vm.expectRevert(StvPool.NotWithdrawalQueue.selector); + pool.transferFromWithLiabilityForWithdrawalQueue(address(this), stvToTransfer, sharesToTransfer); + } + + function test_TransferWithLiabilityForWQ_TransfersStv() public { + uint256 sharesToTransfer = pool.remainingMintingCapacitySharesOf(address(this), 0) / 2; + pool.mintStethShares(sharesToTransfer); + uint256 stvBefore = pool.balanceOf(address(this)); + + vm.prank(withdrawalQueue); + pool.transferFromWithLiabilityForWithdrawalQueue(address(this), stvBefore, sharesToTransfer); + + assertEq(pool.balanceOf(address(this)), 0); + assertEq(pool.balanceOf(withdrawalQueue), stvBefore); + } + + function test_TransferWithLiabilityForWQ_TransfersLiability() public { + uint256 sharesToTransfer = pool.remainingMintingCapacitySharesOf(address(this), 0) / 2; + pool.mintStethShares(sharesToTransfer); + uint256 stvToTransfer = pool.balanceOf(address(this)); + + vm.prank(withdrawalQueue); + pool.transferFromWithLiabilityForWithdrawalQueue(address(this), stvToTransfer, sharesToTransfer); + + assertEq(pool.mintedStethSharesOf(address(this)), 0); + assertEq(pool.mintedStethSharesOf(withdrawalQueue), sharesToTransfer); + } + + function test_TransferWithLiabilityForWQ_ChecksMinStv() public { + uint256 sharesToTransfer = pool.remainingMintingCapacitySharesOf(address(this), 0) / 2; + pool.mintStethShares(sharesToTransfer); + uint256 minStv = pool.calcStvToLockForStethShares(sharesToTransfer); + + vm.prank(withdrawalQueue); + pool.transferFromWithLiabilityForWithdrawalQueue(address(this), minStv, sharesToTransfer); + + assertEq(pool.balanceOf(withdrawalQueue), minStv); + } + + function test_TransferWithLiabilityForWQ_RevertOn_InsufficientStv() public { + uint256 sharesToTransfer = pool.remainingMintingCapacitySharesOf(address(this), 0) / 2; + pool.mintStethShares(sharesToTransfer); + uint256 minStv = pool.calcStvToLockForStethShares(sharesToTransfer); + uint256 insufficientStv = minStv - 1; + + vm.prank(withdrawalQueue); + vm.expectRevert(StvStETHPool.InsufficientStv.selector); + pool.transferFromWithLiabilityForWithdrawalQueue(address(this), insufficientStv, sharesToTransfer); + } + + // General transferWithLiability tests + + function test_TransferWithLiability_EmitsEvents() public { + uint256 sharesToTransfer = pool.remainingMintingCapacitySharesOf(address(this), 0) / 2; + pool.mintStethShares(sharesToTransfer); + uint256 stvToTransfer = pool.balanceOf(address(this)); + + vm.expectEmit(true, true, true, true); + emit StvStETHPool.StethSharesBurned(address(this), sharesToTransfer); + vm.expectEmit(true, true, true, true); + emit StvStETHPool.StethSharesMinted(userAlice, sharesToTransfer); + + pool.transferWithLiability(userAlice, stvToTransfer, sharesToTransfer); + } + + function test_TransferWithLiability_ExactMinStv_Success() public { + uint256 sharesToTransfer = pool.remainingMintingCapacitySharesOf(address(this), 0) / 2; + pool.mintStethShares(sharesToTransfer); + uint256 minStv = pool.calcStvToLockForStethShares(sharesToTransfer); + + bool success = pool.transferWithLiability(userAlice, minStv, sharesToTransfer); + + assertTrue(success); + assertEq(pool.balanceOf(userAlice), minStv); + assertEq(pool.mintedStethSharesOf(userAlice), sharesToTransfer); + } + + function test_TransferWithLiability_MoreThanMinStv_Success() public { + uint256 sharesToTransfer = pool.remainingMintingCapacitySharesOf(address(this), 0) / 2; + pool.mintStethShares(sharesToTransfer); + uint256 minStv = pool.calcStvToLockForStethShares(sharesToTransfer); + uint256 moreStv = minStv + 1 ether; + + bool success = pool.transferWithLiability(userAlice, moreStv, sharesToTransfer); + + assertTrue(success); + assertEq(pool.balanceOf(userAlice), moreStv); + assertEq(pool.mintedStethSharesOf(userAlice), sharesToTransfer); + } } diff --git a/test/unit/stv-steth-pool/UnlockedAssets.test.sol b/test/unit/stv-steth-pool/UnlockedAssets.test.sol new file mode 100644 index 0000000..44e2574 --- /dev/null +++ b/test/unit/stv-steth-pool/UnlockedAssets.test.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; + +contract UnlockedAssetsTest is Test, SetupStvStETHPool { + uint256 ethToDeposit = 4 ether; + + function setUp() public override { + super.setUp(); + pool.depositETH{value: ethToDeposit}(address(this), address(0)); + } + + // unlockedAssetsOf tests + + function test_UnlockedAssets_EqualsTotal_WhenNoMinted() public view { + uint256 totalAssets = pool.assetsOf(address(this)); + uint256 unlockedAssets = pool.unlockedAssetsOf(address(this)); + + assertEq(unlockedAssets, totalAssets); + } + + function test_UnlockedAssets_DecreasesAfterMinting() public { + uint256 unlockedBefore = pool.unlockedAssetsOf(address(this)); + uint256 wstethToMint = 1 * 10 ** 18; + + pool.mintWsteth(wstethToMint); + + uint256 unlockedAfter = pool.unlockedAssetsOf(address(this)); + assertLt(unlockedAfter, unlockedBefore); + } + + function test_UnlockedAssets_ZeroWhenFullyMinted() public { + uint256 maxCapacity = pool.remainingMintingCapacitySharesOf(address(this), 0); + + pool.mintWsteth(maxCapacity); + + assertEq(pool.unlockedAssetsOf(address(this)), 0); + } + + function test_UnlockedAssets_IncreasesAfterBurning() public { + uint256 wstethToMint = 1 * 10 ** 18; + pool.mintWsteth(wstethToMint); + wsteth.approve(address(pool), type(uint256).max); + + uint256 unlockedBefore = pool.unlockedAssetsOf(address(this)); + + pool.burnWsteth(wstethToMint / 2); + + uint256 unlockedAfter = pool.unlockedAssetsOf(address(this)); + assertGt(unlockedAfter, unlockedBefore); + } + + function test_UnlockedAssets_WithBurnParameter_Calculated() public { + uint256 wstethToMint = 2 * 10 ** 18; + pool.mintWsteth(wstethToMint); + + uint256 sharesToBurn = 1 * 10 ** 18; + uint256 unlockedWithBurn = pool.unlockedAssetsOf(address(this), sharesToBurn); + uint256 unlockedWithoutBurn = pool.unlockedAssetsOf(address(this), 0); + + assertGt(unlockedWithBurn, unlockedWithoutBurn); + } + + // unlockedStvOf tests + + function test_UnlockedStv_EqualsBalance_WhenNoMinted() public view { + uint256 balance = pool.balanceOf(address(this)); + uint256 unlockedStv = pool.unlockedStvOf(address(this)); + + assertEq(unlockedStv, balance); + } + + function test_UnlockedStv_DecreasesAfterMinting() public { + uint256 unlockedBefore = pool.unlockedStvOf(address(this)); + uint256 wstethToMint = 1 * 10 ** 18; + + pool.mintWsteth(wstethToMint); + + uint256 unlockedAfter = pool.unlockedStvOf(address(this)); + assertLt(unlockedAfter, unlockedBefore); + } + + function test_UnlockedStv_ConversionFromAssets_Accurate() public view { + uint256 unlockedAssets = pool.unlockedAssetsOf(address(this)); + uint256 unlockedStv = pool.unlockedStvOf(address(this)); + + uint256 expectedStv = unlockedAssets * pool.totalSupply() / pool.totalAssets(); + + assertEq(unlockedStv, expectedStv); + } + + function test_UnlockedStv_WithBurnParameter_Calculated() public { + uint256 wstethToMint = 2 * 10 ** 18; + pool.mintWsteth(wstethToMint); + + uint256 sharesToBurn = 1 * 10 ** 18; + uint256 unlockedWithBurn = pool.unlockedStvOf(address(this), sharesToBurn); + uint256 unlockedWithoutBurn = pool.unlockedStvOf(address(this), 0); + + assertGt(unlockedWithBurn, unlockedWithoutBurn); + } + + // stethSharesToBurnForStvOf tests + + function test_SharesToBurn_ZeroForZeroStv() public view { + assertEq(pool.stethSharesToBurnForStvOf(address(this), 0), 0); + } + + function test_SharesToBurn_CalculatesCorrectAmount() public { + uint256 wstethToMint = 2 * 10 ** 18; + pool.mintWsteth(wstethToMint); + + // Try to unlock most of the balance (80%) - this requires burning shares + uint256 stvToUnlock = (pool.balanceOf(address(this)) * 80) / 100; + uint256 sharesToBurn = pool.stethSharesToBurnForStvOf(address(this), stvToUnlock); + + assertGt(sharesToBurn, 0); + } + + function test_SharesToBurn_RevertOn_InsufficientBalance() public { + uint256 balance = pool.balanceOf(address(this)); + uint256 excessiveStv = balance + 1; + + vm.expectRevert(StvStETHPool.InsufficientBalance.selector); + pool.stethSharesToBurnForStvOf(address(this), excessiveStv); + } + + function test_SharesToBurn_ForFullBalance() public { + uint256 wstethToMint = 2 * 10 ** 18; + pool.mintWsteth(wstethToMint); + + uint256 fullBalance = pool.balanceOf(address(this)); + uint256 sharesToBurn = pool.stethSharesToBurnForStvOf(address(this), fullBalance); + + assertEq(sharesToBurn, pool.mintedStethSharesOf(address(this))); + } +} diff --git a/test/unit/stv-steth-pool/VaultParameters.test.sol b/test/unit/stv-steth-pool/VaultParameters.test.sol index 9d38bab..592bb55 100644 --- a/test/unit/stv-steth-pool/VaultParameters.test.sol +++ b/test/unit/stv-steth-pool/VaultParameters.test.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Test} from "forge-std/Test.sol"; +import {MockVaultHub} from "../../mocks/MockVaultHub.sol"; import {SetupStvStETHPool} from "./SetupStvStETHPool.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; -import {MockVaultHub} from "../../mocks/MockVaultHub.sol"; contract VaultParametersTest is Test, SetupStvStETHPool { function test_ReserveRatioBP_ReturnsExpectedValue() public view { @@ -46,8 +46,7 @@ contract VaultParametersTest is Test, SetupStvStETHPool { vm.expectEmit(false, false, false, true); emit StvStETHPool.VaultParametersUpdated( - baseReserveRatioBP + reserveRatioGapBP, - baseForcedThresholdBP + reserveRatioGapBP + baseReserveRatioBP + reserveRatioGapBP, baseForcedThresholdBP + reserveRatioGapBP ); pool.syncVaultParameters(); } diff --git a/test/unit/withdrawal-queue/BadDebt.test.sol b/test/unit/withdrawal-queue/BadDebt.test.sol new file mode 100644 index 0000000..3fd2a79 --- /dev/null +++ b/test/unit/withdrawal-queue/BadDebt.test.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvPool} from "src/StvPool.sol"; + +contract BadDebtTest is Test, SetupWithdrawalQueue { + function setUp() public virtual override { + super.setUp(); + + // Deposit some ETH and mint max stETH shares for the test contract + pool.depositETH{value: 10 ether}(address(this), address(0)); + pool.mintStethShares(pool.remainingMintingCapacitySharesOf(address(this), 0)); + + // Deposit some ETH and mint max stETH shares for Alice + vm.startPrank(userAlice); + pool.depositETH{value: 10 ether}(userAlice, address(0)); + pool.mintStethShares(pool.remainingMintingCapacitySharesOf(userAlice, 0)); + vm.stopPrank(); + } + + function _simulateBadDebt() internal { + // Simulate negative rewards to create bad debt + uint256 totalAssets = vaultHub.totalValue(address(pool.VAULT())); + uint256 liabilitySteth = steth.getPooledEthBySharesRoundUp(pool.totalLiabilityShares()); + uint256 value = totalAssets - liabilitySteth; + + dashboard.mock_simulateRewards(int256(value) * -1 - 10 wei); + + _assertBadDebt(); + } + + function _getValueAndLiabilityShares() internal view returns (uint256 valueShares, uint256 liabilityShares) { + valueShares = steth.getSharesByPooledEth(vaultHub.totalValue(address(pool.VAULT()))); + liabilityShares = pool.totalLiabilityShares(); + } + + function _assertBadDebt() internal view { + (uint256 valueShares, uint256 liabilityShares) = _getValueAndLiabilityShares(); + assertLt(valueShares, liabilityShares); + } + + function _assertNoBadDebt() internal view { + (uint256 valueShares, uint256 liabilityShares) = _getValueAndLiabilityShares(); + assertGe(valueShares, liabilityShares); + } + + // Initial state tests + + function test_InitialState_NoBadDebt() public view { + _assertNoBadDebt(); + } + + // Bad debt tests + + function test_BadDebt_RevertInRequestWithdrawals() public { + _simulateBadDebt(); + + uint256 balance = pool.balanceOf(address(this)); + assertGt(balance, 0); + + vm.expectRevert(StvPool.VaultInBadDebt.selector); + withdrawalQueue.requestWithdrawal(address(pool), balance, 0); + } + + function test_BadDebt_RevertOnFinalization() public { + uint256 balance = pool.balanceOf(address(this)); + uint256 liabilityShares = pool.mintedStethSharesOf(address(this)); + assertGt(balance, 0); + + withdrawalQueue.requestWithdrawal(address(pool), balance, liabilityShares); + + _simulateBadDebt(); + _warpAndMockOracleReport(); + + vm.prank(finalizeRoleHolder); + vm.expectRevert(StvPool.VaultInBadDebt.selector); + withdrawalQueue.finalize(1, address(0)); + } +} diff --git a/test/unit/withdrawal-queue/Checkpoints.test.sol b/test/unit/withdrawal-queue/Checkpoints.test.sol index 7daa2bb..86e952e 100644 --- a/test/unit/withdrawal-queue/Checkpoints.test.sol +++ b/test/unit/withdrawal-queue/Checkpoints.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract CheckpointsTest is Test, SetupWithdrawalQueue { @@ -72,11 +72,8 @@ contract CheckpointsTest is Test, SetupWithdrawalQueue { uint256[] memory requestIds = new uint256[](1); requestIds[0] = requestId; - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); assertEq(hints.length, 1); assertEq(hints[0], 1); // Should point to first checkpoint @@ -89,11 +86,8 @@ contract CheckpointsTest is Test, SetupWithdrawalQueue { requestIds[1] = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); requestIds[2] = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); assertEq(hints.length, 3); assertEq(hints[0], 1); // First request → first checkpoint @@ -110,11 +104,8 @@ contract CheckpointsTest is Test, SetupWithdrawalQueue { uint256[] memory requestIds = new uint256[](1); requestIds[0] = requestId2; // Second request not finalized - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); // Should return NOT_FOUND (0) for unfinalized request assertEq(hints[0], 0); @@ -150,11 +141,8 @@ contract CheckpointsTest is Test, SetupWithdrawalQueue { searchIds[1] = requestIds[2]; // Request 3 searchIds[2] = requestIds[3]; // Request 4 - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - searchIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(searchIds, 1, withdrawalQueue.getLastCheckpointIndex()); assertEq(hints[0], 2); // Request 2 → Checkpoint 2 assertEq(hints[1], 3); // Request 3 → Checkpoint 3 @@ -169,11 +157,8 @@ contract CheckpointsTest is Test, SetupWithdrawalQueue { _finalizeRequests(3); // All requests in one checkpoint - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); // All requests should point to the same checkpoint assertEq(hints[0], 1); @@ -210,6 +195,27 @@ contract CheckpointsTest is Test, SetupWithdrawalQueue { assertEq(hints[0], 0); } + function test_FindCheckpointHint_ReturnsZeroWhenStartGreaterThanEnd() public { + uint256 requestId = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); + + uint256 lastIndex = withdrawalQueue.getLastCheckpointIndex(); + assertEq(lastIndex, 1); + + uint256 hint = withdrawalQueue.findCheckpointHint(requestId, lastIndex + 1, lastIndex); + assertEq(hint, 0); + } + + function test_FindCheckpointHint_RevertOnRequestBeyondLastId() public { + _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); + + uint256 invalidRequestId = withdrawalQueue.getLastRequestId() + 1; + uint256 startCheckpointIndex = 1; + uint256 endCheckpointIndex = withdrawalQueue.getLastCheckpointIndex(); + + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.InvalidRequestId.selector, invalidRequestId)); + withdrawalQueue.findCheckpointHint(invalidRequestId, startCheckpointIndex, endCheckpointIndex); + } + // Receive ETH for tests receive() external payable {} } diff --git a/test/unit/withdrawal-queue/Claiming.test.sol b/test/unit/withdrawal-queue/Claiming.test.sol index 451c437..cf4e88b 100644 --- a/test/unit/withdrawal-queue/Claiming.test.sol +++ b/test/unit/withdrawal-queue/Claiming.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract ClaimingTest is Test, SetupWithdrawalQueue { @@ -64,26 +64,26 @@ contract ClaimingTest is Test, SetupWithdrawalQueue { requestIds[1] = requestId2; requestIds[2] = requestId3; - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); // Record initial balance and claimable amounts uint256 initialBalance = address(this).balance; uint256 totalClaimable = 0; + uint256[] memory expected = new uint256[](requestIds.length); for (uint256 i = 0; i < requestIds.length; i++) { - totalClaimable += withdrawalQueue.getClaimableEther(requestIds[i]); + expected[i] = withdrawalQueue.getClaimableEther(requestIds[i]); + totalClaimable += expected[i]; } // Batch claim - withdrawalQueue.claimWithdrawalBatch(address(this), requestIds, hints); + uint256[] memory claimed = withdrawalQueue.claimWithdrawalBatch(address(this), requestIds, hints); - // Verify all claims + // Verify all claims and returned amounts for (uint256 i = 0; i < requestIds.length; i++) { assertTrue(withdrawalQueue.getWithdrawalStatus(requestIds[i]).isClaimed); assertEq(withdrawalQueue.getClaimableEther(requestIds[i]), 0); + assertEq(claimed[i], expected[i]); } assertEq(address(this).balance, initialBalance + totalClaimable); } @@ -193,11 +193,8 @@ contract ClaimingTest is Test, SetupWithdrawalQueue { requestIds[0] = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); requestIds[1] = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); uint256 initialBalance = address(this).balance; uint256 totalClaimable; @@ -233,6 +230,32 @@ contract ClaimingTest is Test, SetupWithdrawalQueue { withdrawalQueue.claimWithdrawalBatch(address(this), requestIds, hints); } + function test_ClaimWithdrawals_RevertWithPreviousCheckpointHint() public { + uint256 requestId1 = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); + uint256 requestId2 = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); + + uint256[] memory requestIds = new uint256[](2); + requestIds[0] = requestId1; + requestIds[1] = requestId2; + + uint256[] memory correctHints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); + assertEq(correctHints[0], 1); + assertEq(correctHints[1], 2); + + uint256[] memory wrongHints = new uint256[](2); + wrongHints[0] = correctHints[0]; + wrongHints[1] = correctHints[0]; + + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.InvalidHint.selector, wrongHints[1])); + withdrawalQueue.claimWithdrawalBatch(address(this), requestIds, wrongHints); + } + + function test_GetClaimableEther_ReturnsZeroForUnknownRequest() public { + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.InvalidRequestId.selector, 999)); + withdrawalQueue.getClaimableEther(999); + } + function test_GetClaimableEtherBatch_RevertArraysLengthMismatch() public { uint256 requestId = _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); diff --git a/test/unit/withdrawal-queue/EmergencyExit.test.sol b/test/unit/withdrawal-queue/EmergencyExit.test.sol deleted file mode 100644 index f356574..0000000 --- a/test/unit/withdrawal-queue/EmergencyExit.test.sol +++ /dev/null @@ -1,162 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; - -import {Test} from "forge-std/Test.sol"; -import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; -import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; - -contract EmergencyExitTest is Test, SetupWithdrawalQueue { - function setUp() public override { - super.setUp(); - - // Deposit initial ETH to pool for withdrawals - pool.depositETH{value: 1000 ether}(address(this), address(0)); - } - - // Basic Emergency Exit State - - function test_EmergencyExit_InitialState() public view { - // Initially should not be activated - assertFalse(withdrawalQueue.isEmergencyExitActivated()); - assertFalse(withdrawalQueue.isWithdrawalQueueStuck()); - } - - function test_EmergencyExit_QueueNotStuckWithoutRequests() public { - // Empty queue should never be stuck - assertFalse(withdrawalQueue.isWithdrawalQueueStuck()); - - // Even after long time - vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS() + 1); - assertFalse(withdrawalQueue.isWithdrawalQueueStuck()); - } - - function test_EmergencyExit_QueueNotStuckWhenFullyFinalized() public { - _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); - - // Queue should not be stuck even after long time - vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS() + 1); - assertFalse(withdrawalQueue.isWithdrawalQueueStuck()); - } - - // Queue Stuck Detection - - function test_EmergencyExit_QueueBecomesStuck() public { - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - - // Initially not stuck - assertFalse(withdrawalQueue.isWithdrawalQueueStuck()); - - // Still not stuck before max time - vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS()); - assertFalse(withdrawalQueue.isWithdrawalQueueStuck()); - - // Becomes stuck after max time - vm.warp(block.timestamp + 1); - assertTrue(withdrawalQueue.isWithdrawalQueueStuck()); - } - - function test_EmergencyExit_QueueStuckWithMultipleRequests() public { - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - - // Advance time past max acceptable time for first request - vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS() + 1); - - assertTrue(withdrawalQueue.isWithdrawalQueueStuck()); - } - - // Emergency Exit Activation - - function test_EmergencyExit_SuccessfulActivation() public { - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - - // Make queue stuck - vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS() + 1); - assertTrue(withdrawalQueue.isWithdrawalQueueStuck()); - - // Anyone can activate emergency exit - vm.prank(userAlice); - vm.expectEmit(true, false, false, true); - emit WithdrawalQueue.EmergencyExitActivated(block.timestamp); - withdrawalQueue.activateEmergencyExit(); - - assertTrue(withdrawalQueue.isEmergencyExitActivated()); - } - - function test_EmergencyExit_RevertWhenNotStuck() public { - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - - // Queue is not stuck yet - assertFalse(withdrawalQueue.isWithdrawalQueueStuck()); - - // Should revert when trying to activate - vm.expectRevert(WithdrawalQueue.InvalidEmergencyExitActivation.selector); - withdrawalQueue.activateEmergencyExit(); - } - - function test_EmergencyExit_RevertAlreadyActivated() public { - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - - // Make queue stuck and activate - vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS() + 1); - withdrawalQueue.activateEmergencyExit(); - - // Try to activate again - vm.expectRevert(WithdrawalQueue.InvalidEmergencyExitActivation.selector); - withdrawalQueue.activateEmergencyExit(); - } - - // Emergency Exit Effects on Operations - - function test_EmergencyExit_RequestsWorkWhenPaused() public { - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - - // Make queue stuck and activate emergency exit - vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS() + 1); - withdrawalQueue.activateEmergencyExit(); - - // Pause the contract - vm.prank(pauseRoleHolder); - withdrawalQueue.pause(); - - // Should still be able to create requests in emergency exit - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - assertEq(withdrawalQueue.getLastRequestId(), 2); - } - - function test_EmergencyExit_FinalizationBypassesRoles() public { - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - - // Make queue stuck and activate emergency exit - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); - vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS() + 1); - withdrawalQueue.activateEmergencyExit(); - - // Any user can finalize in emergency exit (no FINALIZE_ROLE needed) - vm.prank(userBob); - uint256 finalizedCount = withdrawalQueue.finalize(1); - assertEq(finalizedCount, 1); - } - - function test_EmergencyExit_FinalizationBypassesPause() public { - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - - // Make queue stuck and activate emergency exit - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); - vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS() + 1); - withdrawalQueue.activateEmergencyExit(); - - // Pause the contract - vm.prank(pauseRoleHolder); - withdrawalQueue.pause(); - - // Should still be able to finalize in emergency exit - vm.prank(userAlice); - uint256 finalizedCount = withdrawalQueue.finalize(1); - assertEq(finalizedCount, 1); - } - - // Receive ETH for tests - receive() external payable {} -} diff --git a/test/unit/withdrawal-queue/Finalization.test.sol b/test/unit/withdrawal-queue/Finalization.test.sol index 6965b6f..437fc0e 100644 --- a/test/unit/withdrawal-queue/Finalization.test.sol +++ b/test/unit/withdrawal-queue/Finalization.test.sol @@ -1,16 +1,16 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; -import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; -import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; +import {FeaturePausable} from "src/utils/FeaturePausable.sol"; contract FinalizationTest is Test, SetupWithdrawalQueue { function setUp() public override { super.setUp(); - pool.depositETH{value: 100_000 ether}(address(this), address(0)); + pool.depositETH{value: 10_000 ether}(address(this), address(0)); } // Basic Finalization @@ -29,11 +29,11 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { assertEq(status.owner, address(this)); // Move time forward to pass minimum delay - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); + _warpAndMockOracleReport(); // Finalize the request vm.prank(finalizeRoleHolder); - uint256 finalizedCount = withdrawalQueue.finalize(1); + uint256 finalizedCount = withdrawalQueue.finalize(1, address(0)); // Verify finalization succeeded assertEq(finalizedCount, 1); @@ -55,11 +55,11 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { assertEq(withdrawalQueue.getLastFinalizedRequestId(), 0); // Move time forward - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); + _warpAndMockOracleReport(); // Finalize all requests vm.prank(finalizeRoleHolder); - uint256 finalizedCount = withdrawalQueue.finalize(10); // More than needed + uint256 finalizedCount = withdrawalQueue.finalize(10, address(0)); // More than needed // Verify all finalized assertEq(finalizedCount, 3); @@ -80,16 +80,16 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { assertEq(withdrawalQueue.getLastRequestId(), 3); - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); + _warpAndMockOracleReport(); vm.prank(finalizeRoleHolder); - uint256 finalizedCount = withdrawalQueue.finalize(1); + uint256 finalizedCount = withdrawalQueue.finalize(1, address(0)); assertEq(finalizedCount, 1); assertEq(withdrawalQueue.getLastFinalizedRequestId(), 1); vm.prank(finalizeRoleHolder); - uint256 remainingCount = withdrawalQueue.finalize(10); + uint256 remainingCount = withdrawalQueue.finalize(10, address(0)); assertTrue(remainingCount > 0); } @@ -104,7 +104,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // Should not finalize because min delay not passed vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_RequestAfterReport() public { @@ -121,54 +121,38 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // Should not finalize because request was created after last report vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_RevertOnlyFinalizeRole() public { withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); + _warpAndMockOracleReport(); // Try to finalize without proper role vm.expectRevert( abi.encodeWithSelector( - IAccessControl.AccessControlUnauthorizedAccount.selector, - userAlice, - withdrawalQueue.FINALIZE_ROLE() + IAccessControl.AccessControlUnauthorizedAccount.selector, userAlice, withdrawalQueue.FINALIZE_ROLE() ) ); vm.prank(userAlice); - withdrawalQueue.finalize(1); - } - - function test_Finalize_RevertWhenPaused() public { - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); - - // Pause the contract - vm.prank(pauseRoleHolder); - withdrawalQueue.pause(); - - vm.prank(finalizeRoleHolder); - vm.expectRevert(abi.encodeWithSelector(PausableUpgradeable.EnforcedPause.selector)); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_ReturnsZeroWhenWithdrawableInsufficient() public { uint256 stvToRequest = 10 ** STV_DECIMALS; withdrawalQueue.requestWithdrawal(address(this), stvToRequest, 0); - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); + _warpAndMockOracleReport(); - address stakingVault = address(dashboard.STAKING_VAULT()); + address stakingVault = address(dashboard.VAULT()); uint256 vaultBalance = stakingVault.balance; dashboard.mock_setLocked(vaultBalance); // Should not finalize because eth to withdraw is locked vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_PartialDueToWithdrawableLimit() public { @@ -178,15 +162,15 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { withdrawalQueue.requestWithdrawal(address(this), stvRequest1, 0); withdrawalQueue.requestWithdrawal(address(this), stvRequest2, 0); - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); + _warpAndMockOracleReport(); uint256 expectedEthFirst = pool.previewRedeem(stvRequest1); - address stakingVault = address(dashboard.STAKING_VAULT()); + address stakingVault = address(dashboard.VAULT()); uint256 vaultBalance = stakingVault.balance; dashboard.mock_setLocked(vaultBalance - expectedEthFirst); vm.prank(finalizeRoleHolder); - uint256 finalizedCount = withdrawalQueue.finalize(10); + uint256 finalizedCount = withdrawalQueue.finalize(10, address(0)); assertEq(finalizedCount, 1); assertEq(withdrawalQueue.getLastFinalizedRequestId(), 1); @@ -201,19 +185,19 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { pool.mintStethShares(mintedStethShares); withdrawalQueue.requestWithdrawal(address(this), stvToRequest, mintedStethShares); - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); + _warpAndMockOracleReport(); uint256 assetsPreview = pool.previewRedeem(stvToRequest); uint256 assetsToRebalance = pool.STETH().getPooledEthBySharesRoundUp(mintedStethShares); - address stakingVault = address(dashboard.STAKING_VAULT()); + address stakingVault = address(dashboard.VAULT()); vm.deal(stakingVault, assetsPreview); dashboard.mock_setLocked(assetsToRebalance + 1); // block by 1 wei // Should not finalize because eth to withdraw is locked vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_RebalanceWithBlockedButAvailableAssets() public { @@ -223,17 +207,17 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { pool.mintStethShares(mintedStethShares); withdrawalQueue.requestWithdrawal(address(this), stvToRequest, mintedStethShares); - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); + _warpAndMockOracleReport(); uint256 assetsPreview = pool.previewRedeem(stvToRequest); uint256 assetsToRebalance = pool.STETH().getPooledEthBySharesRoundUp(mintedStethShares); - address stakingVault = address(dashboard.STAKING_VAULT()); + address stakingVault = address(dashboard.VAULT()); vm.deal(stakingVault, assetsPreview); dashboard.mock_setLocked(assetsToRebalance); vm.prank(finalizeRoleHolder); - assertEq(withdrawalQueue.finalize(1), 1); + assertEq(withdrawalQueue.finalize(1, address(0)), 1); } function test_Finalize_RebalancePartiallyDueToAvailableBalance() public { @@ -246,100 +230,168 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { pool.mintStethShares(mintedStethShares); uint256 requestId2 = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, mintedStethShares); - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); + _warpAndMockOracleReport(); uint256 assetsRequired = pool.previewRedeem(stvToRequest); - address stakingVault = address(dashboard.STAKING_VAULT()); + address stakingVault = address(dashboard.VAULT()); vm.deal(stakingVault, assetsRequired); dashboard.mock_setLocked(0); vm.prank(finalizeRoleHolder); - uint256 finalizedCount = withdrawalQueue.finalize(10); + uint256 finalizedCount = withdrawalQueue.finalize(10, address(0)); assertEq(finalizedCount, 1); assertTrue(withdrawalQueue.getWithdrawalStatus(requestId1).isFinalized); assertFalse(withdrawalQueue.getWithdrawalStatus(requestId2).isFinalized); } + // Pause & resume request finalization + + function test_Finalize_RevertWhenPaused() public { + bytes32 finalizeFeatureId = withdrawalQueue.FINALIZE_FEATURE(); + vm.prank(finalizePauseRoleHolder); + withdrawalQueue.pauseFinalization(); + + withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + + vm.prank(finalizeRoleHolder); + vm.expectRevert(abi.encodeWithSelector(FeaturePausable.FeaturePauseEnforced.selector, finalizeFeatureId)); + withdrawalQueue.finalize(1, finalizeRoleHolder); + } + + function test_PauseFinalization_RevertWhenCallerUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + address(this), + withdrawalQueue.FINALIZE_PAUSE_ROLE() + ) + ); + withdrawalQueue.pauseFinalization(); + } + + function test_ResumeFinalization_RevertWhenCallerUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + address(this), + withdrawalQueue.FINALIZE_RESUME_ROLE() + ) + ); + withdrawalQueue.resumeFinalization(); + } + + function test_Resume_AllowsFinalizationAfterPause() public { + vm.prank(finalizePauseRoleHolder); + withdrawalQueue.pauseFinalization(); + + vm.prank(finalizeResumeRoleHolder); + withdrawalQueue.resumeFinalization(); + + withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + + _warpAndMockOracleReport(); + + vm.prank(finalizeRoleHolder); + uint256 finalized = withdrawalQueue.finalize(1, finalizeRoleHolder); + assertEq(finalized, 1); + } + // Edge Cases function test_Finalize_ZeroMaxRequests() public { withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); + _warpAndMockOracleReport(); vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(0); + withdrawalQueue.finalize(0, address(0)); } function test_Finalize_NoRequestsToFinalize() public { vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_AlreadyFullyFinalized() public { withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); + _warpAndMockOracleReport(); // First finalization vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); // Try to finalize again vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } function test_Finalize_RevertWhenReportStale() public { withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); + _warpAndMockOracleReport(); - dashboard.VAULT_HUB().mock_setReportFreshness(address(dashboard.STAKING_VAULT()), false); + dashboard.VAULT_HUB().mock_setReportFreshness(address(dashboard.VAULT()), false); vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.VaultReportStale.selector); - withdrawalQueue.finalize(1); + withdrawalQueue.finalize(1, address(0)); } - // Checkpoint Tests + function test_Finalize_RevertWhenFinalizerCannotReceiveFee() public { + uint256 coverage = 0.0001 ether; + vm.prank(finalizeRoleHolder); + withdrawalQueue.setFinalizationGasCostCoverage(coverage); - function test_Finalize_CreatesCheckpoint() public { - withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + RevertingFinalizer finalizer = new RevertingFinalizer(withdrawalQueue); + bytes32 finalizeRole = withdrawalQueue.FINALIZE_ROLE(); - // Verify no checkpoints initially - assertEq(withdrawalQueue.getLastCheckpointIndex(), 0); + vm.prank(owner); + withdrawalQueue.grantRole(finalizeRole, address(finalizer)); - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + assertEq(requestId, 1); - vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(1); + _warpAndMockOracleReport(); - // Verify checkpoint was created - assertEq(withdrawalQueue.getLastCheckpointIndex(), 1); + vm.expectRevert(WithdrawalQueue.CantSendValueRecipientMayHaveReverted.selector); + finalizer.callFinalize(1); + } + + function test_Finalize_RevertWhenWithdrawableInsufficientButAvailableEnough() public { + uint256 stvToRequest = 10 ** STV_DECIMALS; + uint256 expectedAssets = pool.previewRedeem(stvToRequest); + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, 0); + + assertEq(requestId, 1); + + _warpAndMockOracleReport(); + dashboard.mock_setLocked(pool.totalAssets() - expectedAssets + 1); + + vm.prank(finalizeRoleHolder); + vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); + withdrawalQueue.finalize(1, address(0)); } - // Emergency Exit + // Checkpoint Tests - function test_Finalize_DuringEmergencyExit() public { + function test_Finalize_CreatesCheckpoint() public { withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); - // Set very old timestamp to make queue "stuck" - vm.warp(block.timestamp + withdrawalQueue.MAX_ACCEPTABLE_WQ_FINALIZATION_TIME_IN_SECONDS() + 1); + // Verify no checkpoints initially + assertEq(withdrawalQueue.getLastCheckpointIndex(), 0); - // Activate emergency exit - withdrawalQueue.activateEmergencyExit(); - assertTrue(withdrawalQueue.isEmergencyExitActivated()); + _warpAndMockOracleReport(); - // Should be able to finalize without role restriction in emergency - vm.prank(userAlice); // Any user can call - uint256 finalizedCount = withdrawalQueue.finalize(1); + vm.prank(finalizeRoleHolder); + withdrawalQueue.finalize(1, address(0)); - assertEq(finalizedCount, 1); + // Verify checkpoint was created + assertEq(withdrawalQueue.getLastCheckpointIndex(), 1); } // Rewards & penalties @@ -356,9 +408,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { assertEq(totalAssetsAfter, totalAssetsBefore + 10 ether); // Finalize request - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); - vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(1); + _finalizeRequests(1); // Check finalized request has correct ETH amount unaffected by rewards assertEq(withdrawalQueue.getClaimableEther(requestId), expectedEth); @@ -378,11 +428,60 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { uint256 expectedEth = pool.previewRedeem(stvToRequest); // Finalize request - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); - vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(1); + _finalizeRequests(1); // Check finalized request has correct ETH amount unaffected by rewards assertEq(withdrawalQueue.getClaimableEther(requestId), expectedEth); } + + // Exceeding Minted StETH + + function test_Finalize_MultipleRequestsWithExceedingSteth() public { + uint256 mintedStethShares = pool.totalMintingCapacitySharesOf(address(this)) / 3 * 3; + pool.mintStethShares(mintedStethShares); + + // Initially no exceeding minted steth + assertEq(pool.totalExceedingMintedStethShares(), 0); + + // Create multiple withdrawal requests with enough stv to cover liability + uint256 stvPerRequest = pool.balanceOf(address(this)) / 3; + withdrawalQueue.requestWithdrawal(address(this), stvPerRequest, mintedStethShares / 3); + withdrawalQueue.requestWithdrawal(address(this), stvPerRequest, mintedStethShares / 3); + withdrawalQueue.requestWithdrawal(address(this), stvPerRequest, mintedStethShares / 3); + + assertEq(withdrawalQueue.getLastRequestId(), 3); + + // Simulate vault rebalance to create exceeding minted steth + uint256 liabilityShares = dashboard.liabilityShares(); + assertGt(liabilityShares, 0); + dashboard.rebalanceVaultWithShares(liabilityShares / 2); + + // Exceeding minted steth should now be present + assertGt(pool.totalExceedingMintedStethShares(), 0); + + // Finalize all requests + _finalizeRequests(3); + + // Verify no unfinalized requests remain + assertEq(withdrawalQueue.unfinalizedRequestsNumber(), 0); + + // Exceeding steth should be consumed during finalization + assertEq(pool.totalExceedingMintedStethShares(), 0); + } +} + +contract RevertingFinalizer { + WithdrawalQueue public immutable withdrawalQueue; + + constructor(WithdrawalQueue _withdrawalQueue) { + withdrawalQueue = _withdrawalQueue; + } + + function callFinalize(uint256 maxRequests) external { + withdrawalQueue.finalize(maxRequests, address(0)); + } + + receive() external payable { + revert("cannot receive"); + } } diff --git a/test/unit/withdrawal-queue/GasCostCoverage.test.sol b/test/unit/withdrawal-queue/GasCostCoverage.test.sol new file mode 100644 index 0000000..74e07db --- /dev/null +++ b/test/unit/withdrawal-queue/GasCostCoverage.test.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {Test} from "forge-std/Test.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; + +contract GasCostCoverageTest is Test, SetupWithdrawalQueue { + function setUp() public override { + super.setUp(); + + vm.deal(address(this), 200_000 ether); + vm.deal(finalizeRoleHolder, 10 ether); + + pool.depositETH{value: 100_000 ether}(address(this), address(0)); + } + + function _setGasCostCoverage(uint256 coverage) internal { + vm.prank(finalizeRoleHolder); + withdrawalQueue.setFinalizationGasCostCoverage(coverage); + } + + function test_FinalizeGasCostCoverage_ZeroCoverageDoesNotPayFinalizer() public { + uint256 initialBalance = finalizeRoleHolder.balance; + _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); + + assertEq(finalizeRoleHolder.balance, initialBalance); + } + + function test_FinalizeGasCostCoverage_PaysFinalizerWhenSet() public { + uint256 coverage = 0.0005 ether; + uint256 initialBalance = finalizeRoleHolder.balance; + + _setGasCostCoverage(coverage); + _requestWithdrawalAndFinalize(10 ** STV_DECIMALS); + + assertEq(finalizeRoleHolder.balance, initialBalance + coverage); + } + + function test_FinalizeGasCostCoverage_ReducesClaimByCoverage() public { + uint256 coverage = 0.0005 ether; + _setGasCostCoverage(coverage); + + uint256 stvToRequest = 10 ** STV_DECIMALS; + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, 0); + uint256 expectedAssets = pool.previewRedeem(stvToRequest); + _finalizeRequests(1); + + uint256 balanceBefore = address(this).balance; + uint256 claimed = withdrawalQueue.claimWithdrawal(address(this), requestId); + + assertEq(claimed, expectedAssets - coverage); + assertEq(address(this).balance, balanceBefore + claimed); + } + + function test_FinalizeGasCostCoverage_ReducesClaimableByCoverage() public { + uint256 coverage = 0.0005 ether; + _setGasCostCoverage(coverage); + + uint256 stvToRequest = 10 ** STV_DECIMALS; + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, 0); + uint256 expectedAssets = pool.previewRedeem(stvToRequest); + _finalizeRequests(1); + + assertEq(withdrawalQueue.getClaimableEther(requestId), expectedAssets - coverage); + } + + function test_FinalizeGasCostCoverage_RequestWithRebalance() public { + uint256 coverage = 0.0005 ether; + _setGasCostCoverage(coverage); + + uint256 mintedStethShares = 10 ** ASSETS_DECIMALS; + uint256 stvToRequest = 2 * 10 ** STV_DECIMALS; + pool.mintStethShares(mintedStethShares); + + uint256 totalAssets = pool.previewRedeem(stvToRequest); + uint256 assetsToRebalance = pool.STETH().getPooledEthBySharesRoundUp(mintedStethShares); + uint256 expectedClaimable = totalAssets - assetsToRebalance - coverage; + assertGt(expectedClaimable, 0); + + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, mintedStethShares); + _finalizeRequests(1); + + assertEq(withdrawalQueue.getClaimableEther(requestId), expectedClaimable); + } + + function test_FinalizeGasCostCoverage_CoverageCapsToRemainingAssets() public { + uint256 coverage = withdrawalQueue.MAX_GAS_COST_COVERAGE(); + uint256 minValue = withdrawalQueue.MIN_WITHDRAWAL_VALUE(); + _setGasCostCoverage(coverage); + + uint256 stvToRequest = (10 ** STV_DECIMALS / 1 ether) * minValue; + uint256 totalAssets = pool.previewRedeem(stvToRequest); + assertEq(totalAssets, minValue); + + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, 0); + dashboard.mock_simulateRewards(-int256(pool.totalAssets() - 1 ether)); + + uint256 finalizerBalanceBefore = finalizeRoleHolder.balance; + _finalizeRequests(1); + uint256 finalizerBalanceAfter = finalizeRoleHolder.balance; + + assertGt(finalizerBalanceAfter, finalizerBalanceBefore); + assertLt(finalizerBalanceAfter - finalizerBalanceBefore, coverage); + + assertEq(withdrawalQueue.getClaimableEther(requestId), 0); + } + + function test_FinalizeGasCostCoverage_DifferentGasCostRecipient() public { + uint256 coverage = 0.0005 ether; + _setGasCostCoverage(coverage); + + address recipient = makeAddr("finalizerRecipient"); + withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + + uint256 finalizerBalanceBefore = finalizeRoleHolder.balance; + uint256 recipientBalanceBefore = recipient.balance; + + _warpAndMockOracleReport(); + vm.prank(finalizeRoleHolder); + uint256 finalizedRequests = withdrawalQueue.finalize(1, recipient); + + assertEq(finalizedRequests, 1); + + uint256 finalizerBalanceAfter = finalizeRoleHolder.balance; + uint256 recipientBalanceAfter = recipient.balance; + + assertEq(finalizerBalanceAfter, finalizerBalanceBefore); + assertEq(recipientBalanceAfter - recipientBalanceBefore, coverage); + } + + // Receive ETH for claiming tests + receive() external payable {} +} diff --git a/test/unit/withdrawal-queue/GasCostCoverageConfig.test.sol b/test/unit/withdrawal-queue/GasCostCoverageConfig.test.sol new file mode 100644 index 0000000..9133fc6 --- /dev/null +++ b/test/unit/withdrawal-queue/GasCostCoverageConfig.test.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; + +contract GasCostCoverageConfigTest is Test, SetupWithdrawalQueue { + function setUp() public override { + super.setUp(); + + pool.depositETH{value: 1_000 ether}(address(this), address(0)); + } + + // Default value + + function test_GetFinalizationGasCostCoverage_DefaultZero() public view { + assertEq(withdrawalQueue.getFinalizationGasCostCoverage(), 0); + } + + // Setter + + function test_SetFinalizationGasCostCoverage_UpdatesValue() public { + uint256 coverage = 0.0001 ether; + + vm.prank(finalizeRoleHolder); + withdrawalQueue.setFinalizationGasCostCoverage(coverage); + assertEq(withdrawalQueue.getFinalizationGasCostCoverage(), coverage); + } + + function test_SetFinalizationGasCostCoverage_RevertAboveMax() public { + uint256 coverage = withdrawalQueue.MAX_GAS_COST_COVERAGE() + 1; + + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.GasCostCoverageTooLarge.selector, coverage)); + vm.prank(finalizeRoleHolder); + withdrawalQueue.setFinalizationGasCostCoverage(coverage); + } + + function test_SetFinalizationGasCostCoverage_MaxValueCanBeSet() public { + uint256 coverage = withdrawalQueue.MAX_GAS_COST_COVERAGE(); + + vm.prank(finalizeRoleHolder); + withdrawalQueue.setFinalizationGasCostCoverage(coverage); + assertEq(withdrawalQueue.getFinalizationGasCostCoverage(), coverage); + } + + // Access control + + function test_SetFinalizationGasCostCoverage_CanBeCalledByFinalizeRole() public { + vm.prank(finalizeRoleHolder); + withdrawalQueue.setFinalizationGasCostCoverage(0.0001 ether); + } + + function test_SetFinalizationGasCostCoverage_CantBeCalledStranger() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), withdrawalQueue.FINALIZE_ROLE() + ) + ); + withdrawalQueue.setFinalizationGasCostCoverage(0.0001 ether); + } +} diff --git a/test/unit/withdrawal-queue/HappyPath.test.sol b/test/unit/withdrawal-queue/HappyPath.test.sol index bfec36a..75cabde 100644 --- a/test/unit/withdrawal-queue/HappyPath.test.sol +++ b/test/unit/withdrawal-queue/HappyPath.test.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Test} from "forge-std/Test.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; -import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract WithdrawalQueueHappyPathTest is Test, SetupWithdrawalQueue { function setUp() public override { @@ -36,9 +36,7 @@ contract WithdrawalQueueHappyPathTest is Test, SetupWithdrawalQueue { uint256 firstRequestId = withdrawalQueue.requestWithdrawal(address(this), firstWithdrawStv, 0); // Request 1: Check withdrawal status - WithdrawalQueue.WithdrawalRequestStatus memory firstStatus = withdrawalQueue.getWithdrawalStatus( - firstRequestId - ); + WithdrawalQueue.WithdrawalRequestStatus memory firstStatus = withdrawalQueue.getWithdrawalStatus(firstRequestId); assertEq(firstStatus.amountOfStethShares, 0); assertEq(firstStatus.amountOfAssets, 2 ether); // initial deposit / 5 assertEq(firstStatus.amountOfStv, firstWithdrawStv); @@ -106,25 +104,19 @@ contract WithdrawalQueueHappyPathTest is Test, SetupWithdrawalQueue { assertEq(remainingStv, initialStv - firstWithdrawStv - secondWithdrawStv); // Request 2: Check withdrawal status - WithdrawalQueue.WithdrawalRequestStatus memory secondStatus = withdrawalQueue.getWithdrawalStatus( - secondRequestId - ); + WithdrawalQueue.WithdrawalRequestStatus memory secondStatus = + withdrawalQueue.getWithdrawalStatus(secondRequestId); assertEq(secondStatus.amountOfStv, secondWithdrawStv); assertEq(secondStatus.owner, address(this)); // Request 3: Request another withdrawal with rebalance uint256 thirdWithdrawStv = remainingStv; uint256 thirdSharesToRebalance = mintedSharesRemaining; - uint256 thirdRequestId = withdrawalQueue.requestWithdrawal( - address(this), - thirdWithdrawStv, - thirdSharesToRebalance - ); + uint256 thirdRequestId = + withdrawalQueue.requestWithdrawal(address(this), thirdWithdrawStv, thirdSharesToRebalance); // Request 3: Check withdrawal status - WithdrawalQueue.WithdrawalRequestStatus memory thirdStatus = withdrawalQueue.getWithdrawalStatus( - thirdRequestId - ); + WithdrawalQueue.WithdrawalRequestStatus memory thirdStatus = withdrawalQueue.getWithdrawalStatus(thirdRequestId); assertEq(thirdStatus.amountOfStv, thirdWithdrawStv); assertEq(thirdStatus.amountOfStethShares, thirdSharesToRebalance); assertEq(thirdStatus.owner, address(this)); @@ -136,7 +128,7 @@ contract WithdrawalQueueHappyPathTest is Test, SetupWithdrawalQueue { // Request 2 & 3: Try to finalize both requests without waiting period - should fail vm.prank(finalizeRoleHolder); vm.expectRevert(WithdrawalQueue.NoRequestsToFinalize.selector); - withdrawalQueue.finalize(2); + withdrawalQueue.finalize(2, address(0)); // Request 2 & 3: Finalize both requests after waiting period assertEq(pool.balanceOf(address(withdrawalQueue)), secondWithdrawStv + thirdWithdrawStv); @@ -195,7 +187,7 @@ contract WithdrawalQueueHappyPathTest is Test, SetupWithdrawalQueue { // Final check: Withdrawal Queue assertEq(withdrawalQueue.getLastRequestId(), 3); assertEq(withdrawalQueue.getLastFinalizedRequestId(), 3); - assertEq(withdrawalQueue.unfinalizedRequestNumber(), 0); + assertEq(withdrawalQueue.unfinalizedRequestsNumber(), 0); assertEq(pool.balanceOf(address(withdrawalQueue)), 0); assertEq(pool.mintedStethSharesOf(address(withdrawalQueue)), 0); diff --git a/test/unit/withdrawal-queue/InitialPause.test.sol b/test/unit/withdrawal-queue/InitialPause.test.sol new file mode 100644 index 0000000..11877e3 --- /dev/null +++ b/test/unit/withdrawal-queue/InitialPause.test.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {Test} from "forge-std/Test.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; +import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; + +contract InitialPauseTest is Test { + WithdrawalQueue internal withdrawalQueueProxy; + WithdrawalQueue internal withdrawalQueueImpl; + address internal owner; + address internal finalizeRoleHolder; + + function setUp() public { + owner = makeAddr("owner"); + finalizeRoleHolder = makeAddr("finalizeRoleHolder"); + + withdrawalQueueImpl = new WithdrawalQueue( + makeAddr("pool"), + makeAddr("dashboard"), + makeAddr("vaultHub"), + makeAddr("steth"), + makeAddr("stakingVault"), + makeAddr("lazyOracle"), + 1 days, + true + ); + OssifiableProxy proxy = new OssifiableProxy(address(withdrawalQueueImpl), owner, ""); + withdrawalQueueProxy = WithdrawalQueue(payable(proxy)); + } + + function test_InitialPause_ImplementationIsPaused() public view { + assertTrue(withdrawalQueueImpl.isFeaturePaused(withdrawalQueueImpl.WITHDRAWALS_FEATURE())); + assertTrue(withdrawalQueueImpl.isFeaturePaused(withdrawalQueueImpl.FINALIZE_FEATURE())); + } + + function test_InitialPause_ProxyIsNotPaused() public view { + assertFalse(withdrawalQueueProxy.isFeaturePaused(withdrawalQueueProxy.WITHDRAWALS_FEATURE())); + assertFalse(withdrawalQueueProxy.isFeaturePaused(withdrawalQueueProxy.FINALIZE_FEATURE())); + } +} diff --git a/test/unit/withdrawal-queue/Initialization.test.sol b/test/unit/withdrawal-queue/Initialization.test.sol new file mode 100644 index 0000000..1446b0b --- /dev/null +++ b/test/unit/withdrawal-queue/Initialization.test.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {Test} from "forge-std/Test.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; +import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; + +contract InitializationTest is Test { + WithdrawalQueue internal withdrawalQueueProxy; + WithdrawalQueue internal withdrawalQueueImpl; + address internal owner; + address internal finalizeRoleHolder; + + function setUp() public { + owner = makeAddr("owner"); + finalizeRoleHolder = makeAddr("finalizeRoleHolder"); + + withdrawalQueueImpl = new WithdrawalQueue( + makeAddr("pool"), + makeAddr("dashboard"), + makeAddr("vaultHub"), + makeAddr("steth"), + makeAddr("stakingVault"), + makeAddr("lazyOracle"), + 1 days, + true + ); + OssifiableProxy proxy = new OssifiableProxy(address(withdrawalQueueImpl), owner, ""); + withdrawalQueueProxy = WithdrawalQueue(payable(proxy)); + } + + function test_Initialize_RevertOnImplementation() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + withdrawalQueueImpl.initialize(address(0), finalizeRoleHolder); + } + + function test_Initialize_RevertWhenAdminZero() public { + vm.expectRevert(WithdrawalQueue.ZeroAddress.selector); + withdrawalQueueProxy.initialize(address(0), finalizeRoleHolder); + } + + function test_Initialize_RevertWhenCalledTwice() public { + withdrawalQueueProxy.initialize(owner, finalizeRoleHolder); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + withdrawalQueueProxy.initialize(owner, finalizeRoleHolder); + } +} diff --git a/test/unit/withdrawal-queue/Rebalance.test.sol b/test/unit/withdrawal-queue/Rebalance.test.sol index 31527ef..c371273 100644 --- a/test/unit/withdrawal-queue/Rebalance.test.sol +++ b/test/unit/withdrawal-queue/Rebalance.test.sol @@ -1,8 +1,9 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {Test} from "forge-std/Test.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract FinalizationTest is Test, SetupWithdrawalQueue { @@ -16,7 +17,7 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { function test_RebalanceFinalization_InitialState() public view { assertEq(withdrawalQueue.getLastRequestId(), 0); assertEq(withdrawalQueue.getLastFinalizedRequestId(), 0); - assertEq(withdrawalQueue.unfinalizedRequestNumber(), 0); + assertEq(withdrawalQueue.unfinalizedRequestsNumber(), 0); assertEq(withdrawalQueue.unfinalizedAssets(), 0); assertEq(withdrawalQueue.unfinalizedStv(), 0); @@ -61,12 +62,13 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { 1, 1, totalAssets - assetsToRebalance, + 0, stvToRequest - stvToRebalance, stvToRebalance, mintedStethShares, block.timestamp ); - uint256 finalizedCount = withdrawalQueue.finalize(1); + uint256 finalizedCount = withdrawalQueue.finalize(1, address(0)); // Verify finalization succeeded assertEq(finalizedCount, 1); @@ -144,6 +146,10 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { // which result in the request exceeding the reserve ratio dashboard.mock_simulateRewards(-90_000 ether); + // Enable loss socialization + vm.prank(owner); + pool.setMaxLossSocializationBP(100_00); // 100% + // Finalize request uint256 strangerAssetsBefore = pool.previewRedeem(pool.balanceOf(address(userAlice))); _finalizeRequests(1); @@ -179,4 +185,30 @@ contract FinalizationTest is Test, SetupWithdrawalQueue { assertEq(pool.totalMintedStethShares(), 0); assertEq(pool.totalExceedingMintedStethShares(), 0); } + + function test_RebalanceFinalization_SocializedLossEmitsEvent() public { + uint256 mintedStethShares = 10 ** ASSETS_DECIMALS; + uint256 stvToRequest = 2 * 10 ** STV_DECIMALS; + + pool.mintStethShares(mintedStethShares); + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), stvToRequest, mintedStethShares); + + // Apply large penalty so position becomes undercollateralized + dashboard.mock_simulateRewards(-90_000 ether); + assertGt(steth.getPooledEthBySharesRoundUp(mintedStethShares), pool.previewRedeem(stvToRequest)); + + _warpAndMockOracleReport(); + + // Enable socialization + vm.prank(owner); + pool.setMaxLossSocializationBP(100_00); // 100% + + vm.expectEmit(true, true, true, false, address(pool)); + emit StvStETHPool.SocializedLoss(0, 0, 0); + + vm.prank(finalizeRoleHolder); + withdrawalQueue.finalize(1, address(0)); + + assertEq(withdrawalQueue.getClaimableEther(requestId), 0); + } } diff --git a/test/unit/withdrawal-queue/RebalancingDisabled.test.sol b/test/unit/withdrawal-queue/RebalancingDisabled.test.sol new file mode 100644 index 0000000..985fc8c --- /dev/null +++ b/test/unit/withdrawal-queue/RebalancingDisabled.test.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {StvStETHPool} from "src/StvStETHPool.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; +import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; +import {MockDashboard, MockDashboardFactory} from "test/mocks/MockDashboard.sol"; +import {MockLazyOracle} from "test/mocks/MockLazyOracle.sol"; +import {MockStETH} from "test/mocks/MockStETH.sol"; +import {MockVaultHub} from "test/mocks/MockVaultHub.sol"; + +contract RebalancingDisabledTest is Test { + WithdrawalQueue public withdrawalQueue; + StvStETHPool public pool; + MockLazyOracle public lazyOracle; + MockDashboard public dashboard; + MockVaultHub public vaultHub; + MockStETH public steth; + + address internal owner; + address internal finalizeRoleHolder; + + function setUp() public { + owner = makeAddr("owner"); + finalizeRoleHolder = makeAddr("finalizeRoleHolder"); + + // Deploy mocks + dashboard = new MockDashboardFactory().createMockDashboard(owner); + lazyOracle = new MockLazyOracle(); + steth = dashboard.STETH(); + vaultHub = dashboard.VAULT_HUB(); + + WithdrawalQueue impl = new WithdrawalQueue( + address(pool), + address(dashboard), + address(vaultHub), + address(steth), + address(dashboard.VAULT()), + address(lazyOracle), + 1 days, + false + ); + OssifiableProxy proxy = new OssifiableProxy(address(impl), owner, ""); + withdrawalQueue = WithdrawalQueue(payable(proxy)); + } + + function test_RequestWithdrawal_RevertWhenRebalancingDisabled() public { + vm.expectRevert(WithdrawalQueue.RebalancingIsNotSupported.selector); + withdrawalQueue.requestWithdrawal(address(this), 1, 1); + } + + function test_RequestWithdrawalBatch_RevertWhenRebalancingDisabled() public { + uint256[] memory stvAmounts = new uint256[](1); + stvAmounts[0] = 1; + + uint256[] memory stethShares = new uint256[](1); + stethShares[0] = 1; + + vm.expectRevert(WithdrawalQueue.RebalancingIsNotSupported.selector); + withdrawalQueue.requestWithdrawalBatch(address(this), stvAmounts, stethShares); + } +} diff --git a/test/unit/withdrawal-queue/RequestCreation.test.sol b/test/unit/withdrawal-queue/RequestCreation.test.sol index 199b86d..65735fe 100644 --- a/test/unit/withdrawal-queue/RequestCreation.test.sol +++ b/test/unit/withdrawal-queue/RequestCreation.test.sol @@ -1,10 +1,11 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; -import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; +import {FeaturePausable} from "src/utils/FeaturePausable.sol"; contract RequestCreationTest is Test, SetupWithdrawalQueue { function setUp() public override { @@ -25,7 +26,7 @@ contract RequestCreationTest is Test, SetupWithdrawalQueue { function test_InitialState_NoRequests() public view { assertEq(withdrawalQueue.getLastRequestId(), 0); assertEq(withdrawalQueue.getLastFinalizedRequestId(), 0); - assertEq(withdrawalQueue.unfinalizedRequestNumber(), 0); + assertEq(withdrawalQueue.unfinalizedRequestsNumber(), 0); assertEq(withdrawalQueue.unfinalizedAssets(), 0); assertEq(withdrawalQueue.unfinalizedStv(), 0); } @@ -33,12 +34,15 @@ contract RequestCreationTest is Test, SetupWithdrawalQueue { function test_InitialState_CorrectRoles() public view { assertTrue(withdrawalQueue.hasRole(withdrawalQueue.DEFAULT_ADMIN_ROLE(), owner)); assertTrue(withdrawalQueue.hasRole(withdrawalQueue.FINALIZE_ROLE(), finalizeRoleHolder)); - assertTrue(withdrawalQueue.hasRole(withdrawalQueue.PAUSE_ROLE(), pauseRoleHolder)); - assertTrue(withdrawalQueue.hasRole(withdrawalQueue.RESUME_ROLE(), resumeRoleHolder)); + assertTrue(withdrawalQueue.hasRole(withdrawalQueue.WITHDRAWALS_PAUSE_ROLE(), withdrawalsPauseRoleHolder)); + assertTrue(withdrawalQueue.hasRole(withdrawalQueue.WITHDRAWALS_RESUME_ROLE(), withdrawalsResumeRoleHolder)); + assertTrue(withdrawalQueue.hasRole(withdrawalQueue.FINALIZE_PAUSE_ROLE(), finalizePauseRoleHolder)); + assertTrue(withdrawalQueue.hasRole(withdrawalQueue.FINALIZE_RESUME_ROLE(), finalizeResumeRoleHolder)); } function test_InitialState_NotPaused() public view { - assertFalse(withdrawalQueue.paused()); + assertFalse(withdrawalQueue.isFeaturePaused(withdrawalQueue.WITHDRAWALS_FEATURE())); + assertFalse(withdrawalQueue.isFeaturePaused(withdrawalQueue.FINALIZE_FEATURE())); } // Single Request Tests @@ -49,7 +53,7 @@ contract RequestCreationTest is Test, SetupWithdrawalQueue { assertEq(requestId, 1); assertEq(withdrawalQueue.getLastRequestId(), 1); - assertEq(withdrawalQueue.unfinalizedRequestNumber(), 1); + assertEq(withdrawalQueue.unfinalizedRequestsNumber(), 1); assertEq(withdrawalQueue.unfinalizedStv(), stvToRequest); // Check request details @@ -179,32 +183,76 @@ contract RequestCreationTest is Test, SetupWithdrawalQueue { withdrawalQueue.requestWithdrawalBatch(address(this), stvAmounts, stethShares); } - function test_RequestWithdrawal_RevertOnTooSmallAmount() public { - uint256 tinyStvAmount = pool.previewWithdraw(withdrawalQueue.MIN_WITHDRAWAL_AMOUNT()) - 1; + function test_RequestWithdrawal_RevertOnTooSmallValue() public { + uint256 tinyStvAmount = pool.previewWithdraw(withdrawalQueue.MIN_WITHDRAWAL_VALUE()) - 1; uint256 expectedAssets = pool.previewRedeem(tinyStvAmount); - vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestAmountTooSmall.selector, expectedAssets)); + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestValueTooSmall.selector, expectedAssets)); withdrawalQueue.requestWithdrawal(address(this), tinyStvAmount, 0); } + function test_RequestWithdrawal_RevertOnTooSmallValueWithRebalance() public { + uint256 minStvAmount = pool.previewWithdraw(withdrawalQueue.MIN_WITHDRAWAL_VALUE()); + uint256 minMintedShares = 1; + uint256 expectedAssets = pool.previewRedeem(minStvAmount) - steth.getPooledEthBySharesRoundUp(minMintedShares); + + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestValueTooSmall.selector, expectedAssets)); + withdrawalQueue.requestWithdrawal(address(this), minStvAmount, minMintedShares); + } + function test_RequestWithdrawal_RevertOnTooLargeAmount() public { uint256 extraAssetsWei = 10 ** (STV_DECIMALS - ASSETS_DECIMALS); - uint256 hugeStvAmount = pool.previewWithdraw(withdrawalQueue.MAX_WITHDRAWAL_AMOUNT()) + extraAssetsWei; + uint256 hugeStvAmount = pool.previewWithdraw(withdrawalQueue.MAX_WITHDRAWAL_ASSETS()) + extraAssetsWei; uint256 expectedAssets = pool.previewRedeem(hugeStvAmount); - vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestAmountTooLarge.selector, expectedAssets)); + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.RequestAssetsTooLarge.selector, expectedAssets)); withdrawalQueue.requestWithdrawal(address(this), hugeStvAmount, 0); } + // Pause & resume withdrawal requests submission + function test_RequestWithdrawal_RevertWhenPaused() public { - vm.prank(pauseRoleHolder); - withdrawalQueue.pause(); + bytes32 withdrawalsFeatureId = withdrawalQueue.WITHDRAWALS_FEATURE(); + vm.prank(withdrawalsPauseRoleHolder); + withdrawalQueue.pauseWithdrawals(); - vm.prank(address(this)); - vm.expectRevert(abi.encodeWithSelector(PausableUpgradeable.EnforcedPause.selector)); + vm.expectRevert(abi.encodeWithSelector(FeaturePausable.FeaturePauseEnforced.selector, withdrawalsFeatureId)); withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); } + function test_PauseWithdrawals_RevertWhenCallerUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + address(this), + withdrawalQueue.WITHDRAWALS_PAUSE_ROLE() + ) + ); + withdrawalQueue.pauseWithdrawals(); + } + + function test_ResumeWithdrawals_RevertWhenCallerUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + address(this), + withdrawalQueue.WITHDRAWALS_RESUME_ROLE() + ) + ); + withdrawalQueue.resumeWithdrawals(); + } + + function test_Resume_AllowsRequestsAfterPause() public { + vm.prank(withdrawalsPauseRoleHolder); + withdrawalQueue.pauseWithdrawals(); + + vm.prank(withdrawalsResumeRoleHolder); + withdrawalQueue.resumeWithdrawals(); + + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + assertEq(requestId, 1); + } + // Edge cases function test_RequestWithdrawal_ReversOnZeroRecipient() public { @@ -218,8 +266,8 @@ contract RequestCreationTest is Test, SetupWithdrawalQueue { } function test_RequestWithdrawal_ExactMinAmount() public { - // Calculate STV amount needed for MIN_WITHDRAWAL_AMOUNT - uint256 minAmount = withdrawalQueue.MIN_WITHDRAWAL_AMOUNT(); + // Calculate STV amount needed for MAX_WITHDRAWAL_ASSETS + uint256 minAmount = withdrawalQueue.MAX_WITHDRAWAL_ASSETS(); uint256 stvAmount = pool.previewWithdraw(minAmount); // This should succeed @@ -228,8 +276,8 @@ contract RequestCreationTest is Test, SetupWithdrawalQueue { } function test_RequestWithdrawal_ExactMaxAmount() public { - // Calculate STV amount needed for MAX_WITHDRAWAL_AMOUNT - uint256 maxAmount = withdrawalQueue.MAX_WITHDRAWAL_AMOUNT(); + // Calculate STV amount needed for MAX_WITHDRAWAL_ASSETS + uint256 maxAmount = withdrawalQueue.MAX_WITHDRAWAL_ASSETS(); uint256 stvAmount = pool.previewWithdraw(maxAmount); // This should succeed @@ -289,6 +337,20 @@ contract RequestCreationTest is Test, SetupWithdrawalQueue { assertEq(withdrawalQueue.unfinalizedAssets(), 0); } + function test_UnfinalizedStats_TrackStethShares() public { + uint256 stvAmount = 2 * 10 ** STV_DECIMALS; + uint256 mintedShares = 10 ** ASSETS_DECIMALS; + + pool.mintStethShares(mintedShares); + withdrawalQueue.requestWithdrawal(address(this), stvAmount, mintedShares); + + assertEq(withdrawalQueue.unfinalizedStethShares(), mintedShares); + + _finalizeRequests(1); + + assertEq(withdrawalQueue.unfinalizedStethShares(), 0); + } + // Receive function to accept ETH refunds receive() external payable {} } diff --git a/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol b/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol index 0c395bb..2dff40a 100644 --- a/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol +++ b/test/unit/withdrawal-queue/SetupWithdrawalQueue.sol @@ -1,29 +1,32 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Test} from "forge-std/Test.sol"; -import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; -import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; -import {MockLazyOracle} from "test/mocks/MockLazyOracle.sol"; +import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; +import {OssifiableProxy} from "src/proxy/OssifiableProxy.sol"; import {MockDashboard, MockDashboardFactory} from "test/mocks/MockDashboard.sol"; +import {MockLazyOracle} from "test/mocks/MockLazyOracle.sol"; import {MockStETH} from "test/mocks/MockStETH.sol"; +import {MockVaultHub} from "test/mocks/MockVaultHub.sol"; abstract contract SetupWithdrawalQueue is Test { WithdrawalQueue public withdrawalQueue; StvStETHPool public pool; MockLazyOracle public lazyOracle; MockDashboard public dashboard; + MockVaultHub public vaultHub; MockStETH public steth; address public owner; address public finalizeRoleHolder; - address public pauseRoleHolder; - address public resumeRoleHolder; + address public finalizePauseRoleHolder; + address public finalizeResumeRoleHolder; + address public withdrawalsPauseRoleHolder; + address public withdrawalsResumeRoleHolder; address public userAlice; address public userBob; - uint256 public constant MAX_ACCEPTABLE_WQ_FINALIZATION_TIME = 7 days; uint256 public constant MIN_WITHDRAWAL_DELAY_TIME = 1 days; uint256 public constant initialDeposit = 1 ether; uint256 public constant reserveRatioGapBP = 5_00; // 5% @@ -35,8 +38,10 @@ abstract contract SetupWithdrawalQueue is Test { // Create addresses owner = makeAddr("owner"); finalizeRoleHolder = makeAddr("finalizeRoleHolder"); - pauseRoleHolder = makeAddr("pauseRoleHolder"); - resumeRoleHolder = makeAddr("resumeRoleHolder"); + finalizePauseRoleHolder = makeAddr("finalizePauseRoleHolder"); + finalizeResumeRoleHolder = makeAddr("finalizeResumeRoleHolder"); + withdrawalsPauseRoleHolder = makeAddr("withdrawalsPauseRoleHolder"); + withdrawalsResumeRoleHolder = makeAddr("withdrawalsResumeRoleHolder"); userAlice = makeAddr("userAlice"); userBob = makeAddr("userBob"); @@ -49,18 +54,14 @@ abstract contract SetupWithdrawalQueue is Test { dashboard = new MockDashboardFactory().createMockDashboard(owner); lazyOracle = new MockLazyOracle(); steth = dashboard.STETH(); + vaultHub = dashboard.VAULT_HUB(); // Fund dashboard dashboard.fund{value: initialDeposit}(); // Deploy StvStETHPool proxy with temporary implementation StvStETHPool tempImpl = new StvStETHPool( - address(dashboard), - false, - reserveRatioGapBP, - address(0), - address(0), - keccak256("test.wq.pool") + address(dashboard), false, reserveRatioGapBP, address(0), address(0), keccak256("test.wq.pool") ); OssifiableProxy poolProxy = new OssifiableProxy(address(tempImpl), owner, ""); pool = StvStETHPool(payable(poolProxy)); @@ -69,11 +70,10 @@ abstract contract SetupWithdrawalQueue is Test { WithdrawalQueue wqImpl = new WithdrawalQueue( address(pool), address(dashboard), - address(dashboard.VAULT_HUB()), + address(vaultHub), address(steth), - address(dashboard.STAKING_VAULT()), + address(dashboard.VAULT()), address(lazyOracle), - MAX_ACCEPTABLE_WQ_FINALIZATION_TIME, MIN_WITHDRAWAL_DELAY_TIME, true ); @@ -86,8 +86,10 @@ abstract contract SetupWithdrawalQueue is Test { // Grant additional roles vm.startPrank(owner); - withdrawalQueue.grantRole(withdrawalQueue.PAUSE_ROLE(), pauseRoleHolder); - withdrawalQueue.grantRole(withdrawalQueue.RESUME_ROLE(), resumeRoleHolder); + withdrawalQueue.grantRole(withdrawalQueue.WITHDRAWALS_PAUSE_ROLE(), withdrawalsPauseRoleHolder); + withdrawalQueue.grantRole(withdrawalQueue.WITHDRAWALS_RESUME_ROLE(), withdrawalsResumeRoleHolder); + withdrawalQueue.grantRole(withdrawalQueue.FINALIZE_PAUSE_ROLE(), finalizePauseRoleHolder); + withdrawalQueue.grantRole(withdrawalQueue.FINALIZE_RESUME_ROLE(), finalizeResumeRoleHolder); vm.stopPrank(); // Set oracle timestamp to current time @@ -117,9 +119,14 @@ abstract contract SetupWithdrawalQueue is Test { } function _finalizeRequests(uint256 _maxRequests) internal { + _warpAndMockOracleReport(); + + vm.prank(finalizeRoleHolder); + withdrawalQueue.finalize(_maxRequests, address(0)); + } + + function _warpAndMockOracleReport() internal { lazyOracle.mock__updateLatestReportTimestamp(block.timestamp); vm.warp(MIN_WITHDRAWAL_DELAY_TIME + 1 + block.timestamp); - vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(_maxRequests); } } diff --git a/test/unit/withdrawal-queue/Views.test.sol b/test/unit/withdrawal-queue/Views.test.sol index 13b3c13..e4c8bde 100644 --- a/test/unit/withdrawal-queue/Views.test.sol +++ b/test/unit/withdrawal-queue/Views.test.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {Test} from "forge-std/Test.sol"; import {SetupWithdrawalQueue} from "./SetupWithdrawalQueue.sol"; +import {Test} from "forge-std/Test.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; contract ViewsTest is Test, SetupWithdrawalQueue { @@ -48,9 +48,7 @@ contract ViewsTest is Test, SetupWithdrawalQueue { assertFalse(statuses[1].isFinalized); lazyOracle.mock__updateLatestReportTimestamp(block.timestamp); - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); - vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(1); + _finalizeRequests(1); WithdrawalQueue.WithdrawalRequestStatus memory statusSingle = withdrawalQueue.getWithdrawalStatus(requestId1); assertTrue(statusSingle.isFinalized); @@ -72,6 +70,18 @@ contract ViewsTest is Test, SetupWithdrawalQueue { withdrawalQueue.getWithdrawalStatusBatch(ids); } + function test_GetWithdrawalStatusBatch_RevertWhenArrayContainsZero() public { + pool.depositETH{value: 100 ether}(address(this), address(0)); + uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); + + uint256[] memory ids = new uint256[](2); + ids[0] = requestId; + ids[1] = 0; + + vm.expectRevert(abi.encodeWithSelector(WithdrawalQueue.InvalidRequestId.selector, 0)); + withdrawalQueue.getWithdrawalStatusBatch(ids); + } + function test_GetClaimableEther_ViewLifecycle() public { pool.depositETH{value: 100 ether}(address(this), address(0)); uint256 requestId = withdrawalQueue.requestWithdrawal(address(this), 10 ** STV_DECIMALS, 0); @@ -79,17 +89,12 @@ contract ViewsTest is Test, SetupWithdrawalQueue { assertEq(withdrawalQueue.getClaimableEther(requestId), 0); lazyOracle.mock__updateLatestReportTimestamp(block.timestamp); - vm.warp(block.timestamp + MIN_WITHDRAWAL_DELAY_TIME + 1); - vm.prank(finalizeRoleHolder); - withdrawalQueue.finalize(1); + _finalizeRequests(1); uint256[] memory requestIds = new uint256[](1); requestIds[0] = requestId; - uint256[] memory hints = withdrawalQueue.findCheckpointHintBatch( - requestIds, - 1, - withdrawalQueue.getLastCheckpointIndex() - ); + uint256[] memory hints = + withdrawalQueue.findCheckpointHintBatch(requestIds, 1, withdrawalQueue.getLastCheckpointIndex()); uint256 claimable = withdrawalQueue.getClaimableEther(requestId); assertGt(claimable, 0); diff --git a/test/utils/CoreHarness.sol b/test/utils/CoreHarness.sol index 1f50b9f..24fa11f 100644 --- a/test/utils/CoreHarness.sol +++ b/test/utils/CoreHarness.sol @@ -1,19 +1,19 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Test, console} from "forge-std/Test.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {IOssifiableProxy} from "src/interfaces/IOssifiableProxy.sol"; -import {ILidoLocator} from "src/interfaces/ILidoLocator.sol"; -import {ILido} from "src/interfaces/ILido.sol"; -import {ILazyOracle} from "src/interfaces/ILazyOracle.sol"; -import {IDashboard} from "src/interfaces/IDashboard.sol"; -import {IOperatorGrid} from "src/interfaces/IOperatorGrid.sol"; -import {IVaultHub as IVaultHubIntact} from "src/interfaces/IVaultHub.sol"; -import {IVaultFactory} from "src/interfaces/IVaultFactory.sol"; -import {IWstETH} from "../../src/interfaces/IWstETH.sol"; +import {IWstETH} from "../../src/interfaces/core/IWstETH.sol"; +import {IDashboard} from "src/interfaces/core/IDashboard.sol"; +import {ILazyOracle} from "src/interfaces/core/ILazyOracle.sol"; +import {ILido} from "src/interfaces/core/ILido.sol"; +import {ILidoLocator} from "src/interfaces/core/ILidoLocator.sol"; +import {IOperatorGrid} from "src/interfaces/core/IOperatorGrid.sol"; +import {IOssifiableProxy} from "src/interfaces/core/IOssifiableProxy.sol"; +import {IVaultFactory} from "src/interfaces/core/IVaultFactory.sol"; +import {IVaultHub as IVaultHubIntact} from "src/interfaces/core/IVaultHub.sol"; interface IHashConsensus { function updateInitialEpoch(uint256 initialEpoch) external; @@ -65,7 +65,12 @@ contract CoreHarness is Test { constructor() { vm.deal(address(this), 10000000 ether); - address locatorAddress = vm.parseAddress(vm.envString("CORE_LOCATOR_ADDRESS")); + string memory locatorAddressStr = vm.envString("CORE_LOCATOR_ADDRESS"); + if (bytes(locatorAddressStr).length == 0) { + revert("CORE_LOCATOR_ADDRESS is not set"); + } + + address locatorAddress = vm.parseAddress(locatorAddressStr); console.log("Locator address:", locatorAddress); locator = ILidoLocator(locatorAddress); @@ -101,8 +106,9 @@ contract CoreHarness is Test { vm.startPrank(agent); { try IHashConsensus(hashConsensusAddr).updateInitialEpoch(1) { - // ok - } catch { + // ok + } + catch { // ignore if already set on pre-deployed core (Hoodi) } @@ -120,7 +126,7 @@ contract CoreHarness is Test { uint256 totalShares = steth.getTotalShares(); if (totalShares < 100000) { try steth.submit{value: INITIAL_LIDO_SUBMISSION}(address(this)) {} - catch { + catch { // ignore stake limit or other constraints on pre-deployed core } } @@ -218,7 +224,6 @@ contract CoreHarness is Test { ); } - /** * @dev Mock function to simulate validators receiving ETH from the staking vault * This replaces the manual beacon chain transfer simulation in tests diff --git a/test/utils/FactoryHelper.sol b/test/utils/FactoryHelper.sol index c62cd1f..7ad3eb6 100644 --- a/test/utils/FactoryHelper.sol +++ b/test/utils/FactoryHelper.sol @@ -1,63 +1,48 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Factory} from "src/Factory.sol"; +import {DistributorFactory} from "src/factories/DistributorFactory.sol"; +import {GGVStrategyFactory} from "src/factories/GGVStrategyFactory.sol"; import {StvPoolFactory} from "src/factories/StvPoolFactory.sol"; import {StvStETHPoolFactory} from "src/factories/StvStETHPoolFactory.sol"; +import {TimelockFactory} from "src/factories/TimelockFactory.sol"; import {WithdrawalQueueFactory} from "src/factories/WithdrawalQueueFactory.sol"; -import {DistributorFactory} from "src/factories/DistributorFactory.sol"; +import {ILidoLocator} from "src/interfaces/core/ILidoLocator.sol"; import {DummyImplementation} from "src/proxy/DummyImplementation.sol"; -import {LoopStrategyFactory} from "src/factories/LoopStrategyFactory.sol"; -import {GGVStrategyFactory} from "src/factories/GGVStrategyFactory.sol"; -import {TimelockFactory} from "src/factories/TimelockFactory.sol"; contract FactoryHelper { Factory.SubFactories public subFactories; Factory.TimelockConfig public defaultTimelockConfig; - Factory.StrategyParameters public defaultStrategyParameters; constructor() { + address dummyTeller = address(new DummyImplementation()); + address dummyQueue = address(new DummyImplementation()); + subFactories.stvPoolFactory = address(new StvPoolFactory()); subFactories.stvStETHPoolFactory = address(new StvStETHPoolFactory()); subFactories.withdrawalQueueFactory = address(new WithdrawalQueueFactory()); subFactories.distributorFactory = address(new DistributorFactory()); - subFactories.loopStrategyFactory = address(new LoopStrategyFactory()); - subFactories.ggvStrategyFactory = address(new GGVStrategyFactory()); + subFactories.ggvStrategyFactory = address(new GGVStrategyFactory(dummyTeller, dummyQueue)); subFactories.timelockFactory = address(new TimelockFactory()); - defaultTimelockConfig = Factory.TimelockConfig({ - minDelaySeconds: 7 days, - executor: address(this) - }); - - defaultStrategyParameters = Factory.StrategyParameters({ - ggvTeller: address(new DummyImplementation()), - ggvBoringOnChainQueue: address(new DummyImplementation()) - }); + defaultTimelockConfig = + Factory.TimelockConfig({minDelaySeconds: 7 days, proposer: address(this), executor: address(this)}); } function deployMainFactory(address locatorAddress) external returns (Factory factory) { - factory = new Factory(locatorAddress, subFactories, defaultTimelockConfig, defaultStrategyParameters); + factory = new Factory(locatorAddress, subFactories); } - function deployMainFactory( - address locatorAddress, - Factory.StrategyParameters memory strategyParams, - Factory.TimelockConfig memory timelockConfig - ) external returns (Factory factory) { - if (strategyParams.ggvTeller == address(0)) { - strategyParams.ggvTeller = defaultStrategyParameters.ggvTeller; - } - if (strategyParams.ggvBoringOnChainQueue == address(0)) { - strategyParams.ggvBoringOnChainQueue = defaultStrategyParameters.ggvBoringOnChainQueue; - } - if (timelockConfig.executor == address(0)) { - timelockConfig.executor = defaultTimelockConfig.executor; - } - if (timelockConfig.minDelaySeconds == 0) { - timelockConfig.minDelaySeconds = defaultTimelockConfig.minDelaySeconds; + function deployMainFactory(address locatorAddress, address ggvTeller, address ggvBoringQueue) + external + returns (Factory factory) + { + Factory.SubFactories memory factories = subFactories; + if (ggvTeller != address(0) && ggvBoringQueue != address(0)) { + factories.ggvStrategyFactory = address(new GGVStrategyFactory(ggvTeller, ggvBoringQueue)); } - factory = new Factory(locatorAddress, subFactories, timelockConfig, strategyParams); + factory = new Factory(locatorAddress, factories); } } diff --git a/test/utils/MerkleTree.sol b/test/utils/MerkleTree.sol index 16106fb..8be4975 100644 --- a/test/utils/MerkleTree.sol +++ b/test/utils/MerkleTree.sol @@ -1,6 +1,6 @@ // SPDX-FileCopyrightText: 2025 Lido // SPDX-License-Identifier: GPL-3.0 -pragma solidity >=0.8.25; +pragma solidity 0.8.30; /// @dev There's no leaves sorting for simplicity. contract MerkleTree { @@ -13,9 +13,7 @@ contract MerkleTree { return tree.length == 0 ? bytes32(0) : tree[0]; } - function getProof( - uint256 index - ) public view returns (bytes32[] memory proof) { + function getProof(uint256 index) public view returns (bytes32[] memory proof) { if (tree.length == 1) { return proof; } @@ -38,9 +36,11 @@ contract MerkleTree { return proof; } - function getMultiProof( - uint256[] memory indicies - ) public view returns (bytes32[] memory proof, bool[] memory proofFlags) { + function getMultiProof(uint256[] memory indicies) + public + view + returns (bytes32[] memory proof, bool[] memory proofFlags) + { uint256[] memory stack = new uint256[](BUFFER_SIZE); for (uint256 i; i < indicies.length; ++i) { stack[i] = tree.length - 1 - indicies[i]; @@ -99,7 +99,7 @@ contract MerkleTree { tree[tree.length - 1 - i] = leaves[i]; } - for (uint256 i = tree.length - 1 - leaves.length; ; --i) { + for (uint256 i = tree.length - 1 - leaves.length;; --i) { tree[i] = _hashPair(tree[2 * i + 1], tree[2 * i + 2]); if (i == 0) { @@ -122,9 +122,6 @@ contract MerkleTree { } function _hashPair(bytes32 a, bytes32 b) private pure returns (bytes32) { - return - a < b - ? keccak256(bytes.concat(a, b)) - : keccak256(bytes.concat(b, a)); + return a < b ? keccak256(bytes.concat(a, b)) : keccak256(bytes.concat(b, a)); } } diff --git a/test/utils/StvPoolHarness.sol b/test/utils/StvPoolHarness.sol index 94d5443..a2c7a80 100644 --- a/test/utils/StvPoolHarness.sol +++ b/test/utils/StvPoolHarness.sol @@ -1,20 +1,20 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; import {Test, console} from "forge-std/Test.sol"; -import {CoreHarness} from "test/utils/CoreHarness.sol"; -import {IDashboard} from "src/interfaces/IDashboard.sol"; -import {IVaultHub} from "src/interfaces/IVaultHub.sol"; -import {IStakingVault} from "src/interfaces/IStakingVault.sol"; -import {ILido} from "src/interfaces/ILido.sol"; -import {IWstETH} from "src/interfaces/IWstETH.sol"; - +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; +import {Distributor} from "src/Distributor.sol"; +import {Factory} from "src/Factory.sol"; import {StvPool} from "src/StvPool.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; -import {Factory} from "src/Factory.sol"; +import {IDashboard} from "src/interfaces/core/IDashboard.sol"; +import {ILido} from "src/interfaces/core/ILido.sol"; +import {IStakingVault} from "src/interfaces/core/IStakingVault.sol"; +import {IVaultHub} from "src/interfaces/core/IVaultHub.sol"; +import {IWstETH} from "src/interfaces/core/IWstETH.sol"; +import {CoreHarness} from "test/utils/CoreHarness.sol"; import {FactoryHelper} from "test/utils/FactoryHelper.sol"; -import {Distributor} from "src/Distributor.sol"; /** * @title StvPoolHarness @@ -48,7 +48,6 @@ contract StvPoolHarness is Test { // Deployment configuration struct enum StrategyKind { NONE, - LOOP, GGV } @@ -60,12 +59,13 @@ contract StvPoolHarness is Test { address nodeOperatorManager; uint256 nodeOperatorFeeBP; uint256 confirmExpiry; - uint256 maxFinalizationTime; uint256 minWithdrawalDelayTime; uint256 reserveRatioGapBP; StrategyKind strategyKind; address ggvTeller; address ggvBoringQueue; + uint256 timelockMinDelaySeconds; + address timelockExecutor; string name; string symbol; } @@ -77,6 +77,7 @@ contract StvPoolHarness is Test { IStakingVault vault; address strategy; Distributor distributor; + TimelockController timelock; } function _initializeCore() internal { @@ -107,48 +108,43 @@ contract StvPoolHarness is Test { console.log("Using predeployed factory from FACTORY_ADDRESS", factoryFromEnv); } else { FactoryHelper helper = new FactoryHelper(); - - Factory.StrategyParameters memory strategyParams = Factory.StrategyParameters({ - ggvTeller: config.ggvTeller, - ggvBoringOnChainQueue: config.ggvBoringQueue - }); - - Factory.TimelockConfig memory timelockConfig = Factory.TimelockConfig({ - minDelaySeconds: 0, - executor: owner - }); - - factory = helper.deployMainFactory(address(core.locator()), strategyParams, timelockConfig); + factory = helper.deployMainFactory(address(core.locator()), config.ggvTeller, config.ggvBoringQueue); } - Factory.PoolFullConfig memory poolConfig = Factory.PoolFullConfig({ - allowlistEnabled: config.allowlistEnabled, - mintingEnabled: config.mintingEnabled, - owner: owner, + Factory.VaultConfig memory vaultConfig = Factory.VaultConfig({ nodeOperator: config.nodeOperator, nodeOperatorManager: config.nodeOperatorManager, nodeOperatorFeeBP: config.nodeOperatorFeeBP, - confirmExpiry: config.confirmExpiry, - maxFinalizationTime: config.maxFinalizationTime, - minWithdrawalDelayTime: config.minWithdrawalDelayTime, - reserveRatioGapBP: config.reserveRatioGapBP, - name: config.name, - symbol: config.symbol + confirmExpiry: config.confirmExpiry + }); + + Factory.CommonPoolConfig memory commonPoolConfig = Factory.CommonPoolConfig({ + minWithdrawalDelayTime: config.minWithdrawalDelayTime, name: config.name, symbol: config.symbol + }); + + Factory.AuxiliaryPoolConfig memory auxiliaryConfig = Factory.AuxiliaryPoolConfig({ + allowlistEnabled: config.allowlistEnabled, + mintingEnabled: config.mintingEnabled, + reserveRatioGapBP: config.reserveRatioGapBP + }); + + Factory.TimelockConfig memory timelockConfig = Factory.TimelockConfig({ + minDelaySeconds: config.timelockMinDelaySeconds, + proposer: config.timelockExecutor == address(0) ? owner : config.timelockExecutor, + executor: config.timelockExecutor == address(0) ? owner : config.timelockExecutor }); address strategyFactoryAddress = address(0); - if (config.strategyKind == StrategyKind.LOOP) { - strategyFactoryAddress = address(factory.LOOP_STRATEGY_FACTORY()); - } else if (config.strategyKind == StrategyKind.GGV) { + if (config.strategyKind == StrategyKind.GGV) { strategyFactoryAddress = address(factory.GGV_STRATEGY_FACTORY()); } - - Factory.StrategyConfig memory strategyConfig = Factory.StrategyConfig({factory: strategyFactoryAddress}); + // StrategyKind.NONE: strategyFactoryAddress remains address(0) vm.startPrank(config.nodeOperator); - Factory.StvPoolIntermediate memory intermediate = - factory.createPoolStart{value: CONNECT_DEPOSIT}(poolConfig, strategyConfig); - Factory.StvPoolDeployment memory deployment = factory.createPoolFinish(intermediate, strategyConfig); + Factory.PoolIntermediate memory intermediate = factory.createPoolStart{value: CONNECT_DEPOSIT}( + vaultConfig, commonPoolConfig, auxiliaryConfig, timelockConfig, strategyFactoryAddress, "" + ); + Factory.PoolDeployment memory deployment = factory.createPoolFinish(intermediate); vm.stopPrank(); IDashboard dashboard = IDashboard(payable(deployment.dashboard)); @@ -158,6 +154,7 @@ contract StvPoolHarness is Test { Distributor distributor = Distributor(deployment.distributor); address strategy_ = deployment.strategy; + TimelockController timelock = TimelockController(payable(deployment.timelock)); // Apply initial vault report with current total value equal to connect deposit core.applyVaultReport(vault_, CONNECT_DEPOSIT, 0, 0, 0); @@ -168,7 +165,8 @@ contract StvPoolHarness is Test { dashboard: dashboard, vault: IStakingVault(vault_), strategy: strategy_, - distributor: distributor + distributor: distributor, + timelock: timelock }); } @@ -184,12 +182,13 @@ contract StvPoolHarness is Test { nodeOperatorManager: NODE_OPERATOR, nodeOperatorFeeBP: nodeOperatorFeeBP, confirmExpiry: CONFIRM_EXPIRY, - maxFinalizationTime: 30 days, minWithdrawalDelayTime: 1 days, reserveRatioGapBP: 0, strategyKind: StrategyKind.NONE, ggvTeller: address(0), ggvBoringQueue: address(0), + timelockMinDelaySeconds: 0, + timelockExecutor: NODE_OPERATOR, name: "Test STV Pool", symbol: "tSTV" }); diff --git a/test/utils/StvStETHPoolHarness.sol b/test/utils/StvStETHPoolHarness.sol index d32b402..988fdf7 100644 --- a/test/utils/StvStETHPoolHarness.sol +++ b/test/utils/StvStETHPoolHarness.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {StvPoolHarness} from "test/utils/StvPoolHarness.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; +import {StvPoolHarness} from "test/utils/StvPoolHarness.sol"; /** * @title StvStETHPoolHarness @@ -21,12 +21,13 @@ contract StvStETHPoolHarness is StvPoolHarness { nodeOperatorManager: NODE_OPERATOR, nodeOperatorFeeBP: nodeOperatorFeeBP, confirmExpiry: CONFIRM_EXPIRY, - maxFinalizationTime: 30 days, minWithdrawalDelayTime: 1 days, reserveRatioGapBP: reserveRatioGapBP, strategyKind: StrategyKind.NONE, ggvTeller: address(0), ggvBoringQueue: address(0), + timelockMinDelaySeconds: 0, + timelockExecutor: NODE_OPERATOR, name: "Test stETH Pool", symbol: "tSTETH" }); diff --git a/test/utils/StvStrategyPoolHarness.sol b/test/utils/StvStrategyPoolHarness.sol index dbccf44..858e267 100644 --- a/test/utils/StvStrategyPoolHarness.sol +++ b/test/utils/StvStrategyPoolHarness.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {StvStETHPoolHarness} from "test/utils/StvStETHPoolHarness.sol"; import {StvStETHPool} from "src/StvStETHPool.sol"; import {IStrategy} from "src/interfaces/IStrategy.sol"; +import {StvStETHPoolHarness} from "test/utils/StvStETHPoolHarness.sol"; /** * @title StvStrategyPoolHarness @@ -27,12 +27,13 @@ contract StvStrategyPoolHarness is StvStETHPoolHarness { nodeOperatorManager: NODE_OPERATOR, nodeOperatorFeeBP: nodeOperatorFeeBP, confirmExpiry: CONFIRM_EXPIRY, - maxFinalizationTime: 30 days, minWithdrawalDelayTime: 1 days, reserveRatioGapBP: reserveRatioGapBP, strategyKind: StrategyKind.GGV, ggvTeller: _teller, ggvBoringQueue: _boringQueue, + timelockMinDelaySeconds: 0, + timelockExecutor: NODE_OPERATOR, name: "Integration Strategy Pool", symbol: "iSTRAT" }); diff --git a/test/utils/TimelockHarness.sol b/test/utils/TimelockHarness.sol new file mode 100644 index 0000000..54beac5 --- /dev/null +++ b/test/utils/TimelockHarness.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; +import {Test} from "forge-std/Test.sol"; + +contract TimelockHarness is Test { + TimelockController public timelock; + + address timelockProposer; + address timelockExecutor; + + bytes32 salt = keccak256("timelock.salt.for.test"); + + function _setupTimelock(address _timelock, address _proposer, address _executor) internal { + timelock = TimelockController(payable(_timelock)); + timelockProposer = _proposer; + timelockExecutor = _executor; + } + + function _timelockSchedule(address target, bytes memory payload) internal { + uint256 delay = timelock.getMinDelay(); + + vm.prank(timelockProposer); + timelock.schedule({target: target, value: 0, data: payload, predecessor: bytes32(0), salt: salt, delay: delay}); + } + + function _timelockScheduleBatch(address[] memory targets, bytes[] memory payloads) internal { + uint256 delay = timelock.getMinDelay(); + + vm.prank(timelockProposer); + timelock.scheduleBatch({ + targets: targets, + values: new uint256[](targets.length), + payloads: payloads, + predecessor: bytes32(0), + salt: salt, + delay: delay + }); + } + + function _timelockWarp() internal { + vm.warp(block.timestamp + timelock.getMinDelay()); + } + + function _timelockExecute(address target, bytes memory payload) internal { + vm.prank(timelockExecutor); + timelock.execute({target: target, value: 0, payload: payload, predecessor: bytes32(0), salt: salt}); + } + + function _timelockExecuteBatch(address[] memory targets, bytes[] memory payloads) internal { + vm.prank(timelockExecutor); + timelock.executeBatch({ + targets: targets, + values: new uint256[](targets.length), + payloads: payloads, + predecessor: bytes32(0), + salt: salt + }); + } + + function _timelockScheduleAndExecute(address target, bytes memory payload) internal { + _timelockSchedule(target, payload); + _timelockWarp(); + _timelockExecute(target, payload); + } + + function _timelockScheduleAndExecuteBatch(address[] memory targets, bytes[] memory payloads) internal { + _timelockScheduleBatch(targets, payloads); + _timelockWarp(); + _timelockExecuteBatch(targets, payloads); + } +} diff --git a/test/utils/format/TableUtils.sol b/test/utils/format/TableUtils.sol index 0539add..0db3dfc 100644 --- a/test/utils/format/TableUtils.sol +++ b/test/utils/format/TableUtils.sol @@ -1,12 +1,12 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; +pragma solidity 0.8.30; -import {console} from "forge-std/console.sol"; -import {Vm} from "forge-std/Vm.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {console} from "forge-std/console.sol"; -import {IStETH} from "src/interfaces/IStETH.sol"; -import {IWstETH} from "src/interfaces/IWstETH.sol"; +import {IStETH} from "src/interfaces/core/IStETH.sol"; +import {IWstETH} from "src/interfaces/core/IWstETH.sol"; import {IBoringOnChainQueue} from "src/interfaces/ggv/IBoringOnChainQueue.sol"; interface IWrapper { @@ -49,7 +49,7 @@ library TableUtils { self.boringQueue = IBoringOnChainQueue(_boringQueue); } - function printHeader( string memory title) internal pure { + function printHeader(string memory title) internal pure { console.log(); console.log(); console.log(title); @@ -69,9 +69,7 @@ library TableUtils { padLeft("debt.stethShares", 20), padLeft("ggv", 20), padLeft("ggv.wstETHOut", 20), - padLeft("wstETH", 20), - padLeft("stETH", 20), - padLeft("stethShares", 20) + padLeft("wstETH", 20) ) ); console.log( @@ -79,8 +77,11 @@ library TableUtils { ); } - function printUsers(Context storage self, string memory title, User[] memory _addresses, uint256 _discount) internal view { - printHeader( title); + function printUsers(Context storage self, string memory title, User[] memory _addresses, uint256 _discount) + internal + view + { + printHeader(title); for (uint256 i = 0; i < _addresses.length; i++) { printUserRow(self, _addresses[i].name, _addresses[i].user, _discount); @@ -88,13 +89,18 @@ library TableUtils { uint256 stethShareRate = self.steth.getPooledEthByShares(1e18); - console.log(unicode"─────────────────────────────────────────────────"); + console.log( + unicode"─────────────────────────────────────────────────" + ); console.log(" stETH Share Rate:", formatETH(stethShareRate)); console.log("pool totalSupply", formatETH(self.pool.totalSupply())); console.log("pool totalAssets", formatETH(self.pool.totalAssets())); } - function printUserRow(Context storage self, string memory userName, address _user, uint256 _discount) internal view { + function printUserRow(Context storage self, string memory userName, address _user, uint256 _discount) + internal + view + { uint256 balance = _user.balance; uint256 stv = self.pool.balanceOf(_user); uint256 assets = self.pool.previewRedeem(stv); @@ -102,8 +108,6 @@ library TableUtils { uint256 ggv = self.boringVault.balanceOf(_user); uint256 ggvStethOut = self.boringQueue.previewAssetsOut(address(self.wsteth), uint128(ggv), uint16(_discount)); uint256 wsteth = self.wsteth.balanceOf(_user); - uint256 steth = self.steth.balanceOf(_user); - uint256 stethShares = self.steth.sharesOf(_user); console.log( string.concat( @@ -114,9 +118,7 @@ library TableUtils { padLeft(vm.toString(debtSteth), 20), padLeft(vm.toString(ggv), 20), padLeft(vm.toString(ggvStethOut), 20), - padLeft(vm.toString(wsteth), 20), - padLeft(vm.toString(steth), 20), - padLeft(vm.toString(stethShares), 20) + padLeft(vm.toString(wsteth), 20) ) ); }