Skip to main content

Writing a YDS Strategy (Foundry workflow)

Before you read this page

Purpose: Show how to design and implement a concrete Yield Donating Strategy in Foundry, using wrapper-style ERC-4626 integrations as the worked example. Audience: Developers who already understand ERC-4626, mainnet-fork testing, and Octant's high-level strategy model. Level: Intermediate. Source of truth: [email protected], especially the shared YDS base contracts and ERC4626Strategy.sol. Use this page when: you want a deeper implementation reference than the starter tutorial and need to reason about hook design, reporting, and wrapper integration details. Do not use this page for: a claim that every snippet is production-ready as-is, a full local Octant stack deployment guide, or assumptions about newer core revisions.

How to use this page

This page is a deep implementation reference. It builds strategies from a minimal base and explains the design reasoning behind each decision. If you want the fastest path to a working strategy using a pre-configured starter template, start with Hello World Strategy or How to Create a Yield Donating Strategy.

Treat the examples here as concrete learning material, not as an authoritative release manifest. The pinned core repo remains authoritative for hook semantics, constructor surfaces, loss handling, and reporting behavior.

Real integrations: Spark's sDAI (ERC-4626 wrapper over the Sky/Dai Savings Rate) and Sky's sUSDS (ERC-4626 wrapper over SSR)

What you’ll build. Two direct-deposit YDS strategies that route deposits into interest-bearing ERC-4626 wrappers and donate realized profit by minting strategy shares to a donation address at report() time:

  • YDS_SDAI_Strategy — deposits DAI into sDAI (Spark's ERC-4626 wrapper over the Sky/Dai Savings Rate).
  • YDS_SUSDS_Strategy — deposits USDS into sUSDS (Sky’s ERC-4626 wrapper around SSR) on Ethereum mainnet.

These are new tutorial files; they do not exist in the pinned core repo until you create them. The repo contains production strategies (e.g. ERC4626Strategy.sol) but not these sDAI/sUSDS-specific implementations.

Assumptions. Ethereum mainnet; Foundry; users deposit directly into your strategy’s ERC-4626. Profit is realized on the strategy’s report() call (keeper/management). On profit, the base mints donation shares to the donation address. On loss, the base burns donation shares first if enableBurning is enabled; only residual loss lowers PPS.

1.3.0 first-deposit behavior

The shared YDS implementation seeds 1_000 permanently locked shares at address(0xdead) on the first successful empty-strategy deposit or mint. In tests, a first deposit(assets, receiver) should expect assets - 1000 user shares, and a first previewMint(shares) should expect shares + 1000 assets.

Closest production pattern in [email protected]

For wrapper-based integrations like sDAI and sUSDS, the closest repo-level reference is ERC4626Strategy.sol.

This page keeps the examples concrete and strategy-specific, but the production-oriented pattern in the core repo is the better reference for asset validation, approval handling, deposit limits, withdraw limits, and emergency withdrawal behavior.

Fee-charging target vaults are not supported

Wrapper-style ERC-4626 yield-donating strategies must not be deployed against a target vault that charges an entry/deposit or withdrawal/exit fee. Deposit accounting credits the full pre-fee amount, so a new depositor could exit before the next report() and drain existing users. sDAI and sUSDS charge no such fee, so the worked examples here are safe — but confirm the same before wiring this pattern to any other ERC-4626 vault.

Task card — Writing a YDS Strategy

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.

FieldValue
GoalImplement two ERC-4626 wrapper-based Yield Donating Strategies (sDAI and sUSDS) with Foundry, including mainnet-fork tests.
Start repooctant-v2-core@06ae2b9fa2e4443a3d9f6148498d4ada4f9861e8 (tag 1.3.0)
Files to editsrc/strategies/yieldDonating/YDS_SDAI_Strategy.sol, src/strategies/yieldDonating/YDS_SUSDS_Strategy.sol, test/unit/strategies/yieldDonating/YDS_SDAI_Strategy.t.sol, test/unit/strategies/yieldDonating/YDS_SUSDS_Strategy.t.sol, script/deploy/DeployYDS.s.sol, .env
Files NOT to editsrc/strategies/periphery/BaseHealthCheck.sol, src/core/BaseStrategy.sol, src/core/interfaces/ITokenizedStrategy.sol
Required setupFoundry installed; octant-v2-core checked out at 06ae2b9f; dependencies installed via Path A of the Local Development Quickstart; ETH_RPC_URL set in .env
Verification commandsforge build, forge test --fork-url $ETH_RPC_URL -vvv
Do notModify core contracts, invent contract addresses, skip health-check configuration. If working in octant-v2-strategy-foundry-mix instead, replace all src/ import prefixes with @octant-core/ (see import note below).

(See also: Agent Anti-Patterns)

Verify the target interfaces for the yield sources

Both sDAI and sUSDS are ERC-4626 vaults. You only need the standard interface surface:

  • deposit(uint256 assets, address receiver)
  • withdraw(uint256 assets, address receiver, address owner)
  • redeem(uint256 shares, address receiver, address owner)
  • previewRedeem(uint256 shares)
  • maxDeposit(address) / maxWithdraw(address)
  • asset(), balanceOf(address), etc.

Spark / sDAI

Docs: https://docs.spark.fi/dev/savings/sdai-token

Etherscan: https://etherscan.io/address/0x83F20F44975D03b1b09e64809B757c47f942BEeA#code

Sky / sUSDS

Docs: https://developers.sky.money/protocol/tokens/susds/

Etherscan: https://etherscan.io/address/0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD#code

The three core hooks, plus a few important operational overrides

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

  • _deployFunds(uint256 amount) — after deposit / mint, move underlying into the wrapper.
  • _freeFunds(uint256 amount) — during withdraw / redeem, free underlying from the wrapper when idle balance is insufficient.
  • _harvestAndReport() returns (uint256 totalAssets) — trusted step that returns truthful total assets so the base can settle profit or loss.

For sDAI and sUSDS, yield comes from the wrapper’s exchange-rate drift. There are no separate reward tokens to claim. In practice, wrapper-based strategies should usually also override:

  • availableDepositLimit(address)
  • availableWithdrawLimit(address)
  • _emergencyWithdraw(uint256)

Those are not part of the minimal abstract surface, but they matter operationally for clean ERC-4626 integrations.

Shared interface and base imports

Import paths on this page

This guide assumes you are working directly inside octant-v2-core, so all imports use bare src/ paths (e.g. src/strategies/periphery/BaseHealthCheck.sol).

If you are instead working in octant-v2-strategy-foundry-mix, replace every src/ prefix with @octant-core/. The starter template defines the remapping @octant-core=dependencies/octant-v2-core/src, so the equivalent import is:

Illustrative only — starter-template import path

import {BaseHealthCheck} from "@octant-core/strategies/periphery/BaseHealthCheck.sol";

All other code on this page is identical for both environments; only the import prefix changes.

Fragment — shared imports for both strategies

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 "src/strategies/periphery/BaseHealthCheck.sol";

Napkin implementation — sDAI (DAI → sDAI)

YDS constructor signature

Both strategies inherit from BaseHealthCheck, which forwards all arguments to BaseStrategy. BaseStrategy then delegatecalls initialize on the implementation you pass as _tokenizedStrategyAddress — so the donation-minting and dead-share behaviour comes from that implementation, not from a hardcoded contract. Pass the YieldDonatingTokenizedStrategy implementation here (see below). The constructor signature is:

ParameterTypeDescription
_assetaddressThe underlying ERC-20 token (e.g. DAI, USDS)
_namestring memoryHuman-readable strategy name
_symbolstring memoryStrategy share token symbol
_managementaddressManagement role — can configure strategy parameters
_keeperaddressKeeper role — can call report()
_emergencyAdminaddressEmergency admin — can trigger emergency shutdown
_donationAddressaddressReceives minted donation shares on profit
_enableBurningboolIf true, donation shares are burned first on loss
_tokenizedStrategyAddressaddressAddress of the deployed YieldDonatingTokenizedStrategy implementation — not the generic TokenizedStrategy. Passing the generic implementation produces a strategy with no donation minting and no dead-share seeding.

Signature as of 06ae2b9f. Full reference: YieldDonatingTokenizedStrategy.

Mainnet shared implementation

For Ethereum mainnet, pass the already-deployed YieldDonatingTokenizedStrategy at 0xE8797A98710518A6973Cc8612f98154EECF2C711 as the _tokenizedStrategyAddress constructor argument. See Deployed Addresses for the full list of shared implementations and factories.

Addresses. DAI (underlying), sDAI at 0x83F20F44975D03b1b09e64809B757c47f942BEeA.

The complete file is collapsed into the Complete files — copy-paste bundle at the end of this page — copy it from there.

Napkin implementation — sUSDS (USDS → sUSDS)

Addresses. USDS (underlying), sUSDS at 0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD on Ethereum mainnet.

The complete file is collapsed into the Complete files — copy-paste bundle at the end of this page — copy it from there.

Why these strategies are simple

sDAI and sUSDS are good YDS examples because they do not require reward-token harvesting, swaps, or protocol-specific reward accounting. All the strategy has to do is:

  1. move underlying into the wrapper,
  2. free underlying on withdrawals,
  3. report truthful net redeemable assets as idle + previewRedeem(wrapperShares).

That makes them a clean fit for the YDS model.

Security and operational notes

Mirror upstream limits accurately

For ERC-4626 wrappers, surface upstream limits through your strategy — but do it carefully.

A better pattern than blindly proxying maxDeposit() or maxWithdraw() is:

  • deposit limit: remaining additional headroom, not total upstream capacity,
  • withdraw limit: idle assets plus whatever the upstream wrapper can currently release.

That is why the examples above subtract current idle balance from maxDeposit() and add idle balance to maxWithdraw().

Use BaseHealthCheck

BaseHealthCheck is a strong default for YDS because the relevant signal is the total-asset delta between reports.

  • profitLimitRatio bounds how much profit a single report may realize.
  • lossLimitRatio bounds how much loss a single report may realize.
  • If the delta is out of bounds, report() reverts before finalization.

YDS strategies use BaseHealthCheck because they care about the asset-value delta. YSS strategies use the extended BaseYieldSkimmingHealthCheck, which also checks exchange-rate movement.

Know the health-check defaults before your first report

Out of the box, _profitLimitRatio is 10 000 bps (100%) and _lossLimitRatio is 0 bps (0%). That means any profit up to 100% of total assets passes, but any loss at all reverts report().

For most real deployments, set realistic bounds during deployment:

Illustrative only — health-check configuration

// Example: allow up to 5% profit and 1% loss per report period
strategy.setProfitLimitRatio(500);
strategy.setLossLimitRatio(100);

Loss protection is conditional

On profit, YDS mints shares to the donation address.

On loss, the strategy burns donation shares first only if enableBurning is enabled and only up to the available donation-share buffer. If losses exceed that buffer, the remaining loss lowers PPS.

Foundry: prove it on a mainnet fork

Setup. Pin a recent mainnet block, fund test EOAs with DAI / USDS, set keeper / management roles, and approve the strategy for the underlying.

A. Deposit / Withdraw paths

  • Deposit → strategy shares minted → _deployFunds increases wrapper share balance.
  • Withdraw → _freeFunds calls withdraw(assets, ...) on the wrapper → user receives underlying.

B. Profit interval (donation mint)

  • Warp time forward so the wrapper share redemption value increases.
  • Keeper calls report().
  • Assert:
    • strategy totalSupply increases by roughly profit / PPS_prev,
    • donation-address share balance increases accordingly,
    • user PPS remains unchanged within tolerance.

C. Loss handling (separate mock)

sDAI and sUSDS are designed for non-lossy accrual. Use a lossy mock source to test the loss path:

  • donation shares burn first when enableBurning is enabled,
  • PPS declines only if losses exceed the donation-share buffer.

D. Health check

  • Configure realistic profitLimitRatio / lossLimitRatio.
  • Force an outlier.
  • Assert report() reverts and no donation-share balances change.
  • Re-run within bounds.

Testing checklist

Your strategy isn't ready for review until you've tested:

  • Happy path: deposit → report with profit → donation shares appear → user withdraws full principal
  • Loss scenario: simulate a loss → confirm donation shares are burned first → confirm user PPS drops only for the residual
  • Zero deposit: what happens when _deployFunds(0) is called?
  • Emergency shutdown: call shutdownStrategy() → confirm _emergencyWithdraw recovers funds
  • Health-check boundaries: set tight limits → confirm report() reverts when profit or loss exceeds them
  • Deposit/withdraw limits: confirm availableDepositLimit and availableWithdrawLimit return correct values

Before production

Before adapting this pattern to a live deployment, confirm that you have:

  • validated the upstream wrapper’s asset() against your chosen underlying,
  • used exact per-deposit approvals and cleared allowances,
  • implemented realistic deposit and withdraw limits,
  • added _emergencyWithdraw() if the strategy keeps most capital deployed externally,
  • configured health-check bounds before the first live report(),
  • tested deposits, withdrawals, reporting, shutdown, and loss scenarios on a mainnet fork,
  • reviewed ERC4626Strategy.sol as the nearest production pattern.
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 audit. See the full Disclaimer.


Complete files bundle

Complete files — copy-paste bundle

All files below reflect the final state after every step and compatibility fix in this tutorial. The bundle includes both strategy contracts, tests for each, a deploy script, and the required environment variables — everything the task card promises. After applying the bundle, run forge build and forge test --fork-url $ETH_RPC_URL -vvv against octant-v2-core@06ae2b9f (tag 1.3.0).

src/strategies/yieldDonating/YDS_SDAI_Strategy.sol

Complete file — src/strategies/yieldDonating/YDS_SDAI_Strategy.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 "src/strategies/periphery/BaseHealthCheck.sol";

contract YDS_SDAI_Strategy is BaseHealthCheck {
using SafeERC20 for IERC20;

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

IERC20 public immutable DAI;
IERC4626 public immutable sDAI;

constructor(
address _asset,
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
) {
DAI = IERC20(_asset);
sDAI = IERC4626(0x83F20F44975D03b1b09e64809B757c47f942BEeA);

if (_asset != 0x6B175474E89094C44Da98b954EedeAC495271d0F) {
revert UnsupportedAsset(_asset);
}

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

function _deployFunds(uint256 amount) internal override {
DAI.forceApprove(address(sDAI), amount);
uint256 shares = sDAI.deposit(amount, address(this));
require(shares > 0, "YDS_SDAI_Strategy: zero shares minted");
DAI.forceApprove(address(sDAI), 0);
}

function _freeFunds(uint256 amount) internal override {
if (amount == 0) return;
sDAI.withdraw(amount, address(this), address(this));
}

function _harvestAndReport() internal view override returns (uint256 totalAssets) {
uint256 idle = DAI.balanceOf(address(this));
uint256 shares = IERC20(address(sDAI)).balanceOf(address(this));
uint256 deployed = sDAI.previewRedeem(shares);
totalAssets = idle + deployed;
}

function availableDepositLimit(address) public view override returns (uint256) {
uint256 upstreamMax = sDAI.maxDeposit(address(this));
if (upstreamMax == type(uint256).max) return type(uint256).max;
uint256 idle = DAI.balanceOf(address(this));

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

return upstreamMax - idle;
}

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

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

src/strategies/yieldDonating/YDS_SUSDS_Strategy.sol

Complete file — src/strategies/yieldDonating/YDS_SUSDS_Strategy.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 "src/strategies/periphery/BaseHealthCheck.sol";

contract YDS_SUSDS_Strategy is BaseHealthCheck {
using SafeERC20 for IERC20;

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

IERC20 public immutable USDS;
IERC4626 public immutable sUSDS;

constructor(
address _asset,
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
) {
USDS = IERC20(_asset);
sUSDS = IERC4626(0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD);

if (_asset != 0xdC035D45d973E3EC169d2276DDab16f1e407384F) {
revert UnsupportedAsset(_asset);
}

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

function _deployFunds(uint256 amount) internal override {
USDS.forceApprove(address(sUSDS), amount);
uint256 shares = sUSDS.deposit(amount, address(this));
require(shares > 0, "YDS_SUSDS_Strategy: zero shares minted");
USDS.forceApprove(address(sUSDS), 0);
}

function _freeFunds(uint256 amount) internal override {
if (amount == 0) return;
sUSDS.withdraw(amount, address(this), address(this));
}

function _harvestAndReport() internal view override returns (uint256 totalAssets) {
uint256 idle = USDS.balanceOf(address(this));
uint256 shares = IERC20(address(sUSDS)).balanceOf(address(this));
uint256 deployed = sUSDS.previewRedeem(shares);
totalAssets = idle + deployed;
}

function availableDepositLimit(address) public view override returns (uint256) {
uint256 upstreamMax = sUSDS.maxDeposit(address(this));
if (upstreamMax == type(uint256).max) return type(uint256).max;
uint256 idle = USDS.balanceOf(address(this));

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

return upstreamMax - idle;
}

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

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

test/unit/strategies/yieldDonating/YDS_SDAI_Strategy.t.sol

Complete file — test/unit/strategies/yieldDonating/YDS_SDAI_Strategy.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 "src/core/interfaces/ITokenizedStrategy.sol";
import {YDS_SDAI_Strategy} from "src/strategies/yieldDonating/YDS_SDAI_Strategy.sol";

contract YDS_SDAI_Strategy_Test is Test {
// --- Mainnet addresses ---
address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
address constant SDAI = 0x83F20F44975D03b1b09e64809B757c47f942BEeA;
address constant YIELD_DONATING_TOKENIZED_STRATEGY = 0xE8797A98710518A6973Cc8612f98154EECF2C711;

YDS_SDAI_Strategy public strategy;
ITokenizedStrategy public vault;

address management = makeAddr("management");
address keeper = makeAddr("keeper");
address emergencyAdmin = makeAddr("emergencyAdmin");
address donation = makeAddr("donation");
address user = makeAddr("user");

uint256 constant DEPOSIT_AMOUNT = 10_000e18;

function setUp() public {
strategy = new YDS_SDAI_Strategy(
DAI,
"YDS sDAI",
"ydsDAI",
management,
keeper,
emergencyAdmin,
donation,
true,
YIELD_DONATING_TOKENIZED_STRATEGY
);

vault = ITokenizedStrategy(address(strategy));

// Fund the user with DAI via deal cheatcode
deal(DAI, user, DEPOSIT_AMOUNT * 10);
}

// --- 1. Constructor validates the underlying asset ---
function test_constructorRevertsOnWrongAsset() public {
address fakeAsset = makeAddr("fakeAsset");
vm.expectRevert();
new YDS_SDAI_Strategy(
fakeAsset, "bad", "BAD",
management, keeper, emergencyAdmin,
donation, true, YIELD_DONATING_TOKENIZED_STRATEGY
);
}

// --- 2. Deposit succeeds on a mainnet fork ---
function test_depositDeploysFundsToSDAI() public {
vm.startPrank(user);
IERC20(DAI).approve(address(vault), DEPOSIT_AMOUNT);
vault.deposit(DEPOSIT_AMOUNT, user);
vm.stopPrank();

// Strategy should hold sDAI shares after deploying funds
uint256 sDAIBalance = IERC20(SDAI).balanceOf(address(strategy));
assertGt(sDAIBalance, 0, "strategy must hold sDAI shares after deposit");
assertApproxEqAbs(
IERC4626(SDAI).previewRedeem(sDAIBalance),
DEPOSIT_AMOUNT,
2,
"strategy should deploy roughly the deposited DAI amount"
);

// User should hold strategy shares
uint256 userShares = vault.balanceOf(user);
assertGt(userShares, 0, "user must receive strategy shares on deposit");
}

// --- 3. Report runs without breaking accounting ---
function test_reportAfterAccrual() public {
// Deposit
vm.startPrank(user);
IERC20(DAI).approve(address(vault), DEPOSIT_AMOUNT);
vault.deposit(DEPOSIT_AMOUNT, user);
vm.stopPrank();

uint256 totalBefore = vault.totalAssets();

// Warp forward to allow sDAI exchange rate to increase
vm.warp(block.timestamp + 30 days);

// Keeper calls report — should not revert
vm.prank(keeper);
vault.report();

uint256 totalAfter = vault.totalAssets();
assertGe(totalAfter, totalBefore, "totalAssets must not decrease after rate accrual");
}

// --- 4. Withdraw / redeem returns underlying ---
function test_withdrawReturnsDAI() public {
vm.startPrank(user);
IERC20(DAI).approve(address(vault), DEPOSIT_AMOUNT);
vault.deposit(DEPOSIT_AMOUNT, user);

uint256 shares = vault.balanceOf(user);
uint256 daiBefore = IERC20(DAI).balanceOf(user);
vault.redeem(shares, user, user);
uint256 daiAfter = IERC20(DAI).balanceOf(user);
vm.stopPrank();

assertGt(daiAfter - daiBefore, 0, "redeem must return DAI to user");
}

// --- 5. Health-check bounds are configurable ---
function test_healthCheckBoundsRevertOnOutlier() public {
// Tighten profit limit to 0.01% so any real accrual triggers a revert
vm.prank(management);
strategy.setProfitLimitRatio(1); // 1 bps = 0.01%

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

// Warp forward so sDAI rate accrues beyond the tight limit
vm.warp(block.timestamp + 365 days);

// Report should revert due to health-check
vm.prank(keeper);
vm.expectRevert();
vault.report();
}
}

test/unit/strategies/yieldDonating/YDS_SUSDS_Strategy.t.sol

Complete file — test/unit/strategies/yieldDonating/YDS_SUSDS_Strategy.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 "src/core/interfaces/ITokenizedStrategy.sol";
import {YDS_SUSDS_Strategy} from "src/strategies/yieldDonating/YDS_SUSDS_Strategy.sol";

contract YDS_SUSDS_Strategy_Test is Test {
// --- Mainnet addresses ---
address constant USDS = 0xdC035D45d973E3EC169d2276DDab16f1e407384F;
address constant SUSDS = 0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD;
address constant YIELD_DONATING_TOKENIZED_STRATEGY = 0xE8797A98710518A6973Cc8612f98154EECF2C711;

YDS_SUSDS_Strategy public strategy;
ITokenizedStrategy public vault;

address management = makeAddr("management");
address keeper = makeAddr("keeper");
address emergencyAdmin = makeAddr("emergencyAdmin");
address donation = makeAddr("donation");
address user = makeAddr("user");

uint256 constant DEPOSIT_AMOUNT = 10_000e18;

function setUp() public {
strategy = new YDS_SUSDS_Strategy(
USDS,
"YDS sUSDS",
"ydsUSDS",
management,
keeper,
emergencyAdmin,
donation,
true,
YIELD_DONATING_TOKENIZED_STRATEGY
);

vault = ITokenizedStrategy(address(strategy));

deal(USDS, user, DEPOSIT_AMOUNT * 10);
}

// --- 1. Constructor validates the underlying asset ---
function test_constructorRevertsOnWrongAsset() public {
address fakeAsset = makeAddr("fakeAsset");
vm.expectRevert();
new YDS_SUSDS_Strategy(
fakeAsset, "bad", "BAD",
management, keeper, emergencyAdmin,
donation, true, YIELD_DONATING_TOKENIZED_STRATEGY
);
}

// --- 2. Deposit succeeds on a mainnet fork ---
function test_depositDeploysFundsToSUSDS() public {
vm.startPrank(user);
IERC20(USDS).approve(address(vault), DEPOSIT_AMOUNT);
vault.deposit(DEPOSIT_AMOUNT, user);
vm.stopPrank();

uint256 sUSDSBalance = IERC20(SUSDS).balanceOf(address(strategy));
assertGt(sUSDSBalance, 0, "strategy must hold sUSDS shares after deposit");

uint256 userShares = vault.balanceOf(user);
assertGt(userShares, 0, "user must receive strategy shares on deposit");
}

// --- 3. Report runs without breaking accounting ---
function test_reportAfterAccrual() public {
vm.startPrank(user);
IERC20(USDS).approve(address(vault), DEPOSIT_AMOUNT);
vault.deposit(DEPOSIT_AMOUNT, user);
vm.stopPrank();

uint256 totalBefore = vault.totalAssets();

vm.warp(block.timestamp + 30 days);

vm.prank(keeper);
vault.report();

uint256 totalAfter = vault.totalAssets();
assertGe(totalAfter, totalBefore, "totalAssets must not decrease after rate accrual");
}

// --- 4. Withdraw / redeem returns underlying ---
function test_withdrawReturnsUSDS() public {
vm.startPrank(user);
IERC20(USDS).approve(address(vault), DEPOSIT_AMOUNT);
vault.deposit(DEPOSIT_AMOUNT, user);

uint256 shares = vault.balanceOf(user);
uint256 usdsBefore = IERC20(USDS).balanceOf(user);
vault.redeem(shares, user, user);
uint256 usdsAfter = IERC20(USDS).balanceOf(user);
vm.stopPrank();

assertGt(usdsAfter - usdsBefore, 0, "redeem must return USDS to user");
}

// --- 5. Health-check bounds are configurable ---
function test_healthCheckBoundsRevertOnOutlier() public {
vm.prank(management);
strategy.setProfitLimitRatio(1); // 1 bps = 0.01%

vm.startPrank(user);
IERC20(USDS).approve(address(vault), DEPOSIT_AMOUNT);
vault.deposit(DEPOSIT_AMOUNT, user);
vm.stopPrank();

vm.warp(block.timestamp + 365 days);

vm.prank(keeper);
vm.expectRevert();
vault.report();
}
}

script/deploy/DeployYDS.s.sol

Complete file — script/deploy/DeployYDS.s.sol

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

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

/// @title DeployYDS
/// @notice Deploys one or both YDS strategies depending on STRATEGY_KIND env var.
/// @dev Usage:
/// IMPORTANT: these commands will broadcast a live deployment if ETH_RPC_URL points at mainnet.
/// For fork-only testing, use `forge test --fork-url $ETH_RPC_URL` without `--broadcast`.
/// STRATEGY_KIND=SDAI forge script script/deploy/DeployYDS.s.sol --broadcast --fork-url $ETH_RPC_URL
/// STRATEGY_KIND=SUSDS forge script script/deploy/DeployYDS.s.sol --broadcast --fork-url $ETH_RPC_URL
/// STRATEGY_KIND=BOTH forge script script/deploy/DeployYDS.s.sol --broadcast --fork-url $ETH_RPC_URL
contract DeployYDS is Script {
// --- Mainnet addresses ---
address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
address constant USDS = 0xdC035D45d973E3EC169d2276DDab16f1e407384F;
address constant YIELD_DONATING_TOKENIZED_STRATEGY = 0xE8797A98710518A6973Cc8612f98154EECF2C711;

function run() external {
uint256 pk = vm.envUint("PRIVATE_KEY");
address owner = vm.envOr("OWNER", vm.addr(pk));
address donationAddress = vm.envAddress("DONATION_ADDRESS");
string memory kind = vm.envOr("STRATEGY_KIND", string("BOTH"));
bytes32 kindHash = keccak256(bytes(kind));

vm.startBroadcast(pk);

if (kindHash == keccak256("SDAI") || kindHash == keccak256("BOTH")) {
YDS_SDAI_Strategy sdai = new YDS_SDAI_Strategy(
DAI,
"YDS sDAI",
"ydsDAI",
owner, // management
owner, // keeper — reassign post-deploy if needed
owner, // emergencyAdmin
donationAddress,
true, // enableBurning
YIELD_DONATING_TOKENIZED_STRATEGY
);
console.log("YDS_SDAI_Strategy deployed at:", address(sdai));
}

if (kindHash == keccak256("SUSDS") || kindHash == keccak256("BOTH")) {
YDS_SUSDS_Strategy susds = new YDS_SUSDS_Strategy(
USDS,
"YDS sUSDS",
"ydsUSDS",
owner,
owner,
owner,
donationAddress,
true,
YIELD_DONATING_TOKENIZED_STRATEGY
);
console.log("YDS_SUSDS_Strategy deployed at:", address(susds));
}

vm.stopBroadcast();
}
}

.env keys

Fragment — required environment variables

ETH_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
PRIVATE_KEY=0xYOUR_PRIVATE_KEY
DONATION_ADDRESS=0xYOUR_DONATION_ADDRESS
# Optional: defaults to deployer if unset
# OWNER=0xMULTISIG_ADDRESS
# Optional: SDAI, SUSDS, or BOTH (default: BOTH)
# STRATEGY_KIND=BOTH

DONATION_ADDRESS is where the strategy mints donation shares on profit. For routing yield into the current Octant funding pipeline, use the canonical DragonRouter address from Deployed Addresses; for local testing you can deploy your own PaymentSplitter and point at it instead.

Verification commands

Command — build and test

forge build
forge test --fork-url $ETH_RPC_URL -vvv