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)
)
);
}