Skip to main content

Hello World: Your First Octant Strategy

Purpose: Get from zero to a compiling, fork-tested first Octant Yield Donating Strategy quickly. Audience: Developers building their first Octant strategy (Solidity and Foundry basics required). Level: Beginner Source of truth: [email protected]; for the starter-template path on this page, use the tested snapshot octant-v2-strategy-foundry-mix@ddd405c18bb0c765c0256ca952d4d9f4034cf3ec and reconcile it with that pinned core release. Use this page when: you want a first custom-strategy path with concrete code and test flow. Do not use this page for: a full local Octant stack deployment, a canonical production runbook, or assumptions about newer core revisions.

Goal. Go from zero to a compiling, fork-tested Yield Donating Strategy in one page. You will build a minimal sDAI wrapper, a strategy that deposits DAI into Spark's sDAI vault, tracks the resulting asset value, and routes 100% of reported yield as donation shares to a configured address on each report().

Time. ~30 minutes.

What you need. Foundry installed, an Ethereum mainnet RPC URL, and basic Solidity familiarity.

Before you start
  • Required: Local Development Quickstart — Path B with the starter template building successfully.
  • Helpful: DeFi Concepts Primer if ERC-4626 or delegatecall are new to you.
  • Network and tooling: GitHub, the Yarn registry, Soldeer dependency sources, and an archive-capable mainnet RPC must be reachable before fork tests are meaningful.
  • Version target: this tutorial is a worked path for octant-v2-strategy-foundry-mix@ddd405c18bb0c765c0256ca952d4d9f4034cf3ec plus [email protected].

What you'll build

By the end of this page you will have:

  1. Cloned and set up the Octant strategy starter template
  2. Applied required compatibility fixes
  3. Written a strategy contract that deposits DAI into Spark's sDAI vault
  4. Written a fork test that simulates deposit, yield, report, and withdrawal
  5. Run a deployment script against a local fork

This tutorial uses shared Octant infrastructure already deployed on Ethereum mainnet. You can find every address referenced below on the Deployed Addresses page, including the YieldDonatingTokenizedStrategy shared implementation and the DragonRouter singleton used as a donation target (deploy your own splitter via PaymentSplitterFactory if you need a custom split).

Scope of this tutorial

This page targets [email protected] and uses the already deployed YieldDonatingTokenizedStrategy implementation at 0xE8797A98710518A6973Cc8612f98154EECF2C711. It is a first custom-strategy tutorial, not a full local deployment guide for the whole Octant stack. Because sDAI is an ERC-4626 vault, this example intentionally mirrors the generic ERC4626Strategy already present in octant-v2-core — if you do not need custom sDAI-specific logic, compare this tutorial with the built-in ERC4626Strategy before writing your own wrapper.

The code samples below assume your project exposes Octant core through a remapping such as @octant-core/.... If your Foundry project uses a different remapping layout, keep the Solidity code the same and adjust only the import prefixes.

Agent task card

Ask a coding assistant to follow this page in order, edit only the files named in each step, and run the verification commands before moving on. It should not modify dependencies/octant-v2-core, invent contract addresses, or skip compatibility notes.

Goal: Build a minimal sDAI Yield Donating Strategy from the starter template.

Start repo: octant-v2-strategy-foundry-mix@ddd405c18bb0c765c0256ca952d4d9f4034cf3ec

Core target pin: octant-v2-core@36ed6ad6665661a18f83394d561fa75c68ccf4ac (tag 1.2.0-develop.15)

Files to edit:

  • src/strategies/yieldDonating/YieldDonatingStrategy.sol (compatibility fix: add _symbol parameter)
  • src/test/yieldDonating/YieldDonatingSetup.sol (compatibility fix: add _symbol argument)
  • src/strategies/yieldDonating/YieldDonatingStrategyFactory.sol (compatibility fix: add _symbol parameter)
  • src/test/yieldDonating/YieldDonatingFunctionSignature.t.sol (compatibility fix: update ABI signature)
  • src/strategies/yieldDonating/SDAIStrategy.sol (new file: strategy contract)
  • test/SDAIStrategy.t.sol (new file: fork test)
  • script/Deploy.s.sol (new file: deploy script)
  • foundry.toml (optional: disable lint-on-build)
  • .env (add deployment variables)

Files NOT to edit: dependencies/octant-v2-core/*, any file in 07_smart_contracts/

Required setup: Foundry installed, Ethereum mainnet RPC URL, starter template cloned at pinned commit

Verification:

  • forge build (must pass after compatibility fixes)
  • ETH_RPC_URL=... forge test --match-test test_depositAndReport -vv
  • anvil --fork-url $ETH_RPC_URL in one terminal, then forge script script/Deploy.s.sol --rpc-url http://127.0.0.1:8545 --broadcast in another terminal

Do not:

  • Modify anything under dependencies/octant-v2-core/
  • Invent or guess contract addresses; use only those from the Deployed Addresses page
  • Skip the four compatibility fixes before running forge build
  • Use address(0) for any role parameter; the initializer will revert
  • Broadcast to $ETH_RPC_URL unless you intentionally want a live-network deployment

(See also: Agent Anti-Patterns)


Step 1 — Clone the tested strategy workspace

The starter template bundles octant-v2-core as a Git submodule under dependencies/octant-v2-core/ — the Solidity remappings resolve @octant-core/... imports into that directory. The submodule must be initialised before anything will compile.

Command — clone and set up the starter template:

git clone https://github.com/golemfoundation/octant-v2-strategy-foundry-mix.git my-strategy
cd my-strategy
git checkout ddd405c18bb0c765c0256ca952d4d9f4034cf3ec
yarn --version # expect 1.22.22 for the audited starter snapshot
yarn # generates a local yarn.lock for this snapshot
git submodule update --init --recursive # populate the octant-v2-core submodule
git -C dependencies/octant-v2-core checkout 36ed6ad6665661a18f83394d561fa75c68ccf4ac
forge soldeer install

This tutorial assumes that exact starter-template commit. The starter repo's default branch may drift and is not the baseline for this page.

After the manual core pin, expect local git state such as m dependencies/octant-v2-core. A local yarn.lock may also exist, but the starter repo gitignores it. That state is normal for this audited starter snapshot. If you rerun git submodule update --init --recursive later, it will reset the submodule away from 36ed6ad..., so re-apply the pin before building again or run 09_agent_assisted_development/scripts/reconcile-starter-pin.sh from the starter repo root to restore both the submodule HEAD and the optional foundry.lock entry in one step. Note that forge build itself can trigger the same reset when it detects missing dependencies (it prints Missing dependencies found. Installing now...); if you see that line, re-verify the core pin with git -C dependencies/octant-v2-core rev-parse HEAD before trusting the build, because an un-pinned core compiles cleanly and hides the _symbol failure the four fixes above are meant to surface. The matching machine-readable workflow manifest and starter-helper script are described in Workflow Manifests.

Known issue — forge soldeer install panic on Forge 1.5.x on macOS

If forge soldeer install panics with Attempted to create a NULL object, stop and classify the failure as tooling. Do not continue to forge build, because any missing-import errors after that panic still belong to the incomplete setup layer.

Compatibility note for the starter template

Required before forge build — apply these fixes now

At this starter-template snapshot, the scaffold uses an older constructor shape that is missing the _symbol parameter required by [email protected]. You must apply the four fixes below before running forge build. If you skip this step, compilation will fail.

Open src/strategies/yieldDonating/YieldDonatingStrategy.sol and update the constructor:

Patch — old constructor without _symbol (before fix):

// BEFORE
constructor(
address _asset,
string memory _name,
address _management,
...
) BaseStrategy(
_asset,
_name,
_management,
...
)

Patch — updated constructor with _symbol (after fix):

// AFTER
constructor(
address _asset,
string memory _name,
string memory _symbol,
address _management,
...
) BaseStrategy(
_asset,
_name,
_symbol,
_management,
...
)

Then update the matching constructor call in the test setup file, for example in src/test/yieldDonating/YieldDonatingSetup.sol:

Patch — old test setup constructor call (before fix):

// BEFORE
new Strategy(
yieldSource,
address(asset),
"YieldDonating Strategy",
management,
...
)

Patch — updated test setup constructor call with _symbol (after fix):

// AFTER
new Strategy(
yieldSource,
address(asset),
"YieldDonating Strategy",
"YDS",
management,
...
)

Next, open src/strategies/yieldDonating/YieldDonatingStrategyFactory.sol. The factory's newStrategy() function also constructs a YieldDonatingStrategy without _symbol. Add _symbol as a parameter to newStrategy() and pass it through:

Patch — old factory newStrategy() without _symbol (before fix):

// BEFORE
function newStrategy(
address _compounderVault,
address _asset,
string calldata _name
) external virtual returns (address) {
IStrategyInterface _newStrategy = IStrategyInterface(
address(
new YieldDonatingStrategy(
_compounderVault,
_asset,
_name,
management,
...
)
)
);

Patch — updated factory newStrategy() with _symbol (after fix):

// AFTER
function newStrategy(
address _compounderVault,
address _asset,
string calldata _name,
string calldata _symbol
) external virtual returns (address) {
IStrategyInterface _newStrategy = IStrategyInterface(
address(
new YieldDonatingStrategy(
_compounderVault,
_asset,
_name,
_symbol,
management,
...
)
)
);

Finally, open src/test/yieldDonating/YieldDonatingFunctionSignature.t.sol. This test hardcodes the old initialize(...) ABI signature without _symbol. Update the encoded call to match the current ITokenizedStrategy.initialize() shape:

Patch — old initialize() ABI signature (before fix):

// BEFORE
abi.encodeWithSignature(
"initialize(address,string,address,address,address,address,bool)",
address(asset),
"name",
management,
keeper,
emergencyAdmin,
dragonRouter,
true
)

Patch — updated initialize() ABI signature with _symbol (after fix):

// AFTER
abi.encodeWithSignature(
"initialize(address,string,string,address,address,address,address,bool)",
address(asset),
"name",
"SYM",
management,
keeper,
emergencyAdmin,
dragonRouter,
true
)

Verify the template compiles before continuing:

Command — verify compatibility fixes:

forge build

Do not treat the starter snapshot's existing scaffold tests as the compatibility gate for this tutorial. They require concrete TEST_ASSET_ADDRESS and TEST_YIELD_SOURCE values, and some of their placeholder assertions target older starter behavior. For this page, forge build is the required checkpoint before adding the sDAI strategy and tutorial-specific fork test below.

Forge ≥ 1.5 lint-on-build note: If forge build still fails after the four fixes above with errors about unresolved src/... imports inside the core submodule, your Forge version is running its linter during build. The linter cannot resolve the core submodule's internal import paths from the parent project context. Add the following top-level section to your foundry.toml:

Fragment — add as a top-level section in foundry.toml:

[lint]
lint_on_build = false

Then re-run forge build. This disables lint during compilation without affecting correctness. See foundry-rs/foundry#12721 for background.


First-error routing for this tutorial

First error fingerprintClassNext action
Attempted to create a NULL object during forge soldeer installtoolingStop and report the Forge or Soldeer crash. Do not continue to the compatibility edits or forge build.
Could not resolve host: github.com or getaddrinfo ENOTFOUND registry.yarnpkg.comnetworkRestore internet or proxy access, then rerun the exact failed bootstrap step.
Source "... not found" before you have a clean dependency installtoolingTreat it as an incomplete dependency tree, not as a strategy-code problem.
Fork test fails against the copied template RPCRPCReplace ETH_RPC_URL in .env with your own archive-capable endpoint.
Constructor or initialize(...) signature mismatch after dependencies are presentdocs driftApply the four compatibility fixes exactly as documented before editing tutorial logic.
Existing starter scaffold tests fail after forge build succeedsstarter scaffoldDo not rewrite dependencies/octant-v2-core to satisfy old placeholder assertions. Continue with the tutorial-specific strategy and fork test, or reconcile the starter tests separately with concrete TEST_ASSET_ADDRESS and TEST_YIELD_SOURCE values.
Compiler or tutorial-specific test failure after the setup layer is healthy and the compatibility fixes are appliedcode issueDebug the strategy or test implementation itself.

Step 2 — Create your strategy file

Solidity version

The pragma below targets the starter template's Solidity 0.8.25. octant-v2-core itself compiles with 0.8.33 (Prague EVM). Both versions are compatible with BaseStrategy's >=0.8.25 pragma, but if you switch to 0.8.33, update your foundry.toml EVM target accordingly. See the version alignment note for details.

Create src/strategies/yieldDonating/SDAIStrategy.sol:

Signature as of 36ed6ad. Full reference: YieldDonatingTokenizedStrategy.

ParameterTypeDescription
_sDAIaddresssDAI vault contract (ERC-4626)
_assetaddressUnderlying asset (DAI)
_namestring memoryStrategy name for ERC-4626 shares
_symbolstring memoryStrategy symbol for ERC-4626 shares
_managementaddressCan adjust strategy parameters post-deploy
_keeperaddressCan call report() and tend()
_emergencyAdminaddressCan shutdown and emergency-withdraw
_donationAddressaddressReceives 100% of reported profit as shares
_enableBurningboolBurn donation shares first during loss
_yieldDonatingTokenizedStrategyaddressShared implementation for delegatecall

Complete file — src/strategies/yieldDonating/SDAIStrategy.sol:

// 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";

/// @title SDAIStrategy
/// @notice Minimal Octant yield-donating strategy for DAI -> sDAI.
/// @dev This example follows the same pattern as octant-v2-core's ERC4626Strategy,
/// specialized to sDAI for clarity.
contract SDAIStrategy is BaseHealthCheck {
using SafeERC20 for IERC20;

// WHY: Immutable sDAI reference saves gas on every interaction. sDAI never changes,
// so storing it as immutable avoids costly storage reads (100 gas) versus memory reads (3 gas).
IERC4626 public immutable sDAI;

constructor(
address _sDAI,
address _asset,
string memory _name,
string memory _symbol,
address _management,
address _keeper,
address _emergencyAdmin,
address _donationAddress,
bool _enableBurning,
address _yieldDonatingTokenizedStrategy
) BaseHealthCheck(
// WHY: BaseHealthCheck extends BaseStrategy and initializes the delegatecall proxy.
// When a user deposits/withdraws, their call is forwarded via delegatecall to the shared
// YieldDonatingTokenizedStrategy implementation. This contract's storage holds strategy-specific state;
// the shared implementation holds accounting state. The asset parameter must be DAI.
_asset,
_name,
_symbol,
_management,
// WHY: management can adjust strategy parameters like profitLimitRatio after deployment.
// This address has privileges to reconfigure the strategy's behavior.
_keeper,
// WHY: keeper can call report() and tend() to harvest yield and update accounting.
// Keep this as a dedicated key-manager (e.g., a safe automation service).
_emergencyAdmin,
// WHY: emergencyAdmin can shutdown the strategy and execute emergency withdrawals
// if the yield source is compromised or a critical bug is discovered.
_donationAddress,
// WHY: This address receives 100% of reported profit as newly minted strategy shares.
// In Octant's canonical setup, this is the DragonRouter singleton that routes yield
// into the downstream funding pipeline. Or use a custom PaymentSplitter for a fixed split.
_enableBurning,
// WHY: When true, donation shares are burned first during loss protection.
// If the strategy loses value, the donation buffer absorbs it before users realize losses.
_yieldDonatingTokenizedStrategy
// WHY: Address of the shared TokenizedStrategy implementation. When BaseStrategy's fallback
// is triggered, all unrecognized calls are delegatecalled to this address.
// All ERC-4626 surface (deposit, withdraw, report, etc.) is implemented here.
) {
// WHY: Catch asset mismatch at deploy time. sDAI.asset() must equal DAI.
// If sDAI wraps a different asset, deposits will fail or yield incorrect results.
// Fail fast at construction rather than discovering this at first deposit.
require(IERC4626(_sDAI).asset() == _asset, "Asset mismatch with target vault");

sDAI = IERC4626(_sDAI);
}

/// @notice Maximum additional assets that can be deposited safely.
function availableDepositLimit(address /*_owner*/) public view override returns (uint256) {
uint256 vaultLimit = sDAI.maxDeposit(address(this));
if (vaultLimit == type(uint256).max) return type(uint256).max;
uint256 idleBalance = IERC20(address(asset)).balanceOf(address(this));
return vaultLimit > idleBalance ? vaultLimit - idleBalance : 0;
}

/// @notice Maximum assets that can be withdrawn without assuming extra liquidity.
function availableWithdrawLimit(address /*_owner*/) public view override returns (uint256) {
uint256 idle = IERC20(address(asset)).balanceOf(address(this));
uint256 vaultMax = sDAI.maxWithdraw(address(this));

if (vaultMax > type(uint256).max - idle) return type(uint256).max;
return idle + vaultMax;
}

/// @dev Deploy idle DAI into sDAI.
/// Moves asset from this contract's idle balance into the sDAI vault.
/// Called by TokenizedStrategy after a deposit() is confirmed, to put newly received DAI to work.
/// The shared implementation calls this via delegatecall as part of the deposit flow.
function _deployFunds(uint256 _amount) internal override {
// WHY: Approve only the current deposit amount and clear the allowance afterward.
// forceApprove handles tokens that require clearing an existing non-zero allowance first.
IERC20(address(asset)).forceApprove(address(sDAI), _amount);
uint256 shares = sDAI.deposit(_amount, address(this));
require(shares > 0, "SDAIStrategy: zero shares minted");
IERC20(address(asset)).forceApprove(address(sDAI), 0);
}

/// @dev Withdraw DAI from sDAI to satisfy a pending redemption.
/// Called during withdraw() or redeem() operations when users request their assets back.
/// The shared TokenizedStrategy implementation detects that some assets are deployed in sDAI
/// and calls this hook to free up the needed DAI. After this returns, the DAI sits idle in this contract.
function _freeFunds(uint256 _amount) internal override {
sDAI.withdraw(_amount, address(this), address(this));
}

/// @dev Emergency path after shutdown.
/// Called only after the strategy is shutdown via emergencyAdmin or management.
/// Recovers any remaining deployed funds from sDAI without realizing new profit/loss.
/// This is a separate codepath from the normal withdrawal flow to allow recovery even if
/// the strategy is paused. It does not trigger the normal accounting machinery.
function _emergencyWithdraw(uint256 _amount) internal override {
_freeFunds(_amount);
}

/// @dev Return total assets = idle DAI + DAI value of sDAI shares.
/// Called by TokenizedStrategy during report() to determine profit/loss since the last report.
/// Must return the sum of:
/// 1. Idle DAI sitting in this contract's balance
/// 2. DAI value of all sDAI shares held by this contract (via previewRedeem)
/// The difference between this value and the previous report determines profit or loss.
/// If profit > 0, new shares are minted to the donationAddress.
/// If loss > 0 and burning is enabled, donation shares are burned to buffer the loss.
function _harvestAndReport() internal view override returns (uint256 _totalAssets) {
uint256 shares = sDAI.balanceOf(address(this));
uint256 vaultAssets = sDAI.previewRedeem(shares);
uint256 idleAssets = IERC20(address(asset)).balanceOf(address(this));

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

Why this version differs from the earlier draft:

  • it validates that sDAI.asset() == DAI, matching the pattern used in core,
  • it uses exact per-deposit forceApprove calls and clears allowance afterward,
  • it implements _emergencyWithdraw(), which matters after shutdown,
  • it overrides deposit and withdraw limits, instead of leaving the permissive defaults from BaseStrategy.

Check that it compiles:

Command — verify strategy compiles:

forge build
What happens under the hood when a user deposits

When a user calls deposit(amount, receiver) on your strategy's address:

  1. Your strategy address receives the call. But your SDAIStrategy contract does not have a deposit() function — it only has _deployFunds, _freeFunds, and _harvestAndReport.

  2. The call is forwarded via delegatecall. BaseStrategy contains a fallback() function that delegates any unrecognized call to the shared YieldDonatingTokenizedStrategy implementation you passed in the constructor.

  3. TokenizedStrategy's deposit() runs, but using your strategy's storage. It transfers DAI from the user, mints shares to the receiver, and updates the accounting — all in your strategy's storage slots, not in the shared implementation's storage. This is the critical insight: the shared implementation is stateless, and the strategy owns all state.

  4. Your _deployFunds() is called. After accepting the deposit, the shared implementation calls back into your strategy's _deployFunds() hook to move the idle DAI into sDAI. This callback ensures that strategy-specific logic (where to put the funds) stays in your code, while the vault machinery stays in the shared implementation.

This is the delegatecall pattern in action. You write the strategy-specific logic (_deployFunds, _freeFunds, _harvestAndReport); the shared implementation handles everything else (ERC-4626, accounting, role checks, donation share minting).


Step 3 — Write a fork test

Create test/SDAIStrategy.t.sol:

Complete file — test/SDAIStrategy.t.sol:

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

import {Test} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol";
import {ITokenizedStrategy} from "@octant-core/core/interfaces/ITokenizedStrategy.sol";
import {SDAIStrategy} from "../src/strategies/yieldDonating/SDAIStrategy.sol";

contract SDAIStrategyTest is Test {
// Mainnet addresses
address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
address constant SDAI = 0x83F20F44975D03b1b09e64809B757c47f942BEeA;
address constant YIELD_DONATING_TOKENIZED_STRATEGY = 0xE8797A98710518A6973Cc8612f98154EECF2C711;
uint256 constant MINIMUM_LIQUIDITY = 1_000;

SDAIStrategy strategy;
ITokenizedStrategy vault;

address management = makeAddr("management");
address keeper = makeAddr("keeper");
address emergencyAdmin = makeAddr("emergencyAdmin");
address donationAddr = makeAddr("donationAddress");
address user = makeAddr("user");

function setUp() public {
vm.createSelectFork(vm.envString("ETH_RPC_URL"));

strategy = new SDAIStrategy(
SDAI,
DAI,
"sDAI Yield Strategy",
"ysDAI",
management,
keeper,
emergencyAdmin,
donationAddr,
true,
YIELD_DONATING_TOKENIZED_STRATEGY
);

// WHY: We cast the strategy to ITokenizedStrategy because your strategy delegates
// its ERC-4626 surface (deposit, withdraw, redeem, report) to the shared implementation.
// Casting to ITokenizedStrategy makes that delegatecall pattern explicit and ensures
// we call the correct functions. When we call vault.deposit(), it is actually forwarded
// to YieldDonatingTokenizedStrategy.deposit() via delegatecall in the strategy's fallback.
vault = ITokenizedStrategy(address(strategy));

vm.startPrank(management);
strategy.setProfitLimitRatio(500); // 5%
strategy.setLossLimitRatio(100); // 1%
vm.stopPrank();
}

function test_depositAndReport() public {
uint256 depositAmount = 1000e18;
uint256 simulatedProfit = 10e18;

// Give the user DAI.
deal(DAI, user, depositAmount);

// User deposits through the tokenized strategy interface.
vm.startPrank(user);
IERC20(DAI).approve(address(vault), depositAmount);
uint256 shares = vault.deposit(depositAmount, user);
vm.stopPrank();

// On first deposit to an empty YDS, 1,000 shares are permanently locked
// at address(0xdead), so the user receives assets - MINIMUM_LIQUIDITY.
assertEq(shares, depositAmount - MINIMUM_LIQUIDITY, "first deposit must seed minimum liquidity");
assertEq(vault.balanceOf(address(0xdead)), MINIMUM_LIQUIDITY, "dead shares must be locked");

// Verify that _deployFunds() actually moved DAI into sDAI.
// Without this check, a broken _deployFunds() (e.g. a no-op) would leave
// all deposited DAI as idle balance. The profit assertion below would still
// pass because _harvestAndReport() counts idle DAI in totalAssets — so the
// deal()-injected DAI would register as profit regardless.
assertGt(
IERC4626(SDAI).balanceOf(address(strategy)),
0,
"strategy must deploy DAI into sDAI - a zero balance means _deployFunds() is broken"
);

// WHY: use deal() instead of advancing time (vm.warp).
// On a mainnet fork, advancing time does not cause external protocols like Spark to generate yield.
// deal() allows us to directly inject DAI into the strategy's balance to simulate profit.
// This is more reliable and faster than waiting for actual yield generation on fork.
// In a real scenario, this profit would come from the sDAI vault earning Spark interest.
deal(DAI, address(strategy), simulatedProfit);

vm.prank(keeper);
(uint256 profit, uint256 loss) = vault.report();

// When report() is called, the shared implementation:
// 1. Calls this strategy's _harvestAndReport() to get the new totalAssets
// 2. Compares totalAssets to the last reported value to calculate profit/loss
// 3. If profit > 0: mints new shares to the donationAddress (Octant's yield routing)
// 4. If loss > 0 and burning is enabled: burns donation shares first to protect users
// The returned (profit, loss) tuple reflects this accounting.
assertGe(profit, simulatedProfit, "reported profit should include the simulated yield");
assertEq(loss, 0, "loss should be zero in the happy path");
assertGt(vault.balanceOf(donationAddr), 0, "donation address should receive yield shares");

vm.prank(user);
// When the user redeems, they get back their original shares' worth of DAI.
// Because profit was routed to donationAddr, the user's PPS (price per share) did not increase
// from that profit. They recover their deposit (minus any slippage) and no more.
// The donation address holds the new shares that represent the accrued yield.
uint256 daiOut = vault.redeem(vault.balanceOf(user), user, user);
// The user should recover approximately their deposit — not less (loss)
// and not more (which would mean they extracted yield meant for donation).
// assertApproxEqRel checks both directions within a 1% tolerance.
assertApproxEqRel(daiOut, depositAmount, 0.01e18, "user should recover ~original deposit, no more and no less");
}

function test_lossReportBurnsDonationShares() public {
uint256 depositAmount = 1000e18;

// --- Step 1: deposit and generate profit to create donation shares ---
deal(DAI, user, depositAmount);
vm.startPrank(user);
IERC20(DAI).approve(address(vault), depositAmount);
vault.deposit(depositAmount, user);
vm.stopPrank();

deal(DAI, address(strategy), 10e18); // simulate 10 DAI profit
vm.prank(keeper);
vault.report();

// The profit report minted donation shares — record the baseline.
uint256 donationSharesBefore = vault.balanceOf(donationAddr);
assertGt(donationSharesBefore, 0, "profit report must mint donation shares");
uint256 userSharesBefore = vault.balanceOf(user);

// --- Step 2: simulate a loss ---
// Mock sDAI.previewRedeem() to return fewer assets than expected.
// This is equivalent to the sDAI vault suffering a loss event (e.g. bad
// debt in Spark). On a fork, sDAI's rate is static, so mocking is the
// only way to exercise the loss path — the same technique the core repo
// uses for its integration tests.
uint256 sDAIShares = IERC4626(SDAI).balanceOf(address(strategy));
uint256 currentValue = IERC4626(SDAI).previewRedeem(sDAIShares);
uint256 lossValue = (currentValue * 99) / 100; // 1% loss
vm.mockCall(
SDAI,
abi.encodeWithSelector(IERC4626.previewRedeem.selector, sDAIShares),
abi.encode(lossValue)
);

// --- Step 3: report the loss ---
vm.prank(keeper);
(uint256 profit, uint256 loss) = vault.report();
vm.clearMockedCalls();

// --- Step 4: verify loss-protection behaviour ---
assertEq(profit, 0, "profit must be zero in a loss report");
assertGt(loss, 0, "loss must be detected");

// Donation shares should be burned to absorb the loss (burning was
// enabled via the `true` flag in the constructor).
uint256 donationSharesAfter = vault.balanceOf(donationAddr);
assertLt(
donationSharesAfter,
donationSharesBefore,
"donation shares must be burned to protect users from loss"
);

// User shares must stay the same — loss protection shields depositors.
assertEq(vault.balanceOf(user), userSharesBefore, "user shares must not change during loss");
}
}

Why the test is written this way

Two details matter here:

  1. Use ITokenizedStrategy(address(strategy)) for ERC-4626 and reporting calls. Your strategy contract does not have a deposit() function — it only has _deployFunds, _freeFunds, and _harvestAndReport. The strategy delegates its entire ERC-4626 surface and report() logic to the shared YieldDonatingTokenizedStrategy implementation via the fallback function. Casting to ITokenizedStrategy makes that delegatecall pattern explicit and ensures we are calling the correct functions.

  2. Do not leave the implementation address as a placeholder. BaseStrategy delegate-calls the implementation during construction. A dummy address such as 0xCAFE is not a harmless TODO, it will break deployment.

Run the test:

Command — run the fork test:

ETH_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY forge test \
--match-test test_depositAndReport \
-vv

Step 4 — Configure before deployment

Before moving from a fork test to a live network, make sure these are set intentionally.

Health-check limits

The default lossLimitRatio is 0, so any loss will revert report().

Set realistic limits for the strategy you are running:

Illustrative only — health-check configuration:

strategy.setProfitLimitRatio(500); // 5%
strategy.setLossLimitRatio(100); // 1%

Roles

report() can be called by the keeper or management.

Set all of these explicitly:

  • management
  • keeper
  • emergencyAdmin
  • donationAddress (this becomes the strategy's dragonRouter in the shared implementation)

Do not use address(0) for any of them. At this pinned core release, zero addresses for these critical roles will revert initialization.

Where to source donationAddress

The donation address is where 100 % of reported yield is minted as shares. Which address you use depends on your routing setup:

  • Octant's canonical routing — use the DragonRouter singleton listed on the Deployed Addresses page. This routes yield into Octant's downstream funding pipeline.
  • Custom fixed split — deploy your own PaymentSplitter via the PaymentSplitterFactory (also on Deployed Addresses) and pass the new splitter's address.
  • Direct to a single recipient — pass any EOA or contract address that should receive the donation shares.

If you are unsure which path applies, consult the Octant team or start with the DragonRouter address for testing against the canonical flow.


Step 5 — Deploy

The deploy script reads five environment variables that are not in the starter template's .env.example (that file only covers test-time variables like ETH_RPC_URL). Before running the script, add these to your .env or export them in your shell:

Fragment — append to your .env file:

# .env (append to the file you already created from .env.example)
PRIVATE_KEY=0xYOUR_DEPLOYER_PRIVATE_KEY # deployer EOA — must hold enough ETH for gas
MANAGEMENT=0x... # address that can adjust strategy parameters after deployment
KEEPER=0x... # address authorised to call report() and tend()
EMERGENCY_ADMIN=0x... # address that can shut down the strategy in an emergency
DONATION_ADDRESS=0x4C9268BBf4302D7c489458b3eD0e15c6F7d5206d # see tip below — this example uses the DragonRouter

MANAGEMENT, KEEPER, and EMERGENCY_ADMIN are role addresses that govern the deployed strategy. For a first testnet or fork deploy you can use the same address for all three; for production each role should be a distinct multisig or access-controlled account. DONATION_ADDRESS is where 100 % of reported yield is minted as shares — see the tip above for guidance on choosing between the DragonRouter, a custom PaymentSplitter, or a direct recipient. None of the five variables may be zero — the initializer will revert.

Create script/Deploy.s.sol:

Complete file — script/Deploy.s.sol:

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

import {Script} from "forge-std/Script.sol";
import {SDAIStrategy} from "../src/strategies/yieldDonating/SDAIStrategy.sol";

contract Deploy is Script {
address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
address constant SDAI = 0x83F20F44975D03b1b09e64809B757c47f942BEeA;
address constant YIELD_DONATING_TOKENIZED_STRATEGY = 0xE8797A98710518A6973Cc8612f98154EECF2C711;

function run() external {
// Load the deployer's private key. This EOA will pay gas and own the strategy initially.
// Must hold sufficient ETH for deployment transaction.
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");

// Address with management permissions. Can adjust strategy parameters
// (profitLimitRatio, lossLimitRatio, etc.) after deployment. For production,
// this should be a multisig or governance contract.
address management = vm.envAddress("MANAGEMENT");

// Address authorized to call report() and tend(). Keepers maintain the strategy's yield harvesting cadence.
// For mainnet deployment, consider a dedicated key-management service or multisig that can rotate keys safely.
address keeper = vm.envAddress("KEEPER");

// Address with emergency powers. Can shutdown the strategy and perform emergencyWithdraw
// if the strategy or external yield source is compromised.
// For production, this should be a distinct multisig with a small trusted set.
address emergencyAdmin = vm.envAddress("EMERGENCY_ADMIN");

// Address that receives 100% of reported profit as newly minted strategy shares.
// - Canonical Octant: Use the DragonRouter singleton to route yield into Octant's funding pipeline.
// - Custom split: Deploy a PaymentSplitter and pass its address.
// - Single recipient: Any EOA or contract that should receive donation shares.
// For this example, we use the canonical DragonRouter address.
address donationAddress = vm.envAddress("DONATION_ADDRESS");

// The health-check setters below are onlyManagement, so the
// broadcaster must BE the management address for this single-tx flow.
// If your production setup uses a separate management signer (e.g. a multisig),
// move the setProfitLimitRatio / setLossLimitRatio calls into a separate
// transaction signed by the management key after deployment.
require(
vm.addr(deployerPrivateKey) == management,
"PRIVATE_KEY must derive MANAGEMENT - health-check setters are onlyManagement"
);

vm.startBroadcast(deployerPrivateKey);

// Deploy the strategy contract. Constructor parameters:
// - SDAI: The sDAI vault contract that we deploy funds into.
// - DAI: The underlying asset. Users deposit DAI, we deploy DAI into sDAI.
// - "sDAI Yield Strategy" / "ysDAI": Name and symbol for the strategy's ERC-4626 shares.
// - management, keeper, emergencyAdmin, donationAddress: Roles (see above).
// - true: Enable burning of donation shares during loss protection.
// - YIELD_DONATING_TOKENIZED_STRATEGY: Address of the shared implementation.
// This address MUST be the canonical YieldDonatingTokenizedStrategy on Ethereum.
// Passing the wrong address will break the delegatecall pattern and deposits will fail.
SDAIStrategy strategy = new SDAIStrategy(
SDAI,
DAI,
"sDAI Yield Strategy",
"ysDAI",
management,
keeper,
emergencyAdmin,
donationAddress,
true,
YIELD_DONATING_TOKENIZED_STRATEGY
);

// Set realistic health-check bounds. Without these, the defaults are:
// _profitLimitRatio = 10 000 bps (100%) — any profit passes
// _lossLimitRatio = 0 bps (0%) — ANY loss reverts report()
// The zero loss limit means the first depreciation event, however small,
// will block reporting until management intervenes. Always set bounds
// that match your operational cadence and the underlying yield source's
// expected volatility.
strategy.setProfitLimitRatio(500); // 5 %
strategy.setLossLimitRatio(100); // 1 %

vm.stopBroadcast();
}
}

Then deploy to a local Anvil fork. In a separate terminal, start Anvil from your mainnet RPC:

Command — start a local fork:

anvil --fork-url $ETH_RPC_URL

Keep Anvil running. Then, in your project terminal, broadcast the deployment to the local fork endpoint:

Command — deploy the strategy to local Anvil:

forge script script/Deploy.s.sol \
--rpc-url http://127.0.0.1:8545 \
--broadcast

This command sends the transaction only to your local Anvil fork. Do not replace http://127.0.0.1:8545 with $ETH_RPC_URL unless you have intentionally switched from this tutorial path to a live network or public testnet deployment.

If you also want source-code verification, add --verify only after you have configured the relevant explorer API key.

Connecting this strategy to a vault

This script deploys your strategy as a standalone contract. If you need to aggregate capital across multiple strategies, you can add this deployed address to a MultistrategyVault later. See the Multi-Strategy Vaults guide for how to deploy a vault and register strategies in it.


What you built

A minimal Octant yield-donating strategy that:

  • accepts DAI deposits,
  • deploys them into sDAI,
  • reports total assets as idle DAI plus the DAI value of held sDAI shares,
  • mints donation shares to the configured donation address when profit is reported,
  • can burn donation shares first when loss protection is enabled,
  • exposes the ERC-4626 user surface through the shared YieldDonatingTokenizedStrategy implementation.

Before production

Use this tutorial as a starting point, not as a final deploy checklist.

Before handling real funds, make sure you have:

  • replaced all tutorial values with real operational addresses,
  • reviewed BaseStrategy, BaseHealthCheck, and YieldDonatingTokenizedStrategy in the pinned core release,
  • added explicit tests for deposits, withdrawals, reporting, loss handling, shutdown, and emergency withdrawal,
  • decided whether you really need a custom SDAIStrategy instead of the built-in ERC4626Strategy,
  • verified how the donation address should behave in production,
  • arranged an independent security review.
caution

Code examples on this page are for educational purposes and have not been audited. Do not use them in production without thorough testing and an independent security review.


Complete files — copy-paste bundle

All files below reflect the final state after every compatibility fix and tutorial step has been applied. After applying the bundle, verify it against octant-v2-core@36ed6ad6665661a18f83394d561fa75c68ccf4ac (tag 1.2.0-develop.15) with the commands in this tutorial.

src/strategies/yieldDonating/SDAIStrategy.sol

// 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";

/// @title SDAIStrategy
/// @notice Minimal Octant yield-donating strategy for DAI -> sDAI.
/// @dev This example follows the same pattern as octant-v2-core's ERC4626Strategy,
/// specialized to sDAI for clarity.
contract SDAIStrategy is BaseHealthCheck {
using SafeERC20 for IERC20;

IERC4626 public immutable sDAI;

constructor(
address _sDAI,
address _asset,
string memory _name,
string memory _symbol,
address _management,
address _keeper,
address _emergencyAdmin,
address _donationAddress,
bool _enableBurning,
address _yieldDonatingTokenizedStrategy
) BaseHealthCheck(
_asset,
_name,
_symbol,
_management,
_keeper,
_emergencyAdmin,
_donationAddress,
_enableBurning,
_yieldDonatingTokenizedStrategy
) {
require(IERC4626(_sDAI).asset() == _asset, "Asset mismatch with target vault");
sDAI = IERC4626(_sDAI);
}

function availableDepositLimit(address /*_owner*/) public view override returns (uint256) {
uint256 vaultLimit = sDAI.maxDeposit(address(this));
if (vaultLimit == type(uint256).max) return type(uint256).max;
uint256 idleBalance = IERC20(address(asset)).balanceOf(address(this));
return vaultLimit > idleBalance ? vaultLimit - idleBalance : 0;
}

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

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

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

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

function _harvestAndReport() internal view override returns (uint256 _totalAssets) {
uint256 shares = sDAI.balanceOf(address(this));
uint256 vaultAssets = sDAI.previewRedeem(shares);
uint256 idleAssets = IERC20(address(asset)).balanceOf(address(this));
if (vaultAssets > type(uint256).max - idleAssets) return type(uint256).max;
return vaultAssets + idleAssets;
}
}

test/SDAIStrategy.t.sol

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

import {Test} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol";
import {ITokenizedStrategy} from "@octant-core/core/interfaces/ITokenizedStrategy.sol";
import {SDAIStrategy} from "../src/strategies/yieldDonating/SDAIStrategy.sol";

contract SDAIStrategyTest is Test {
address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
address constant SDAI = 0x83F20F44975D03b1b09e64809B757c47f942BEeA;
address constant YIELD_DONATING_TOKENIZED_STRATEGY = 0xE8797A98710518A6973Cc8612f98154EECF2C711;
uint256 constant MINIMUM_LIQUIDITY = 1_000;

SDAIStrategy strategy;
ITokenizedStrategy vault;

address management = makeAddr("management");
address keeper = makeAddr("keeper");
address emergencyAdmin = makeAddr("emergencyAdmin");
address donationAddr = makeAddr("donationAddress");
address user = makeAddr("user");

function setUp() public {
vm.createSelectFork(vm.envString("ETH_RPC_URL"));

strategy = new SDAIStrategy(
SDAI,
DAI,
"sDAI Yield Strategy",
"ysDAI",
management,
keeper,
emergencyAdmin,
donationAddr,
true,
YIELD_DONATING_TOKENIZED_STRATEGY
);

vault = ITokenizedStrategy(address(strategy));

vm.startPrank(management);
strategy.setProfitLimitRatio(500);
strategy.setLossLimitRatio(100);
vm.stopPrank();
}

function test_depositAndReport() public {
uint256 depositAmount = 1000e18;
uint256 simulatedProfit = 10e18;

deal(DAI, user, depositAmount);

vm.startPrank(user);
IERC20(DAI).approve(address(vault), depositAmount);
uint256 shares = vault.deposit(depositAmount, user);
vm.stopPrank();

assertEq(shares, depositAmount - MINIMUM_LIQUIDITY, "first deposit must seed minimum liquidity");
assertEq(vault.balanceOf(address(0xdead)), MINIMUM_LIQUIDITY, "dead shares must be locked");
assertGt(
IERC4626(SDAI).balanceOf(address(strategy)),
0,
"strategy must deploy DAI into sDAI"
);

deal(DAI, address(strategy), simulatedProfit);

vm.prank(keeper);
(uint256 profit, uint256 loss) = vault.report();

assertGe(profit, simulatedProfit, "reported profit should include the simulated yield");
assertEq(loss, 0, "loss should be zero in the happy path");
assertGt(vault.balanceOf(donationAddr), 0, "donation address should receive yield shares");

vm.prank(user);
uint256 daiOut = vault.redeem(vault.balanceOf(user), user, user);
assertApproxEqRel(daiOut, depositAmount, 0.01e18, "user should recover ~original deposit");
}

function test_lossReportBurnsDonationShares() public {
uint256 depositAmount = 1000e18;

deal(DAI, user, depositAmount);
vm.startPrank(user);
IERC20(DAI).approve(address(vault), depositAmount);
vault.deposit(depositAmount, user);
vm.stopPrank();

deal(DAI, address(strategy), 10e18);
vm.prank(keeper);
vault.report();

uint256 donationSharesBefore = vault.balanceOf(donationAddr);
assertGt(donationSharesBefore, 0, "profit report must mint donation shares");
uint256 userSharesBefore = vault.balanceOf(user);

uint256 sDAIShares = IERC4626(SDAI).balanceOf(address(strategy));
uint256 currentValue = IERC4626(SDAI).previewRedeem(sDAIShares);
uint256 lossValue = (currentValue * 99) / 100;
vm.mockCall(
SDAI,
abi.encodeWithSelector(IERC4626.previewRedeem.selector, sDAIShares),
abi.encode(lossValue)
);

vm.prank(keeper);
(uint256 profit, uint256 loss) = vault.report();
vm.clearMockedCalls();

assertEq(profit, 0, "profit must be zero in a loss report");
assertGt(loss, 0, "loss must be detected");
assertLt(
vault.balanceOf(donationAddr),
donationSharesBefore,
"donation shares must be burned to protect users from loss"
);
assertEq(vault.balanceOf(user), userSharesBefore, "user shares must not change during loss");
}
}

script/Deploy.s.sol

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

import {Script} from "forge-std/Script.sol";
import {SDAIStrategy} from "../src/strategies/yieldDonating/SDAIStrategy.sol";

contract Deploy is Script {
address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
address constant SDAI = 0x83F20F44975D03b1b09e64809B757c47f942BEeA;
address constant YIELD_DONATING_TOKENIZED_STRATEGY = 0xE8797A98710518A6973Cc8612f98154EECF2C711;

function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
address management = vm.envAddress("MANAGEMENT");
address keeper = vm.envAddress("KEEPER");
address emergencyAdmin = vm.envAddress("EMERGENCY_ADMIN");
address donationAddress = vm.envAddress("DONATION_ADDRESS");

require(
vm.addr(deployerPrivateKey) == management,
"PRIVATE_KEY must derive MANAGEMENT - health-check setters are onlyManagement"
);

vm.startBroadcast(deployerPrivateKey);

SDAIStrategy strategy = new SDAIStrategy(
SDAI,
DAI,
"sDAI Yield Strategy",
"ysDAI",
management,
keeper,
emergencyAdmin,
donationAddress,
true,
YIELD_DONATING_TOKENIZED_STRATEGY
);

strategy.setProfitLimitRatio(500);
strategy.setLossLimitRatio(100);

vm.stopBroadcast();
}
}

Required .env keys

ETH_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY # archive-capable endpoint required for fork tests
PRIVATE_KEY=0xYOUR_DEPLOYER_PRIVATE_KEY
MANAGEMENT=0x...
KEEPER=0x...
EMERGENCY_ADMIN=0x...
DONATION_ADDRESS=0x4C9268BBf4302D7c489458b3eD0e15c6F7d5206d

Foundry does not automatically load .env for every bare command in every shell. For fork tests, either export ETH_RPC_URL first or prefix the command as shown below.

Verification commands

# 1. Compile everything
forge build

# 2. Run the fork test for SDAIStrategy
ETH_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY forge test \
--match-test test_depositAndReport -vv

# 3. Start a local Anvil fork in a separate terminal
anvil --fork-url $ETH_RPC_URL

# 4. Deploy to the local fork endpoint, not directly to $ETH_RPC_URL
forge script script/Deploy.s.sol \
--rpc-url http://127.0.0.1:8545 \
--broadcast
make test after creating test/

This starter snapshot's Makefile does not mark test as a phony target. After this tutorial creates the top-level test/ directory, plain make test can return make: 'test' is up to date. without running Forge. Use the direct forge test ... commands above for tutorial verification. If you intentionally run the starter Makefile targets, force execution with make -B test and make sure ETH_RPC_URL, TEST_ASSET_ADDRESS, and TEST_YIELD_SOURCE are configured.