Skip to main content

Multi-Strategy Vaults

Purpose: Explain when to use a multi-strategy vault and how to reason about deployment and operation. Audience: Developers and operators wiring one underlying asset across multiple Octant-compatible strategies. Level: Intermediate Source of truth: [email protected], especially the MultistrategyVault, MultistrategyLockedVault, and MultistrategyVaultFactory sources. Use this page when: you need the mental model, deployment constraints, and operational vocabulary before diving into contract reference pages. Do not use this page for: assuming a canonical public factory deployment or skipping the exact factory and vault source for initializer and role details.

Before you read this page
TL;DR

A MultistrategyVault is an ERC-4626 vault that can hold one underlying asset and allocate that asset across multiple ERC-4626-compatible strategies using the same underlying asset. Deploy it through a MultistrategyVaultFactory instance — either one you deploy yourself or one already available in your environment. Then assign roles, add strategies, set debt ceilings, configure the withdrawal queue, and rebalance with update_debt(). Withdrawals pull from idle first, then from strategies in queue order unless a custom queue is used and useDefaultQueue is false.

Use this page for the vault workflow and mental model. When you need exact methods, events, or role-gated behavior, keep MultistrategyVault, MultistrategyLockedVault, and DebtManagementLib open as the contract-reference layer. Also keep the MultistrategyVaultFactory source open in octant-v2-core/src/factories/MultistrategyVaultFactory.sol. Use in production only after confirming your role model, reporting flow, queue policy, debt ceilings, loss tolerances, and monitoring plan against your actual deployment requirements.

Task card — what you will do on this page

Ask a coding assistant to follow this page in order and run the verification commands before moving on. It should not invent contract addresses, use placeholder implementation addresses, or skip fork/RPC prerequisites.

FieldDetail
GoalDeploy a MultistrategyVault through a factory, assign roles, add and fund strategies, configure the withdrawal queue, and run the normal reporting and rebalancing loop.
Files to editYour deployment script (e.g. a fork of script/deploy/DeployVault.s.sol); your ops/runbook scripts for update_debt and process_report calls.
Files NOT to editsrc/core/MultistrategyVault.sol, src/core/MultistrategyLockedVault.sol, src/factories/MultistrategyVaultFactory.sol, src/core/libs/DebtManagementLib.sol — read these for reference only.
Required setupA running Anvil fork or a live testnet RPC; a deployed MultistrategyVaultFactory instance (deploy one from script/deploy/DeployVaultFactory.s.sol if your environment does not already have one); at least one ERC-4626-compatible strategy using the same underlying asset as the vault.
VerificationAfter each operating-workflow step, call the matching view function (totalAssets(), strategies(address), get_default_queue(), totalIdle()) and confirm the returned values match your intent before proceeding.
Core target pinoctant-v2-core@36ed6ad
Do notInvent factory addresses; deploy MultistrategyVault directly (always use MultistrategyVaultFactory); skip role assignment; treat roleManager as optional. See also: Agent Anti-Patterns.

The following sequence diagram shows the core deposit and withdrawal flow through a multistrategy vault:

Factory deployment is required

MultistrategyVault uses a proxy pattern: the constructor sets asset to a non-zero sentinel value, and initialize() only succeeds when asset == address(0). That means a vault deployed with new MultistrategyVault() cannot be initialized. The only supported path is to deploy through a MultistrategyVaultFactory, which creates a minimal proxy clone and calls initialize() on it.

This page does not assume that Octant v2 exposes a canonical public Ethereum mainnet instance of MultistrategyVaultFactory. If your environment does not already have one, deploy your own factory first using the source and factory-deployment script in octant-v2-core (see script/deploy/DeployVaultFactory.s.sol). Once you have a factory address, use script/deploy/DeployVault.s.sol to deploy vaults through that factory.

Do you actually need a multistrategy vault?

When do you actually need a multistrategy vault?

Most developers should start with a standalone strategy vault. You only need a multistrategy vault when:

  • You want one deposit token (e.g., USDC) allocated across multiple yield sources
  • An operator needs to rebalance capital between strategies without users withdrawing and re-depositing
  • You need a withdrawal queue that tries strategies in priority order

If you just want one yield source and one deposit surface, a standalone strategy vault is simpler and sufficient.

Overview

Multi-Strategy Vaults in Octant v2 let capital providers diversify yield generation across multiple strategies while maintaining a single vault share token. Conceptually, this is a fund-of-funds model: one vault accepts deposits of a single asset and deploys that asset into several compatible ERC-4626 strategies.

The vault itself keeps track of three things:

  • Idle funds: assets sitting in the vault and ready for withdrawals or future allocation.
  • Strategy debt: assets the vault has allocated to each strategy.
  • Withdrawal queue: the ordered list the vault uses when it needs to pull funds from strategies during withdrawals.

A vault can hold more strategies than it has queue slots. The default withdrawal queue is capped at 10 entries (MAX_QUEUE = 10), but that cap applies only to the queue, not to the total number of active strategies.

Withdrawal queue vs active strategies

MAX_QUEUE = 10 limits the default withdrawal queue. It does not cap the total number of strategies the vault can hold.

A strategy can be active without being in the default queue. Such a strategy can still receive debt and be rebalanced with update_debt(), but it will not be used automatically during withdrawals unless:

  1. it is in the default queue, or
  2. the caller passes a custom queue to withdraw / redeem and useDefaultQueue is false.

How the vault works

Capital flow

Illustrative only — capital flow overview
User deposits USDC → Multi-Strategy Vault issues shares

Vault allocates USDC to multiple strategies
↓ ↓ ↓
Strategy A Strategy B Strategy C
(e.g. Aave) (e.g. Yearn) (e.g. Spark)

What “debt” means

In this vault, “debt” means the amount of vault assets that have been allocated to and are currently deployed in a strategy. The term comes from Yearn's vault accounting model: the vault records how much it has given each strategy, and that outstanding allocation is tracked as the strategy's “debt.” It is not a financial liability — the vault is not borrowing from the strategy. It is simply tracking how much capital is currently out on assignment.

For each active strategy, the vault tracks:

  • current debt: how much capital is currently allocated,
  • max debt: the upper bound the vault is allowed to allocate,
  • last report and related accounting state.

When the vault increases debt, it pushes idle assets into a strategy. When it decreases debt, it pulls assets back out.

Debt allocation vs profit realization

Changing a strategy's debt is different from realizing its gains or losses. When you call update_debt(), you are moving capital. When you call process_report(), you are discovering whether the capital already deployed has grown or shrunk. You usually do both, but at different times.

What queue order controls, and what it does not

The default queue controls:

  • the order in which strategies are used during withdrawals,
  • the default source order when the vault needs to free funds,
  • the target strategy for auto-allocation, if auto-allocation is enabled.

The default queue does not determine the priority of manual rebalancing. A call to update_debt(strategy, targetDebt, maxLoss) always acts on the specific strategy you pass in.

How withdrawal queues work in practice

When a user withdraws USDC, the vault first checks idle balance. If that is not enough, it goes to the first strategy in the default queue and pulls what it needs. If the first strategy does not have enough, it continues to the second strategy, and so on. Strategies outside the queue are not used for withdrawals unless you explicitly allow custom queues and the caller provides one.

Core concepts

1. Strategy compatibility

A strategy added to the vault must:

  • be ERC-4626-compatible,
  • use the same underlying asset as the vault,
  • not already be active in the vault,
  • not be the vault itself.

If the asset does not match, add_strategy() reverts.

note

Several multistrategy vault methods intentionally use Yearn/Vyper-style snake_case names, such as process_report() and update_debt(). Use the exact selectors shown here and in the contract reference, even from Solidity or TypeScript integrations.

2. Reporting and accounting

The vault does not automatically discover profit or loss. Operators must explicitly call:

Fragment — process_report signature
function process_report(address strategy_) external returns (uint256 gain, uint256 loss);

This is a core part of the operating model.

process_report():

  • compares a strategy’s current asset value with its recorded debt,
  • realizes gain or loss,
  • applies accountant fees and refunds if an accountant is configured,
  • updates vault accounting,
  • locks profits over time if profit locking is enabled.

If you skip reporting, the vault’s accounting will drift away from strategy reality.

Reporting vault idle assets

process_report(address(this)) is also valid. It is used to account for assets that arrive directly in the vault itself, for example airdropped underlying tokens.

3. Rebalancing

Rebalancing happens through:

Fragment — update_debt signature
function update_debt(address strategy_, uint256 targetDebt_, uint256 maxLoss_) external returns (uint256);

This function:

  • increases debt by sending idle assets into a strategy,
  • decreases debt by withdrawing assets from a strategy,
  • respects the strategy’s max debt,
  • respects minimumTotalIdle, if configured,
  • reverts if realized loss exceeds maxLoss_.

Useful values:

  • targetDebt_ = type(uint256).max: allocate as much available idle capital as possible, up to the strategy’s max debt,
  • targetDebt_ = 0: attempt to withdraw everything from that strategy.
Why maxLoss matters during rebalancing

When the vault pulls assets from a strategy, it may discover that the strategy’s assets have declined in value. The maxLoss_ parameter (in basis points) sets the maximum loss you will tolerate before the rebalance reverts. A value of 0 means you accept no loss. A value of 100 means you accept up to 1% slippage or realized loss. This protects you from accidentally liquidating at a terrible time.

4. Auto-allocation

If autoAllocate is enabled, the vault will try to allocate newly deposited assets to the first strategy in the default queue.

Fragment — set_auto_allocate signature
function set_auto_allocate(bool autoAllocate_) external;

Two details matter here:

  • auto-allocation only targets _defaultQueue[0], not the whole queue,
  • if the default queue is empty, deposits still succeed, but funds remain idle because there is no queue entry to allocate to.

5. Queue policy

The vault exposes two queue controls:

Fragment — queue policy controls
function set_default_queue(address[] memory newDefaultQueue_) external;
function set_use_default_queue(bool useDefaultQueue_) external;

set_default_queue() sets the stored default queue. set_use_default_queue() determines whether custom queues passed to withdraw / redeem are respected.

If useDefaultQueue is true, the vault ignores any custom queue passed by a caller and always uses the stored default queue.

Avoid duplicate entries in the default queue

set_default_queue() checks that listed strategies are active, but it does not deduplicate the array.

Do not put the same strategy into the default queue more than once. Duplicate entries can break assumptions used by maxWithdraw / maxRedeem style calculations and make withdrawal behavior harder to reason about.

Operating workflow

Step 1 — Deploy the vault through a factory

MultistrategyVault must be deployed through a MultistrategyVaultFactory. The vault constructor deliberately prevents direct initialization (see the caution box at the top of this page), so new MultistrategyVault() produces an implementation contract that cannot accept deposits.

If your environment already has a MultistrategyVaultFactory instance — either one deployed by the Octant team or one documented by the deployment you are integrating with — use that. Otherwise, deploy your own factory first using the source in octant-v2-core (the repo includes script/deploy/DeployVaultFactory.s.sol for factory deployment). After that, use script/deploy/DeployVault.s.sol when you want to deploy a vault through an existing factory.

Once you have a factory, call:

Fragment — deployNewVault signature
function deployNewVault(
address asset,
string memory _name,
string memory symbol,
address roleManager,
uint256 profitMaxUnlockTime
) external returns (address);

The factory deploys a minimal proxy clone (EIP-1167) of the vault implementation and immediately calls initialize() on that clone. The address returned is your usable vault.

This means:

  • you do not deploy a fresh full implementation each time,
  • the roleManager you pass at deployment matters immediately,
  • post-deploy configuration happens on the clone address returned by the factory.

Step 2 — Assign roles before trying to operate the vault

This is the most important post-deploy step.

A newly deployed vault knows its roleManager, but day-to-day operational methods are role-gated. Before you can add strategies, change the queue, rebalance, or process reports, the roleManager must grant the relevant roles.

The vault exposes:

Fragment — role management signatures
function set_role(address account_, uint256 rolesBitmask_) external;
function add_role(address account_, Roles role_) external;
function remove_role(address account_, Roles role_) external;

Common roles in the initial setup flow are:

  • ADD_STRATEGY_MANAGER
  • QUEUE_MANAGER
  • DEBT_MANAGER
  • MAX_DEBT_MANAGER
  • REPORTING_MANAGER
  • DEPOSIT_LIMIT_MANAGER
  • MINIMUM_IDLE_MANAGER
  • EMERGENCY_MANAGER

A minimal example:

Fragment — minimal role grant example
vault.add_role(ops, IMultistrategyVault.Roles.ADD_STRATEGY_MANAGER);
vault.add_role(ops, IMultistrategyVault.Roles.QUEUE_MANAGER);
vault.add_role(ops, IMultistrategyVault.Roles.DEBT_MANAGER);
vault.add_role(ops, IMultistrategyVault.Roles.MAX_DEBT_MANAGER);
vault.add_role(ops, IMultistrategyVault.Roles.REPORTING_MANAGER);
vault.add_role(ops, IMultistrategyVault.Roles.DEPOSIT_LIMIT_MANAGER);

Without this step, the rest of the setup flow will revert with NotAllowed().

Step 3 — Add strategies

Fragment — add_strategy signature
function add_strategy(address newStrategy_, bool addToQueue_) external;

This:

  • registers the strategy as active,
  • initializes its debt tracking,
  • optionally appends it to the default queue if addToQueue_ is true and the queue still has room.
addToQueue_ = true does not guarantee queue insertion

If addToQueue_ is true but the default queue is already full, the strategy is still added as active, but it is not appended to the queue.

Step 4 — Set debt ceilings

Fragment — update_max_debt_for_strategy signature
function update_max_debt_for_strategy(address strategy_, uint256 newMaxDebt_) external;

Set an explicit max debt for every active strategy you plan to fund.

Until you do this, update_debt() has no meaningful ceiling to work against. Setting max debt = 0 is also the cleanest way to stop future allocations to a strategy while keeping existing debt in place.

Step 5 — Configure queue, auto-allocation, and idle policy

Useful configuration calls:

Fragment — queue, auto-allocate, and idle configuration signatures
function set_default_queue(address[] memory newDefaultQueue_) external;
function set_use_default_queue(bool useDefaultQueue_) external;
function set_auto_allocate(bool autoAllocate_) external;
function set_minimum_total_idle(uint256 minimumTotalIdle_) external;

Use them deliberately:

  • default queue sets the normal withdrawal order,
  • useDefaultQueue decides whether callers may override that order with a custom queue,
  • autoAllocate sends new deposits to the first default-queue strategy when possible,
  • minimumTotalIdle keeps a withdrawal buffer in the vault and is respected by update_debt().

Step 6 — Enable deposits and fund the vault

A newly initialized vault has a deposit limit of zero. Before the vault can receive assets, configure either a nonzero fixed deposit limit or a deposit-limit module. The caller needs the DEPOSIT_LIMIT_MANAGER role.

Fragment — enable deposits and fund idle liquidity
uint256 depositAmount = 1_000_000e6; // Example for a 6-decimal asset

vault.set_deposit_limit(depositAmount, false);

IERC20(asset).approve(address(vault), depositAmount);
vault.deposit(depositAmount, receiver);

The depositor must hold enough of the underlying asset and approve the vault first. The deposit creates the idle liquidity that update_debt() can allocate. If autoAllocate is enabled and the default queue is configured, a deposit may allocate automatically instead; verify the resulting idle and debt balances before making a separate update_debt() call.

Step 7 — Rebalance with update_debt()

Fragment — update_debt full signature
function update_debt(
address strategy_,
uint256 targetDebt_,
uint256 maxLoss_
) external returns (uint256);

Typical uses:

  • allocate part of idle to a strategy,
  • fully fund the strategy up to its ceiling,
  • pull debt back to idle,
  • unwind a strategy before revocation.

Examples:

Fragment — update_debt usage examples
// Allocate up to the strategy's max debt using available idle
vault.update_debt(strategyA, type(uint256).max, 0);

// Pull everything back from the strategy, allowing up to 1% loss
vault.update_debt(strategyA, 0, 100);

Remember that maxLoss_ is in basis points:

  • 0 = accept no realized loss,
  • 100 = accept up to 1%,
  • 10_000 = accept any loss.

Step 7 — Process reports as part of normal operations

Fragment — process_report signature
function process_report(address strategy_) external returns (uint256 gain, uint256 loss);

This should be part of your normal runbook, not an afterthought.

Use it to:

  • realize profit and loss,
  • keep debt accounting current,
  • trigger fee assessment if an accountant is configured,
  • update profit-locking state.

A practical mental model is:

  1. strategies generate or lose value,
  2. process_report() realizes that change inside the vault,
  3. update_debt() changes how much capital is allocated going forward.

Those are related operations, but they are not the same operation.

Step 8 — Understand withdrawal behavior before going live

MultistrategyVault extends the usual ERC-4626 withdrawal path with loss tolerance and optional queue selection:

Fragment — withdraw and redeem signatures
function withdraw(
uint256 assets_,
address receiver_,
address owner_,
uint256 maxLoss_,
address[] calldata strategiesArray_
) external returns (uint256 shares);

function redeem(
uint256 shares_,
address receiver_,
address owner_,
uint256 maxLoss_,
address[] calldata strategiesArray_
) external returns (uint256 assets);

Withdrawal flow:

  1. use idle first,
  2. if needed, pull from strategies in the selected order,
  3. selected order comes from the custom queue only if useDefaultQueue == false,
  4. otherwise the vault uses the stored default queue.

That means a strategy outside the default queue is not an automatic withdrawal source unless you explicitly allow custom queues and pass one in.

Additional controls you may also need

The vault exposes additional operational controls that are worth knowing early:

Fragment — additional operational control signatures
function set_deposit_limit(uint256 depositLimit_, bool shouldOverride_) external;
function set_deposit_limit_module(address depositLimitModule_, bool shouldOverride_) external;
function set_withdraw_limit_module(address withdrawLimitModule_) external;
function set_accountant(address newAccountant_) external;
function setProfitMaxUnlockTime(uint256 newProfitMaxUnlockTime_) external;

One of the two deposit-control functions is required before the first deposit because a new vault starts with a zero deposit limit. The other controls are relevant if you need:

  • a hard cap or dynamic module-based cap on deposits,
  • a custom withdraw-limit module,
  • fee accounting and refunds,
  • a specific profit-locking policy.

Emergency and lifecycle operations

Soft revoke vs force revoke

To remove a strategy cleanly, first withdraw its debt, then revoke it:

Fragment — revoke_strategy signature
function revoke_strategy(address strategy_) external;

A soft revoke requires currentDebt == 0.

If a strategy is broken or unrecoverable, the vault also exposes:

Fragment — force_revoke_strategy signature
function force_revoke_strategy(address strategy_) external;

This is dangerous. force_revoke_strategy() does not try to pull funds back. It writes the remaining debt off as loss for the vault.

Before using it, consider this order:

  1. try update_debt(strategy, 0, maxLoss) to pull out whatever you can,
  2. consider buy_debt() if that makes sense for your recovery plan,
  3. use force_revoke_strategy() only as a last resort.

Shutdown

Fragment — shutdown_vault signature
function shutdown_vault() external;

shutdown_vault() is controlled by the EMERGENCY_MANAGER role and is irreversible.

It:

  • sets the vault into shutdown mode,
  • disables deposits by setting the deposit limit to zero,
  • clears depositLimitModule, if any,
  • keeps withdrawals available,
  • grants DEBT_MANAGER to the shutdown caller for emergency unwinds.

In shutdown, operators should think in terms of retrieving funds, not continuing normal allocation.

Practical deployment pattern

A typical setup sequence looks like this:

  1. deploy the vault through a MultistrategyVaultFactory instance (deploy your own factory first if your environment does not already have one),
  2. grant roles from the roleManager,
  3. add each compatible strategy,
  4. set max debt per strategy,
  5. configure the default queue, useDefaultQueue, minimumTotalIdle, and autoAllocate as needed,
  6. configure a nonzero deposit limit or deposit-limit module,
  7. approve and deposit underlying assets to create idle liquidity,
  8. call update_debt() to allocate capital that was not automatically allocated,
  9. operate the vault with regular process_report() calls and periodic rebalancing.

Example configuration ideas

These are implementation sketches, not recommendations.

Balanced public goods vault

Illustrative only — balanced public goods vault allocation
40% Yield-Donating Aave Strategy → donation routing
30% Yield-Donating Spark Strategy → donation routing
20% Standard compounding strategy
10% Idle buffer in vault

Liquidity-first treasury vault

Illustrative only — liquidity-first treasury vault allocation
30% low-risk lending strategy
30% diversified yield strategy
40% idle in vault

Concentrated high-conviction vault

Illustrative only — concentrated high-conviction vault allocation
45% Strategy A
45% Strategy B
10% idle buffer

Whichever model you use, set debt ceilings and queue policy explicitly. Do not rely on implicit defaults.

Important considerations

Reporting is not optional operationally

You can deploy and fund a vault without calling process_report(), but you cannot operate it correctly for long without reporting. Gains, losses, fees, and profit locking all depend on that step.

Queue policy is part of your risk model

A default queue is not just an implementation detail. It affects withdrawal liquidity and determines which strategy receives deposits when auto-allocation is enabled.

Idle policy matters

minimumTotalIdle is often a better way to preserve withdrawal liquidity than keeping debt ceilings artificially low.

Role design matters as much as strategy design

Do not treat the roleManager as a formality. The vault is designed around explicit operational roles.

Before production

Before using a multistrategy vault with real funds, make sure you have:

  • chosen a clear role model for management, reporting, rebalancing, emergency actions, and any external operators,
  • validated every integrated strategy separately before adding it to the vault,
  • tested deposits, withdrawals, queue changes, debt increases, debt decreases, reporting, revocation, and shutdown flows on your target stack,
  • set explicit max debt ceilings and maxLoss tolerances for each strategy,
  • decided whether idle capital, auto-allocation, minimumTotalIdle, and the default queue match your liquidity needs,
  • documented whether callers may use custom queues or whether useDefaultQueue should be enforced,
  • configured any accountant, deposit-limit, and withdraw-limit modules intentionally rather than leaving them implicit,
  • arranged monitoring for failed reports, debt drift, queue misconfiguration, and unusual withdrawal behavior,
  • arranged an independent security review before handling real funds.

deployNewVault — signature reference

Signature as of 36ed6ad. Full reference: MultistrategyVaultFactory.

ParameterTypeDescription
assetaddressThe ERC-20 underlying asset for the vault. All strategies added to this vault must share the same asset.
_namestring memoryHuman-readable name for the vault ERC-20 share token.
symbolstring memoryTicker symbol for the vault ERC-20 share token.
roleManageraddressAddress that will be set as the initial role manager on the newly deployed vault clone. This address must later grant operational roles before the vault can be configured.
profitMaxUnlockTimeuint256Seconds over which realised profits are linearly unlocked into the share price. Pass 0 to disable profit locking.
ReturnsaddressThe address of the newly initialised vault clone (EIP-1167 minimal proxy). Use this address for all subsequent configuration.

The factory creates an EIP-1167 minimal proxy clone of the vault implementation and calls initialize() on it in the same transaction. The returned address is your usable vault; the implementation address must never be used directly.