|
| 1 | +--- |
| 2 | +sidebar_position: 4 |
| 3 | +--- |
| 4 | + |
| 5 | +import Tabs from '@theme/Tabs'; |
| 6 | +import TabItem from '@theme/TabItem'; |
| 7 | + |
| 8 | +# 🧩 Pool with a custom strategy |
| 9 | + |
| 10 | +## Intro |
| 11 | + |
| 12 | +This guide walks through how to build and deploy a **pooled staking product with a custom yield strategy** using the DeFi Wrapper toolkit. |
| 13 | + |
| 14 | +The DeFi Wrapper architecture is designed to support **any custom strategy** as long as it implements the required interfaces. |
| 15 | + |
| 16 | +There are **two paths** to getting a pool with a custom strategy: |
| 17 | + |
| 18 | +1. [**Deploy from scratch**](#path-a-deploy-a-new-pool-with-custom-strategy) - Already have a custom strategy and ready to launch a pool |
| 19 | + |
| 20 | +2. [**Upgrade existing pool**](#path-b-upgrade-an-existing-pool-to-a-strategy-pool) - Create a pool and add a custom strategy later |
| 21 | + |
| 22 | +Both paths share the same smart-contract development steps (implementing `IStrategy` and `IStrategyFactory`). |
| 23 | + |
| 24 | +## Smart contract development |
| 25 | + |
| 26 | +1. Implement the [`IStrategy`](https://github.com/lidofinance/vaults-wrapper/blob/develop/src/interfaces/IStrategy.sol) interface |
| 27 | + |
| 28 | +2. Implement the [`IStrategyFactory`](https://github.com/lidofinance/vaults-wrapper/blob/develop/src/interfaces/IStrategyFactory.sol) interface. |
| 29 | + The `_deployBytes` parameter can be used to pass additional strategy-specific configuration during deployment. If your strategy doesn't need extra config, it can be ignored. |
| 30 | + |
| 31 | +3. Deploy the strategy factory |
| 32 | + |
| 33 | +:::note |
| 34 | +Note the deployed **strategy factory address** — you will need it in Path A. |
| 35 | +::: |
| 36 | + |
| 37 | +:::warning |
| 38 | +Make sure to deploy the strategy factory on the same network where you will create the pool (Hoodi testnet for testing, Ethereum mainnet for production). |
| 39 | +::: |
| 40 | + |
| 41 | +--- |
| 42 | + |
| 43 | +## Path A: Deploy a new pool with custom strategy |
| 44 | + |
| 45 | +Use this path when launching a new product from scratch. |
| 46 | + |
| 47 | +### Create the pool via CLI |
| 48 | + |
| 49 | +Use the `create-pool-custom` command to deploy the pool with your strategy: |
| 50 | + |
| 51 | +```bash |
| 52 | +yarn start defi-wrapper contracts factory w create-pool-custom <DEFI_WRAPPER_FACTORY> \ |
| 53 | + --nodeOperator <NODE_OPERATOR_ADDRESS> \ |
| 54 | + --nodeOperatorManager <NODE_OPERATOR_MANAGER_ADDRESS> \ |
| 55 | + --nodeOperatorFeeRateBP 10 \ |
| 56 | + --confirmExpiry 86400 \ |
| 57 | + --minDelaySeconds 3600 \ |
| 58 | + --minWithdrawalDelayTime 3600 \ |
| 59 | + --name "My Custom Strategy Pool" \ |
| 60 | + --symbol STV \ |
| 61 | + --proposer <PROPOSER_ADDRESS> \ |
| 62 | + --executor <EXECUTOR_ADDRESS> \ |
| 63 | + --emergencyCommittee <EMERGENCY_COMMITTEE_ADDRESS> \ |
| 64 | + --reserveRatioGapBP 250 \ |
| 65 | + --mintingEnabled true \ |
| 66 | + --allowList true \ |
| 67 | + --allowListManager <ALLOW_LIST_MANAGER_ADDRESS> \ |
| 68 | + --strategyFactory <MY_STRATEGY_FACTORY_ADDRESS> \ |
| 69 | + --strategyFactoryDeployBytes <strategyFactoryDeployBytes> |
| 70 | +``` |
| 71 | + |
| 72 | +Run `yarn start defi-wrapper contracts factory write create-pool-custom -h` for the full description of all available parameters. |
| 73 | + |
| 74 | +:::info |
| 75 | +The deployer must have at least `1 ETH` available. This is the `CONNECT_DEPOSIT` required to be locked on the vault upon connection to Lido `VaultHub`. |
| 76 | +::: |
| 77 | + |
| 78 | +<details> |
| 79 | + <summary>Parameter reference</summary> |
| 80 | + |
| 81 | +| Parameter | Description | |
| 82 | +|-----------|-------------| |
| 83 | +| `<DEFI_WRAPPER_FACTORY>` | DeFi Wrapper Factory contract address (see [Environments](/run-on-lido/stvaults/building-guides/pooled-staking-product/#environments)) | |
| 84 | +| `--nodeOperator` | Address of the Node Operator managing validators | |
| 85 | +| `--nodeOperatorManager` | Address authorized to manage Node Operator settings | |
| 86 | +| `--nodeOperatorFeeRateBP` | Node Operator fee in basis points (10 = 0.1%) | |
| 87 | +| `--confirmExpiry` | Confirmation timeout in seconds | |
| 88 | +| `--minDelaySeconds` | TimeLock minimum delay before execution | |
| 89 | +| `--minWithdrawalDelayTime` | Minimum delay before withdrawals can be finalized | |
| 90 | +| `--name` | ERC-20 pool share token name | |
| 91 | +| `--symbol` | ERC-20 pool share token symbol | |
| 92 | +| `--proposer` | Address authorized to propose TimeLock operations | |
| 93 | +| `--executor` | Address authorized to execute TimeLock operations | |
| 94 | +| `--emergencyCommittee` | Address that can pause pool operations | |
| 95 | +| `--reserveRatioGapBP` | Reserve ratio gap in basis points (recommended min: 250) | |
| 96 | +| `--mintingEnabled` | Enable stETH minting (`true` / `false`) | |
| 97 | +| `--allowList` | Enable deposit allowlist (`true` / `false`) | |
| 98 | +| `--allowListManager` | Address managing the allowlist | |
| 99 | +| `--strategyFactory` | Your deployed strategy factory address | |
| 100 | +| `--strategyFactoryDeployBytes` | Optional hex-encoded bytes passed to your factory's `deploy()` | |
| 101 | + |
| 102 | +</details> |
| 103 | + |
| 104 | +:::warning |
| 105 | +The minimum recommended value for `reserveRatioGapBP` is `250` (2.5%). It is expected to be sufficient to absorb enough of the vault's performance volatility to keep users' positions healthy in most cases. |
| 106 | +::: |
| 107 | + |
| 108 | + |
| 109 | +After successful deployment, the CLI outputs the addresses and environment variables you need: |
| 110 | + |
| 111 | +- **Vault** contract address |
| 112 | +- **Pool** contract address |
| 113 | +- **WithdrawalQueue** contract address |
| 114 | +- **Distributor** contract address |
| 115 | +- **Strategy** contract address |
| 116 | +- **TimeLock** contract address |
| 117 | +- UI environment variables (`VITE_POOL_ADDRESS`, `VITE_POOL_TYPE`, etc.) |
| 118 | + |
| 119 | +:::info |
| 120 | +Keep the CLI output — you will need these addresses for the UI setup and ongoing operations. |
| 121 | +::: |
| 122 | + |
| 123 | +Continue with [Post-deployment steps](/run-on-lido/stvaults/building-guides/pooled-staking-product/#2-create-web-ui). |
| 124 | + |
| 125 | +--- |
| 126 | + |
| 127 | +## Path B: Upgrade an existing pool to a strategy pool |
| 128 | + |
| 129 | +Use this path when you have a running [`StvStETHPool`](/run-on-lido/stvaults/building-guides/pooled-staking-product/#deployment-of-stvstethpool-pool-with-steth-minting) and want to add a strategy without redeploying the pool. All existing user balances and state are preserved through the proxy upgrade. |
| 130 | + |
| 131 | +:::info |
| 132 | +This upgrade path uses the [`OssifiableProxy`](https://github.com/lidofinance/vaults-wrapper/blob/develop/src/proxy/OssifiableProxy.sol) pattern. The pool contract is a proxy whose implementation can be swapped by its admin (the `TimelockController`). Storage (user balances, roles, parameters) lives in the proxy and is preserved across implementation changes. |
| 133 | +::: |
| 134 | + |
| 135 | +### What changes during the upgrade |
| 136 | + |
| 137 | +| Aspect | Before (`StvStETHPool`) | After (`StvStrategyPool`) | |
| 138 | +|--------|------------------------|--------------------------------------| |
| 139 | +| Pool type | `STV_STETH_POOL_TYPE` | `STRATEGY_POOL_TYPE` | |
| 140 | +| Allowlist | Disabled | Enabled (only strategy can deposit) | |
| 141 | +| Strategy | None | Your custom strategy contract | |
| 142 | +| Direct user deposits | Allowed | Blocked (users go through strategy) | |
| 143 | +| User STV balances | ✅ Preserved | ✅ Preserved | |
| 144 | +| Vault, Dashboard, WQ | ✅ Unchanged | ✅ Unchanged | |
| 145 | + |
| 146 | +### Deploy the new pool implementation and strategy |
| 147 | + |
| 148 | +You need two new contracts: a new pool implementation (with `STRATEGY_POOL_TYPE` and `allowListEnabled = true`) and the strategy itself. |
| 149 | + |
| 150 | +#### Deploy new pool implementation |
| 151 | + |
| 152 | +Use the existing `StvStETHPoolFactory` to create a new implementation with the correct pool type: |
| 153 | + |
| 154 | +```bash |
| 155 | +cast send <STV_STETH_POOL_FACTORY> \ |
| 156 | + "deploy(address,bool,uint256,address,address,bytes32)(address)" \ |
| 157 | + <DASHBOARD> \ |
| 158 | + true \ |
| 159 | + <RESERVE_RATIO_GAP_BP> \ |
| 160 | + <WITHDRAWAL_QUEUE> \ |
| 161 | + <DISTRIBUTOR> \ |
| 162 | + <STRATEGY_POOL_TYPE> \ |
| 163 | + --rpc-url $RPC_URL \ |
| 164 | + --private-key $DEPLOYER_KEY |
| 165 | +``` |
| 166 | + |
| 167 | +Parameters: |
| 168 | +- `<STV_STETH_POOL_FACTORY>` — the `StvStETHPoolFactory` address from the DeFi Wrapper Factory (`Factory.STV_STETH_POOL_FACTORY()`) |
| 169 | +- `<DASHBOARD>` — your pool's existing Dashboard address |
| 170 | +- `true` — enables the allowlist (immutable in the new implementation) |
| 171 | +- `<RESERVE_RATIO_GAP_BP>` — same value as the existing pool (e.g., `500`) |
| 172 | +- `<WITHDRAWAL_QUEUE>` — your pool's existing WithdrawalQueue address |
| 173 | +- `<DISTRIBUTOR>` — your pool's existing Distributor address |
| 174 | +- `<STRATEGY_POOL_TYPE>` — the strategy pool type hash (`Factory.STRATEGY_POOL_TYPE()`) |
| 175 | + |
| 176 | +Note the deployed **new pool implementation address**. |
| 177 | + |
| 178 | +#### Deploy strategy implementation and proxy |
| 179 | + |
| 180 | + |
| 181 | +Deploy your strategy, you can use `forge create` or `cast send` for example |
| 182 | + |
| 183 | +Note the deployed **strategy proxy address**. |
| 184 | + |
| 185 | +:::warning |
| 186 | +The strategy proxy admin must be the pool's `TimelockController` address. The `initialize` call sets the Timelock as the strategy's `DEFAULT_ADMIN_ROLE` holder. |
| 187 | +::: |
| 188 | + |
| 189 | +### Execute the upgrade via TimelockController batch |
| 190 | + |
| 191 | +The upgrade must be executed as an **atomic batch** through the `TimelockController` to prevent an intermediate state where the allowlist is enabled but the strategy is not yet allowlisted. |
| 192 | + |
| 193 | +The batch consists of operations, all targeting the pool proxy: |
| 194 | + |
| 195 | +:::warning |
| 196 | +The exact number and content of operations depends on the current pool configuration (e.g., whether minting is paused, which roles are assigned). The example below is illustrative and may differ in your case. |
| 197 | +::: |
| 198 | + |
| 199 | +| # | Operation | Purpose | |
| 200 | +|---|-----------|---------| |
| 201 | +| 1 | `proxy__upgradeToAndCall(newImpl, "")` | Swap implementation to strategy pool type | |
| 202 | +| 2 | `grantRole(ALLOW_LIST_MANAGER_ROLE, timelock)` | Temporarily grant allowlist management to Timelock | |
| 203 | +| 3 | `addToAllowList(strategyProxy)` | Allow the strategy to deposit into the pool | |
| 204 | +| 4 | `revokeRole(ALLOW_LIST_MANAGER_ROLE, factory)` | Remove Factory's allowlist management | |
| 205 | +| 5 | `revokeRole(ALLOW_LIST_MANAGER_ROLE, timelock)` | Remove Timelock's temporary allowlist management | |
| 206 | +| 6 | `revokeRole(DEPOSITS_PAUSE_ROLE, nodeOperator)` | Adjust pause roles for the new setup | |
| 207 | +| 7 | `revokeRole(MINTING_PAUSE_ROLE, nodeOperator)` | Adjust pause roles for the new setup | |
| 208 | +| 8 | `grantRole(MINTING_RESUME_ROLE, timelock)` | Temporarily grant minting resume capability | |
| 209 | +| 9 | `resumeMinting()` | Re-enable minting (needed if paused in the original pool) | |
| 210 | +| 10 | `revokeRole(MINTING_RESUME_ROLE, timelock)` | Remove temporary minting resume capability | |
| 211 | + |
| 212 | +:::info |
| 213 | +Steps 8–10 (resume minting) are only needed if minting was paused in the original pool. If minting was already active, these steps can be omitted from the batch. |
| 214 | +::: |
| 215 | + |
| 216 | +:::info |
| 217 | +Steps 6–7 (revoke pause roles from the Node Operator) adjust the emergency role setup to match the strategy pool configuration. Review the [DeFi Wrapper roles and permissions](./roles-and-permissions) to decide what role assignment is appropriate for your setup. |
| 218 | +::: |
| 219 | + |
| 220 | +<details> |
| 221 | + <summary>Step 1: Prepare calldata for each operation</summary> |
| 222 | + |
| 223 | +Use `cast` (from Foundry) to encode each payload: |
| 224 | + |
| 225 | +```bash |
| 226 | +# 1. Upgrade pool implementation |
| 227 | +PAYLOAD_1=$(cast calldata "proxy__upgradeToAndCall(address,bytes)" <NEW_POOL_IMPL> 0x) |
| 228 | + |
| 229 | +# 2. Grant ALLOW_LIST_MANAGER_ROLE to timelock |
| 230 | +ALLOW_LIST_MANAGER_ROLE=$(cast call <POOL> "ALLOW_LIST_MANAGER_ROLE()(bytes32)" --rpc-url $RPC_URL) |
| 231 | +PAYLOAD_2=$(cast calldata "grantRole(bytes32,address)" $ALLOW_LIST_MANAGER_ROLE <TIMELOCK>) |
| 232 | + |
| 233 | +# 3. Add strategy to allowlist |
| 234 | +PAYLOAD_3=$(cast calldata "addToAllowList(address)" <STRATEGY_PROXY>) |
| 235 | + |
| 236 | +# 4. Revoke ALLOW_LIST_MANAGER_ROLE from factory |
| 237 | +PAYLOAD_4=$(cast calldata "revokeRole(bytes32,address)" $ALLOW_LIST_MANAGER_ROLE <FACTORY>) |
| 238 | + |
| 239 | +# 5. Revoke ALLOW_LIST_MANAGER_ROLE from timelock |
| 240 | +PAYLOAD_5=$(cast calldata "revokeRole(bytes32,address)" $ALLOW_LIST_MANAGER_ROLE <TIMELOCK>) |
| 241 | + |
| 242 | +# 6. Revoke DEPOSITS_PAUSE_ROLE from node operator |
| 243 | +DEPOSITS_PAUSE_ROLE=$(cast call <POOL> "DEPOSITS_PAUSE_ROLE()(bytes32)" --rpc-url $RPC_URL) |
| 244 | +PAYLOAD_6=$(cast calldata "revokeRole(bytes32,address)" $DEPOSITS_PAUSE_ROLE <NODE_OPERATOR>) |
| 245 | + |
| 246 | +# 7. Revoke MINTING_PAUSE_ROLE from node operator |
| 247 | +MINTING_PAUSE_ROLE=$(cast call <POOL> "MINTING_PAUSE_ROLE()(bytes32)" --rpc-url $RPC_URL) |
| 248 | +PAYLOAD_7=$(cast calldata "revokeRole(bytes32,address)" $MINTING_PAUSE_ROLE <NODE_OPERATOR>) |
| 249 | + |
| 250 | +# 8. Grant MINTING_RESUME_ROLE to timelock |
| 251 | +MINTING_RESUME_ROLE=$(cast call <POOL> "MINTING_RESUME_ROLE()(bytes32)" --rpc-url $RPC_URL) |
| 252 | +PAYLOAD_8=$(cast calldata "grantRole(bytes32,address)" $MINTING_RESUME_ROLE <TIMELOCK>) |
| 253 | + |
| 254 | +# 9. Resume minting |
| 255 | +PAYLOAD_9=$(cast calldata "resumeMinting()") |
| 256 | + |
| 257 | +# 10. Revoke MINTING_RESUME_ROLE from timelock |
| 258 | +PAYLOAD_10=$(cast calldata "revokeRole(bytes32,address)" $MINTING_RESUME_ROLE <TIMELOCK>) |
| 259 | +``` |
| 260 | + |
| 261 | +</details> |
| 262 | + |
| 263 | +<details> |
| 264 | + <summary>Step 2: Schedule the batch (Proposer)</summary> |
| 265 | + |
| 266 | +Call `TimelockController.scheduleBatch` on the Timelock contract. This can be done via **Etherscan** or `cast`: |
| 267 | + |
| 268 | +```bash |
| 269 | +POOL=<POOL_ADDRESS> |
| 270 | +PREDECESSOR=0x0000000000000000000000000000000000000000000000000000000000000000 |
| 271 | +SALT=0x0000000000000000000000000000000000000000000000000000000000000000 |
| 272 | +DELAY=<MIN_DELAY_SECONDS> |
| 273 | + |
| 274 | +cast send <TIMELOCK> \ |
| 275 | + "scheduleBatch(address[],uint256[],bytes[],bytes32,bytes32,uint256)" \ |
| 276 | + "[$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL]" \ |
| 277 | + "[0,0,0,0,0,0,0,0,0,0]" \ |
| 278 | + "[$PAYLOAD_1,$PAYLOAD_2,$PAYLOAD_3,$PAYLOAD_4,$PAYLOAD_5,$PAYLOAD_6,$PAYLOAD_7,$PAYLOAD_8,$PAYLOAD_9,$PAYLOAD_10]" \ |
| 279 | + $PREDECESSOR \ |
| 280 | + $SALT \ |
| 281 | + $DELAY \ |
| 282 | + --rpc-url $RPC_URL \ |
| 283 | + --private-key $PROPOSER_KEY |
| 284 | +``` |
| 285 | + |
| 286 | +Note the **operation ID** from the `CallScheduled` event in the transaction logs. |
| 287 | + |
| 288 | +</details> |
| 289 | + |
| 290 | +<details> |
| 291 | + <summary>Step 3: Execute the batch (Executor)</summary> |
| 292 | + |
| 293 | +After the timelock delay has passed, execute the batch: |
| 294 | + |
| 295 | +```bash |
| 296 | +cast send <TIMELOCK> \ |
| 297 | + "executeBatch(address[],uint256[],bytes[],bytes32,bytes32)" \ |
| 298 | + "[$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL]" \ |
| 299 | + "[0,0,0,0,0,0,0,0,0,0]" \ |
| 300 | + "[$PAYLOAD_1,$PAYLOAD_2,$PAYLOAD_3,$PAYLOAD_4,$PAYLOAD_5,$PAYLOAD_6,$PAYLOAD_7,$PAYLOAD_8,$PAYLOAD_9,$PAYLOAD_10]" \ |
| 301 | + $PREDECESSOR \ |
| 302 | + $SALT \ |
| 303 | + --rpc-url $RPC_URL \ |
| 304 | + --private-key $EXECUTOR_KEY |
| 305 | +``` |
| 306 | + |
| 307 | +You can verify the operation is ready before executing: |
| 308 | +```bash |
| 309 | +cast call <TIMELOCK> "isOperationReady(bytes32)(bool)" <OPERATION_ID> --rpc-url $RPC_URL |
| 310 | +``` |
| 311 | + |
| 312 | +</details> |
| 313 | + |
| 314 | +### Verify the upgrade via CLI |
| 315 | + |
| 316 | +```bash |
| 317 | +yarn start defi-wrapper contracts pool r info <POOL_ADDRESS> |
| 318 | +yarn start vo r info -v <VAULT_ADDRESS> |
| 319 | +``` |
| 320 | + |
| 321 | +### What users experience after the upgrade |
| 322 | + |
| 323 | +- **Existing STV balances** are fully preserved — users keep their tokens. |
| 324 | +- **Direct deposits** to the pool are no longer possible (blocked by allowlist). Users must go through the strategy. |
| 325 | +- **Existing STV holders** can approve and deposit their tokens into the strategy to start receiving strategy-boosted yield. |
| 326 | +- **Withdrawals** of existing STV continue to work through the WithdrawalQueue as before. |
| 327 | + |
| 328 | +--- |
| 329 | + |
| 330 | +## Reference implementation |
| 331 | + |
| 332 | +The [`GGVStrategy`](https://github.com/lidofinance/vaults-wrapper/blob/develop/src/strategy/GGVStrategy.sol) and its [`GGVStrategyFactory`](https://github.com/lidofinance/vaults-wrapper/blob/develop/src/factories/GGVStrategyFactory.sol) serve as the reference implementation for custom strategies. |
| 333 | + |
| 334 | +Study them to understand the complete pattern, including: |
| 335 | + |
| 336 | +- How `StrategyCallForwarderRegistry` manages per-user proxies |
| 337 | +- How `FeaturePausable` enables granular pause control |
| 338 | +- How to handle ERC-20 approvals and transfers through call forwarders |
| 339 | +- How to implement cancel/replace flows for pending exit requests |
| 340 | +- How the proxy upgrade preserves all user state |
| 341 | + |
| 342 | +The [upgrade integration test](https://github.com/lidofinance/vaults-wrapper/blob/develop/test/integration/wrapper-upgrade-b-to-c.test.sol) demonstrates the complete `StvStETHPool` → strategy pool upgrade flow. |
| 343 | + |
| 344 | +## Useful links |
| 345 | + |
| 346 | +- [DeFi Wrapper Technical Design](https://hackmd.io/@lido/lido-v3-wrapper-design) |
| 347 | +- [IStrategy interface](https://github.com/lidofinance/vaults-wrapper/blob/develop/src/interfaces/IStrategy.sol) |
| 348 | +- [IStrategyFactory interface](https://github.com/lidofinance/vaults-wrapper/blob/develop/src/interfaces/IStrategyFactory.sol) |
| 349 | +- [Upgrade integration test (StvStETHPool → strategy pool)](https://github.com/lidofinance/vaults-wrapper/blob/develop/test/integration/wrapper-upgrade-b-to-c.test.sol) |
| 350 | +- [stVaults CLI documentation](https://lidofinance.github.io/lido-staking-vault-cli/) |
| 351 | +- [stVaults Roles and Permissions](../../features-and-mechanics/roles-and-permissions) |
| 352 | +- [Health Monitoring Guide](../../operational-and-management-guides/health-monitoring-guide.md) |
0 commit comments