Writing a YDS Strategy (Foundry workflow)
Real integrations: Spark’s sDAI (DSR) and Sky’s sUSDS (SSR)
What you’ll build. Two direct‑deposit YDS strategies that route deposits into interest‑bearing ERC‑4626 wrappers and donate all realized profit by minting/burning strategy shares at report() time:
- YDS_SDAI_Strategy— deposits DAI into sDAI (Spark’s ERC‑4626 wrapper around DSR).
- YDS_SUSDS_Strategy— deposits USDS into sUSDS (Sky’s ERC‑4626 wrapper around SSR) on Ethereum mainnet.
Assumptions. Ethereum mainnet; Foundry; users deposit directly into your strategy’s ERC‑4626. Profit is realized on the strategy’s report() (keeper/management), at which point the base contract mints donation shares on profit or burns donation shares on loss, holding user PPS flat until the donation buffer is exhausted.
Verify the target interfaces for the Yield sources (don’t skip this)
Both sDAI and sUSDS are ERC‑4626 vaults. You will call only the standard interface:
- deposit(uint256 assets, address receiver)
- withdraw(uint256 assets, address receiver, address owner)
- redeem(uint256 shares, address receiver, address owner)
- convertToAssets(uint256 shares)
- asset(),- balanceOf(address), etc.
Spark
Docs:https://docs.spark.fi/dev/savings/sdai-token Etherscan:https://etherscan.io/address/0x83f20f44975d03b1b09e64809b757c47f942beea#code Deth:https://etherscan.deth.net/address/0x83f20f44975d03b1b09e64809b757c47f942beea#code
Sky
Docs:https://developers.sky.money/protocol/tokens/susds/ & http://docs.spark.fi/dev/savings/susds-token Etherscan:https://etherscan.io/address/0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD#code Deth:https://etherscan.deth.net/address/0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD#code
Minimal strategy surface (the three overrides)
Your YDS strategy inherits the base and overrides exactly three internal hooks:
- _deployFunds(uint256 amount)— after- deposit/mint, move up to- amountunderlying into the wrapper (sDAI or sUSDS). Keep it deterministic; no swaps or oracle‑gated logic here (permissionless path).
- _freeFunds(uint256 amount)— during- withdraw/redeemwhen idle is insufficient, pull- amountunderlying back from the wrapper. If you cannot free the full amount safely, either free what you can (shortfall is realized loss to the caller) or revert for illiquid sources.
- _harvestAndReport() returns (uint256 totalAssets)— trusted (keeper/management) step: There are no reward tokens for sDAI/sUSDS; yield is the wrapper’s exchange‑rate drift. The base compares your returned value to the previous report and mints/burns donation shares accordingly. In our case we must assess the of the position to calculate total assets eg. idle + wrapper.convertToAssets(wrapperShareBalance).
Napkin Implementation — sDAI (DAI → sDAI)
Addresses. DAI (underlying), sDAI at 0x83F20F44975D03b1b09E64809B757c47f942BEeA. The contract exposes full ERC‑4626 semantics backed by DSR accounting (chi/rho/drip), so you never “claim rewards”; value accrues in the exchange rate surfaced by convertToAssets .
interface IERC4626 {
    function asset() external view returns (address);
    function balanceOf(address) external view returns (uint256);
    function deposit(uint256 assets, address receiver) external returns (uint256 shares);
    function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);
    function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);
    function convertToAssets(uint256 shares) external view returns (uint256);
}
contract YDS_SDAI_Strategy is BaseHealthCheck {
    IERC20   public immutable DAI;
    IERC4626 public immutable sDAI;
    /// Set up / initialize contract
    ... 
    function _deployFunds(uint256 amount) internal override {
        sDAI.deposit(amount, address(this));
    }
    function _freeFunds(uint256 amount) internal override {
        if (amount == 0) return;
        sDAI.withdraw(amount, address(this), address(this));
    }
    function _harvestAndReport() internal override returns (uint256 _totalAssets) {
        uint256 idle   = DAI.balanceOf(address(this));
        uint256 shares = IERC20(address(sDAI)).balanceOf(address(this));
        _totalAssets = idle + sDAI.convertToAssets(shares);
    }
    
    /// Implement extra hooks
    ...
}
Napkin Implementation — sUSDS (USDS → sUSDS)
Addresses. USDS (underlying), sUSDS at 0xa3931d71877C0e7A3148CB7Eb4463524FEc27fbD on Ethereum mainnet. The Sky docs specify ERC‑4626 deposit/withdraw semantics and “no fees.” The yield source is SSR, reflected in convertToAssets.
contract YDS_SUSDS_Strategy is BaseHealthCheck {
    IERC20   public immutable USDS;
    IERC4626 public immutable sUSDS;
    ...
    function _deployFunds(uint256 amount) internal override {
        sUSDS.deposit(amount, address(this));
    }
    function _freeFunds(uint256 amount) internal override {
        sUSDS.withdraw(amount, address(this), address(this));
    }
    function _harvestAndReport() internal override returns (uint256 _totalAssets) {
        uint256 idle   = USDS.balanceOf(address(this));
        uint256 shares = IERC20(address(sUSDS)).balanceOf(address(this));
        _totalAssets = idle + sUSDS.convertToAssets(shares);
    }
    
    ...
}
Security — mirror upstream limits & add safety rails
Map upstream capacity into your strategy’s limits. You may choose to surface the wrapper’s maxDeposit/maxWithdraw as your strategy’s availableDepositLimit / availableWithdrawLimit so users get immediate, accurate gating (added here for completeness):
function availableDepositLimit(address) public view override returns (uint256) {
    return IERC4626(address(sDAI /* or sUSDS */)).maxDeposit(address(this));
}
function availableWithdrawLimit(address) public view override returns (uint256) {
    // For wrappers this is usually unconstrained by fees, but honor upstream limits.
    return IERC4626(address(sDAI /* or sUSDS */)).maxWithdraw(address(this));
}
Use BaseHealthCheck (recommended). Set profitLimitRatio/lossLimitRatio in bps and make sure doHealthCheck is on. If _harvestAndReport() returns an out‑of‑bounds delta, report() reverts before donation shares are minted/burned. This prevents faulty accounting from oracle glitches or unexpected behavior. (The pattern comes directly from Yearn v3’s periphery health check.)
6) Foundry: prove it on a mainnet‑fork
Setup. Pin a recent mainnet block; fund test EOAs with DAI/USDS; set keeper/management roles; 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 convertToAssetsincreases (DSR/SSR accrual).
- Keeper calls report().
- Assert: strategy totalSupply increases by ≈ profit / PPS_prev; donation address share balance increases accordingly; user PPS unchanged within tolerance. (sDAI/sUSDS value accrues in the ERC‑4626 exchange rate, not via reward tokens.)
C. Loss handling (separate mock)
- sDAI/sUSDS are designed for non‑lossy accrual; use a lossy mock source to validate: donation shares burn first, PPS declines only if losses exceed the donation balance.
D. Health check
- Configure realistic profitLimitRatio/lossLimitRatio; force an outlier; assertreport()reverts and no donation shares change; re‑run within bounds.
Sources
- sDAI (Spark) developer docs: ERC‑4626 interface and semantics; Savings DAI token. (Spark Documentation)
- sUSDS developer docs (Sky): ERC‑4626 wrapper; deposit/withdraw USDS; no fees. (Sky Protocol Docs)
- sDAI Etherscan (mainnet): 0x83F20F44975D03b1b09E64809B757c47f942BEeA
- sUSDS Etherscan (mainnet proxy): 0xa3931d71877C0e7A3148CB7Eb4463524FEc27fbD.