Skip to main content

🧩 Pool with a custom strategy

Intro

This guide walks through how to build and deploy a pooled staking product with a custom yield strategy using the DeFi Wrapper toolkit.

The DeFi Wrapper architecture is designed to support any custom strategy as long as it implements the required interfaces.

There are two paths to getting a pool with a custom strategy:

  1. Deploy from scratch - Already have a custom strategy and ready to launch a pool

  2. Upgrade existing pool - Create a pool and add a custom strategy later

Both paths share the same smart-contract development steps (implementing IStrategy and IStrategyFactory).

Smart contract development

  1. Implement the IStrategy interface

  2. Implement the IStrategyFactory interface. 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.

  3. Deploy the strategy factory

note

Note the deployed strategy factory address — you will need it in Path A.

warning

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).


Path A: Deploy a new pool with custom strategy

Use this path when launching a new product from scratch.

Create the pool via CLI

Use the create-pool-custom command to deploy the pool with your strategy:

yarn start defi-wrapper contracts factory w create-pool-custom <DEFI_WRAPPER_FACTORY> \
--nodeOperator <NODE_OPERATOR_ADDRESS> \
--nodeOperatorManager <NODE_OPERATOR_MANAGER_ADDRESS> \
--nodeOperatorFeeRateBP 10 \
--confirmExpiry 86400 \
--minDelaySeconds 3600 \
--minWithdrawalDelayTime 3600 \
--name "My Custom Strategy Pool" \
--symbol STV \
--proposer <PROPOSER_ADDRESS> \
--executor <EXECUTOR_ADDRESS> \
--emergencyCommittee <EMERGENCY_COMMITTEE_ADDRESS> \
--reserveRatioGapBP 250 \
--mintingEnabled true \
--allowList true \
--allowListManager <ALLOW_LIST_MANAGER_ADDRESS> \
--strategyFactory <MY_STRATEGY_FACTORY_ADDRESS> \
--strategyFactoryDeployBytes <strategyFactoryDeployBytes>

Run yarn start defi-wrapper contracts factory write create-pool-custom -h for the full description of all available parameters.

info

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.

Parameter reference
ParameterDescription
<DEFI_WRAPPER_FACTORY>DeFi Wrapper Factory contract address (see Environments)
--nodeOperatorAddress of the Node Operator managing validators
--nodeOperatorManagerAddress authorized to manage Node Operator settings
--nodeOperatorFeeRateBPNode Operator fee in basis points (10 = 0.1%)
--confirmExpiryConfirmation timeout in seconds
--minDelaySecondsTimeLock minimum delay before execution
--minWithdrawalDelayTimeMinimum delay before withdrawals can be finalized
--nameERC-20 pool share token name
--symbolERC-20 pool share token symbol
--proposerAddress authorized to propose TimeLock operations
--executorAddress authorized to execute TimeLock operations
--emergencyCommitteeAddress that can pause pool operations
--reserveRatioGapBPReserve ratio gap in basis points (recommended min: 250)
--mintingEnabledEnable stETH minting (true / false)
--allowListEnable deposit allowlist (true / false)
--allowListManagerAddress managing the allowlist
--strategyFactoryYour deployed strategy factory address
--strategyFactoryDeployBytesOptional hex-encoded bytes passed to your factory's deploy()
warning

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.

After successful deployment, the CLI outputs the addresses and environment variables you need:

  • Vault contract address
  • Pool contract address
  • WithdrawalQueue contract address
  • Distributor contract address
  • Strategy contract address
  • TimeLock contract address
  • UI environment variables (VITE_POOL_ADDRESS, VITE_POOL_TYPE, etc.)
info

Keep the CLI output — you will need these addresses for the UI setup and ongoing operations.

Continue with Post-deployment steps.


Path B: Upgrade an existing pool to a strategy pool

Use this path when you have a running StvStETHPool and want to add a strategy without redeploying the pool. All existing user balances and state are preserved through the proxy upgrade.

info

This upgrade path uses the OssifiableProxy 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.

What changes during the upgrade

AspectBefore (StvStETHPool)After (StvStrategyPool)
Pool typeSTV_STETH_POOL_TYPESTRATEGY_POOL_TYPE
AllowlistDisabledEnabled (only strategy can deposit)
StrategyNoneYour custom strategy contract
Direct user depositsAllowedBlocked (users go through strategy)
User STV balances✅ Preserved✅ Preserved
Vault, Dashboard, WQ✅ Unchanged✅ Unchanged

Deploy the new pool implementation and strategy

You need two new contracts: a new pool implementation (with STRATEGY_POOL_TYPE and allowListEnabled = true) and the strategy itself.

Deploy new pool implementation

Use the existing StvStETHPoolFactory to create a new implementation with the correct pool type:

cast send <STV_STETH_POOL_FACTORY> \
"deploy(address,bool,uint256,address,address,bytes32)(address)" \
<DASHBOARD> \
true \
<RESERVE_RATIO_GAP_BP> \
<WITHDRAWAL_QUEUE> \
<DISTRIBUTOR> \
<STRATEGY_POOL_TYPE> \
--rpc-url $RPC_URL \
--private-key $DEPLOYER_KEY

Parameters:

  • <STV_STETH_POOL_FACTORY> — the StvStETHPoolFactory address from the DeFi Wrapper Factory (Factory.STV_STETH_POOL_FACTORY())
  • <DASHBOARD> — your pool's existing Dashboard address
  • true — enables the allowlist (immutable in the new implementation)
  • <RESERVE_RATIO_GAP_BP> — same value as the existing pool (e.g., 500)
  • <WITHDRAWAL_QUEUE> — your pool's existing WithdrawalQueue address
  • <DISTRIBUTOR> — your pool's existing Distributor address
  • <STRATEGY_POOL_TYPE> — the strategy pool type hash (Factory.STRATEGY_POOL_TYPE())

Note the deployed new pool implementation address.

Deploy strategy implementation and proxy

Deploy your strategy, you can use forge create or cast send for example

Note the deployed strategy proxy address.

warning

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.

Execute the upgrade via TimelockController batch

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.

The batch consists of operations, all targeting the pool proxy:

warning

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.

#OperationPurpose
1proxy__upgradeToAndCall(newImpl, "")Swap implementation to strategy pool type
2grantRole(ALLOW_LIST_MANAGER_ROLE, timelock)Temporarily grant allowlist management to Timelock
3addToAllowList(strategyProxy)Allow the strategy to deposit into the pool
4revokeRole(ALLOW_LIST_MANAGER_ROLE, factory)Remove Factory's allowlist management
5revokeRole(ALLOW_LIST_MANAGER_ROLE, timelock)Remove Timelock's temporary allowlist management
6revokeRole(DEPOSITS_PAUSE_ROLE, nodeOperator)Adjust pause roles for the new setup
7revokeRole(MINTING_PAUSE_ROLE, nodeOperator)Adjust pause roles for the new setup
8grantRole(MINTING_RESUME_ROLE, timelock)Temporarily grant minting resume capability
9resumeMinting()Re-enable minting (needed if paused in the original pool)
10revokeRole(MINTING_RESUME_ROLE, timelock)Remove temporary minting resume capability
info

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.

info

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 to decide what role assignment is appropriate for your setup.

Step 1: Prepare calldata for each operation

Use cast (from Foundry) to encode each payload:

# 1. Upgrade pool implementation
PAYLOAD_1=$(cast calldata "proxy__upgradeToAndCall(address,bytes)" <NEW_POOL_IMPL> 0x)

# 2. Grant ALLOW_LIST_MANAGER_ROLE to timelock
ALLOW_LIST_MANAGER_ROLE=$(cast call <POOL> "ALLOW_LIST_MANAGER_ROLE()(bytes32)" --rpc-url $RPC_URL)
PAYLOAD_2=$(cast calldata "grantRole(bytes32,address)" $ALLOW_LIST_MANAGER_ROLE <TIMELOCK>)

# 3. Add strategy to allowlist
PAYLOAD_3=$(cast calldata "addToAllowList(address)" <STRATEGY_PROXY>)

# 4. Revoke ALLOW_LIST_MANAGER_ROLE from factory
PAYLOAD_4=$(cast calldata "revokeRole(bytes32,address)" $ALLOW_LIST_MANAGER_ROLE <FACTORY>)

# 5. Revoke ALLOW_LIST_MANAGER_ROLE from timelock
PAYLOAD_5=$(cast calldata "revokeRole(bytes32,address)" $ALLOW_LIST_MANAGER_ROLE <TIMELOCK>)

# 6. Revoke DEPOSITS_PAUSE_ROLE from node operator
DEPOSITS_PAUSE_ROLE=$(cast call <POOL> "DEPOSITS_PAUSE_ROLE()(bytes32)" --rpc-url $RPC_URL)
PAYLOAD_6=$(cast calldata "revokeRole(bytes32,address)" $DEPOSITS_PAUSE_ROLE <NODE_OPERATOR>)

# 7. Revoke MINTING_PAUSE_ROLE from node operator
MINTING_PAUSE_ROLE=$(cast call <POOL> "MINTING_PAUSE_ROLE()(bytes32)" --rpc-url $RPC_URL)
PAYLOAD_7=$(cast calldata "revokeRole(bytes32,address)" $MINTING_PAUSE_ROLE <NODE_OPERATOR>)

# 8. Grant MINTING_RESUME_ROLE to timelock
MINTING_RESUME_ROLE=$(cast call <POOL> "MINTING_RESUME_ROLE()(bytes32)" --rpc-url $RPC_URL)
PAYLOAD_8=$(cast calldata "grantRole(bytes32,address)" $MINTING_RESUME_ROLE <TIMELOCK>)

# 9. Resume minting
PAYLOAD_9=$(cast calldata "resumeMinting()")

# 10. Revoke MINTING_RESUME_ROLE from timelock
PAYLOAD_10=$(cast calldata "revokeRole(bytes32,address)" $MINTING_RESUME_ROLE <TIMELOCK>)
Step 2: Schedule the batch (Proposer)

Call TimelockController.scheduleBatch on the Timelock contract. This can be done via Etherscan or cast:

POOL=<POOL_ADDRESS>
PREDECESSOR=0x0000000000000000000000000000000000000000000000000000000000000000
SALT=0x0000000000000000000000000000000000000000000000000000000000000000
DELAY=<MIN_DELAY_SECONDS>

cast send <TIMELOCK> \
"scheduleBatch(address[],uint256[],bytes[],bytes32,bytes32,uint256)" \
"[$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL]" \
"[0,0,0,0,0,0,0,0,0,0]" \
"[$PAYLOAD_1,$PAYLOAD_2,$PAYLOAD_3,$PAYLOAD_4,$PAYLOAD_5,$PAYLOAD_6,$PAYLOAD_7,$PAYLOAD_8,$PAYLOAD_9,$PAYLOAD_10]" \
$PREDECESSOR \
$SALT \
$DELAY \
--rpc-url $RPC_URL \
--private-key $PROPOSER_KEY

Note the operation ID from the CallScheduled event in the transaction logs.

Step 3: Execute the batch (Executor)

After the timelock delay has passed, execute the batch:

cast send <TIMELOCK> \
"executeBatch(address[],uint256[],bytes[],bytes32,bytes32)" \
"[$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL,$POOL]" \
"[0,0,0,0,0,0,0,0,0,0]" \
"[$PAYLOAD_1,$PAYLOAD_2,$PAYLOAD_3,$PAYLOAD_4,$PAYLOAD_5,$PAYLOAD_6,$PAYLOAD_7,$PAYLOAD_8,$PAYLOAD_9,$PAYLOAD_10]" \
$PREDECESSOR \
$SALT \
--rpc-url $RPC_URL \
--private-key $EXECUTOR_KEY

You can verify the operation is ready before executing:

cast call <TIMELOCK> "isOperationReady(bytes32)(bool)" <OPERATION_ID> --rpc-url $RPC_URL

Verify the upgrade via CLI

yarn start defi-wrapper contracts pool r info <POOL_ADDRESS>
yarn start vo r info -v <VAULT_ADDRESS>

What users experience after the upgrade

  • Existing STV balances are fully preserved — users keep their tokens.
  • Direct deposits to the pool are no longer possible (blocked by allowlist). Users must go through the strategy.
  • Existing STV holders can approve and deposit their tokens into the strategy to start receiving strategy-boosted yield.
  • Withdrawals of existing STV continue to work through the WithdrawalQueue as before.

Reference implementation

The GGVStrategy and its GGVStrategyFactory serve as the reference implementation for custom strategies.

Study them to understand the complete pattern, including:

  • How StrategyCallForwarderRegistry manages per-user proxies
  • How FeaturePausable enables granular pause control
  • How to handle ERC-20 approvals and transfers through call forwarders
  • How to implement cancel/replace flows for pending exit requests
  • How the proxy upgrade preserves all user state

The upgrade integration test demonstrates the complete StvStETHPool → strategy pool upgrade flow.