Skip to main content

Strategy Development Tutorial

Before you read this page

Purpose: Illustrate the structure of a simple rewards-donating strategy before you adapt the pattern to a real integration. Audience: Developers who want a conceptual strategy walkthrough before working directly from the core implementation surfaces. Level: Intermediate Source of truth: [email protected] for production patterns; this page is an explanatory tutorial. Use this page when: you want to understand the thin integration layer a strategy author is expected to implement. Do not use this page for: assuming SavingsUsdcStrategy exists as a production contract or copying the tutorial code into production without review and testing.

Code status

This page is illustrative. Use it to understand architecture and hook shape, then reconcile your final implementation against the pinned core repo and the relevant strategy-writing guide.

The following outlines SavingsUsdcStrategy — a tutorial example of a rewards-donating strategy that deploys USDC into an ERC-4626 yield vault and directs all realized rewards to a configurable external address.

The basic fund flow looks like this:

When burning is enabled, donation shares provide a first-loss buffer. That improves depositor protection, but it does not make shares redeemable 1:1 in all cases. If losses exceed the available donation-share buffer, price per share can still fall.

For [email protected], also account for YDS minimum liquidity: the first successful deposit into an empty yield-donating strategy locks 1_000 shares at address(0xdead), so the first depositor receives assets - 1000 shares rather than an exact 1:1 amount.

What this page is and is not

This page is a conceptual walkthrough of a simple generic ERC-4626 Yield Donating Strategy, illustrated with USDC as the underlying asset. It is not a claim that SavingsUsdcStrategy exists in the repo as a production contract under this exact name.

For the closest production pattern in [email protected], compare this tutorial with the repo's ERC-4626-based yield-donating strategy pattern and the Spark-specific factory flow.

The Modular Architecture

Before diving into the strategy code, a quick reminder of the Octant v2 strategy stack:

SavingsUsdcStrategy ← Tutorial contract focused on one yield-source integration
↓ inherits from
BaseHealthCheck ← Adds configurable safety bounds checking
↓ inherits from
BaseStrategy ← Delegates vault logic to a shared implementation
↓ delegates to
YieldDonatingTokenizedStrategy ← Handles reporting, donation-share minting, and loss absorption
↓ inherits from
TokenizedStrategy ← Contains ERC-4626 accounting, deposits, withdrawals, roles, and shutdown logic

Think of this as composing one thin integration layer on top of shared vault infrastructure.

  • TokenizedStrategy handles ERC-4626 accounting, share issuance and burning, access control, and vault lifecycle.
  • YieldDonatingTokenizedStrategy extends that shared implementation with donation-share minting on profit and optional donation-share burning on loss.
  • BaseStrategy gives you the strategy authoring surface: you implement the hooks that deploy funds, free funds, and report total assets.
  • BaseHealthCheck wraps reporting in configurable lower and upper bounds so unusual profit or loss reports can be reviewed before continuing normal operation.
  • SavingsUsdcStrategy only needs to integrate the external yield source correctly.

That modular structure is the main developer experience win in Octant v2: you do not rebuild ERC-4626 vault logic from scratch, you only implement the integration-specific layer.

Setting Up Our Strategy

Starter compatibility and import paths

The _symbol parameter is required by the BaseStrategy constructor in the pinned [email protected] (it has been present since 1.2.0-develop.1). Older starter-template snapshots omit it, which causes compilation failures against the current core. If you are working from a starter template, see the Hello World — Compatibility note for the required fix.

The code samples below use @octant-core/ import paths — for example @octant-core/strategies/periphery/BaseHealthCheck.sol. This is the convention for working in the starter template (octant-v2-strategy-foundry-mix), which defines the remapping @octant-core=dependencies/octant-v2-core/src.

If you are working directly inside octant-v2-core, replace @octant-core/ with src/. All other code on this page is identical for both environments; only the import prefix changes.

A minimal tutorial constructor looks like this:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {BaseHealthCheck} from "@octant-core/strategies/periphery/BaseHealthCheck.sol";

contract SavingsUsdcStrategy is BaseHealthCheck {
using SafeERC20 for IERC20;

error UnsupportedAsset(address asset_);
error AssetMismatch(address vaultAsset_, address strategyAsset_);

address public constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;

IERC4626 public immutable targetVault;

constructor(
address _asset,
address _targetVault,
string memory _name,
string memory _symbol,
address _management,
address _keeper,
address _emergencyAdmin,
address _donationAddress,
bool _enableBurning,
address _tokenizedStrategyAddress
) BaseHealthCheck(
_asset,
_name,
_symbol,
_management,
_keeper,
_emergencyAdmin,
_donationAddress,
_enableBurning,
_tokenizedStrategyAddress
) {
if (_asset != USDC) {
revert UnsupportedAsset(_asset);
}

if (IERC4626(_targetVault).asset() != _asset) {
revert AssetMismatch(IERC4626(_targetVault).asset(), _asset);
}

targetVault = IERC4626(_targetVault);
}

This keeps the example focused on the generic ERC-4626 integration pattern, while using USDC as a familiar illustrative asset. Two distinct checks run in the constructor: UnsupportedAsset rejects any _asset other than USDC, and AssetMismatch separately ensures the target vault's own asset matches _asset. If you adapt this example to a real Spark vault (which accepts DAI/sDAI or USDS/sUSDS, not USDC), change the hardcoded USDC constant accordingly so the UnsupportedAsset check matches the vault you are wiring to.

A few details matter here:

  • _symbol is part of the current BaseStrategy constructor surface and should not be omitted.
  • _tokenizedStrategyAddress is the shared YieldDonatingTokenizedStrategy implementation address that the strategy delegates vault logic to.
  • IERC4626(_targetVault).asset() == _asset should be validated explicitly so the strategy cannot be wired to a mismatched upstream vault.
  • forceApprove should be used inside _deployFunds for the exact amount being deployed, followed by clearing the allowance back to zero. This is the exact-approve-then-clear pattern used across the ERC-4626 strategies and avoids leaving a standing max approval on the upstream vault.

Role meanings follow the Octant v2 strategy model:

  • Management is the primary administrator. It can change key roles, handle interventions when bounds are hit, and update strategy-level policy.
  • Keeper calls report().
  • Emergency Administrator can shut down the strategy and trigger emergency flows.
  • Donation Address receives newly minted donation shares on profit.
  • Enable Burning controls whether donation shares are burned first when a loss is reported.

The Three Core Hooks, Plus Important Operational Overrides

At the BaseStrategy layer, the three required strategy hooks are:

  1. _deployFunds
  2. _freeFunds
  3. _harvestAndReport

For an ERC-4626 upstream vault, you should also usually override a few operational helpers such as deposit limits, withdraw limits, and emergency withdrawal behavior. That is the difference between a minimal abstract strategy and a strategy that behaves cleanly in production-like conditions.

Integration Hooks

1. Deploying User Funds

When users deposit USDC into our strategy, we send idle assets into the target vault:

function _deployFunds(uint256 _amount) internal override {
IERC20(address(asset)).forceApprove(address(targetVault), _amount);
uint256 shares = targetVault.deposit(_amount, address(this));
require(shares > 0, "Strategy: zero shares minted");
IERC20(address(asset)).forceApprove(address(targetVault), 0);
}

The shared implementation handles strategy share accounting. This hook only moves the underlying into the upstream ERC-4626 vault.

2. Freeing User Funds

When users withdraw, the strategy must return the requested amount of USDC from the upstream vault:

function _freeFunds(uint256 _amount) internal override {
targetVault.withdraw(_amount, address(this), address(this));
}

Again, the shared implementation handles user-facing ERC-4626 semantics. This hook only unwinds the upstream position.

3. Reporting Total Assets

This is the core reporting hook. The strategy reports its total current asset value, and the shared implementation calculates profit or loss relative to the previous report.

function _harvestAndReport() internal view override returns (uint256 _totalAssets) {
uint256 vaultShares = targetVault.balanceOf(address(this));
uint256 vaultAssets = targetVault.previewRedeem(vaultShares);
uint256 idleAssets = IERC20(address(asset)).balanceOf(address(this));

if (vaultAssets > type(uint256).max - idleAssets) return type(uint256).max;
_totalAssets = vaultAssets + idleAssets;
}

From there, the shared implementation handles the rest:

  • if total assets increased, it treats the delta as profit and mints donation shares,
  • if total assets decreased, it treats the delta as loss and, when configured, burns donation shares first up to the available buffer.

Operational Overrides You Should Usually Add

For an ERC-4626 upstream vault, the following helpers are usually worth implementing as well.

4. Respecting Upstream Deposit Capacity

A simple version would just proxy maxDeposit(address(this)), but a better implementation also accounts for idle assets already sitting in the strategy.

function availableDepositLimit(address) public view override returns (uint256) {
uint256 upstreamMax = targetVault.maxDeposit(address(this));
if (upstreamMax == type(uint256).max) return type(uint256).max;
uint256 idleAssets = IERC20(address(asset)).balanceOf(address(this));

if (upstreamMax <= idleAssets) {
return 0;
}

return upstreamMax - idleAssets;
}

That returns the remaining additional deposit headroom instead of overstating available capacity.

5. Respecting Upstream Withdraw Capacity

Withdrawals should also expose how much value can currently be freed.

function availableWithdrawLimit(address) public view override returns (uint256) {
uint256 idle = IERC20(address(asset)).balanceOf(address(this));
uint256 vaultMax = targetVault.maxWithdraw(address(this));
if (vaultMax > type(uint256).max - idle) return type(uint256).max;
return idle + vaultMax;
}

6. Supporting Emergency Withdrawal

If the strategy is shut down and operators need to pull assets back from the upstream vault, emergency withdrawal should free funds from the external position.

function _emergencyWithdraw(uint256 _amount) internal override {
_freeFunds(_amount);
}

Without this override, shutdown behavior is less complete for a strategy that keeps most of its capital deployed externally.

Emergency withdrawal design

_emergencyWithdraw runs after the strategy is shut down. It is the "break glass" path — it should do the minimum to get funds out, even if the external protocol is in a weird state. The simple implementation shown here delegates to _freeFunds, but you may need to add error recovery or degraded-mode paths if the upstream protocol could become partially insolvent or liquidity-constrained.

How It All Flows Together

When a User Deposits

  1. The user calls deposit() on the strategy.
  2. The shared tokenized-strategy implementation checks limits, calculates shares, and updates accounting.
  3. The strategy's _deployFunds() hook deposits the new USDC into the target vault.
  4. Strategy shares are minted to the user.

When a User Withdraws

  1. The user calls withdraw() or redeem().
  2. The shared implementation calculates how many strategy shares must be burned.
  3. The strategy's _freeFunds() hook frees the required USDC from the target vault.
  4. The user receives USDC.

When Rewards Are Reported

  1. The keeper or management calls report().
  2. BaseHealthCheck runs the total-asset report through configured loss and profit bounds.
  3. The strategy's _harvestAndReport() hook returns the current total asset value.
  4. The shared implementation compares that value to the previous report.
  5. Profit results in donation-share minting to the configured donation address.
  6. Loss results in loss accounting and, when configured, donation-share burning first.

Factory and Deployment Context

This tutorial focuses on the strategy logic itself, not on one specific deployment path.

In [email protected], there are already factory-based deployment patterns for ERC-4626 strategies, including Spark-oriented flows. If you are not deliberately doing a manual constructor-based deployment, compare your deployment plan with the factory layer in the repo before shipping a live setup.

That distinction matters because there are two separate concerns here:

  • writing the strategy integration correctly, and
  • deploying it through the intended shared Octant infrastructure.

Summary

This example shows the core Octant v2 pattern for an ERC-4626-based Yield Donating Strategy.

The shared implementation gives you ERC-4626 accounting, reporting, donation-share minting, optional loss absorption through donation-share burning, role-based operations, and shutdown behavior. Your contract only needs to integrate the upstream yield source correctly.

For a tutorial-quality strategy, the three core hooks are enough to explain the model. For a production-quality ERC-4626 integration, you should usually also add limit-aware helpers, emergency withdrawal behavior, correct asset validation, and a deployment flow that matches the factory and implementation layer used by the repo.