Writing a YDS Strategy (Foundry workflow)
- Required: Introduction to YDS and Hello World Strategy
- Helpful: YDS Architecture
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 andERC4626Strategy.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.
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.
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.
[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.
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.
| Field | Value |
|---|---|
| Goal | Implement two ERC-4626 wrapper-based Yield Donating Strategies (sDAI and sUSDS) with Foundry, including mainnet-fork tests. |
| Start repo | octant-v2-core@06ae2b9fa2e4443a3d9f6148498d4ada4f9861e8 (tag 1.3.0) |
| Files to edit | src/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 edit | src/strategies/periphery/BaseHealthCheck.sol, src/core/BaseStrategy.sol, src/core/interfaces/ITokenizedStrategy.sol |
| Required setup | Foundry 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 commands | forge build, forge test --fork-url $ETH_RPC_URL -vvv |
| Do not | Modify 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)— afterdeposit/mint, move underlying into the wrapper._freeFunds(uint256 amount)— duringwithdraw/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
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:
| Parameter | Type | Description |
|---|---|---|
_asset | address | The underlying ERC-20 token (e.g. DAI, USDS) |
_name | string memory | Human-readable strategy name |
_symbol | string memory | Strategy share token symbol |
_management | address | Management role — can configure strategy parameters |
_keeper | address | Keeper role — can call report() |
_emergencyAdmin | address | Emergency admin — can trigger emergency shutdown |
_donationAddress | address | Receives minted donation shares on profit |
_enableBurning | bool | If true, donation shares are burned first on loss |
_tokenizedStrategyAddress | address | Address 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.
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:
- move underlying into the wrapper,
- free underlying on withdrawals,
- 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.
profitLimitRatiobounds how much profit a single report may realize.lossLimitRatiobounds 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.
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 →
_deployFundsincreases wrapper share balance. - Withdraw →
_freeFundscallswithdraw(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
totalSupplyincreases by roughlyprofit / PPS_prev, - donation-address share balance increases accordingly,
- user PPS remains unchanged within tolerance.
- strategy
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
enableBurningis 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.solas the nearest production pattern.
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