Skip to main content

Writing a YSS Strategy (Foundry workflow)

Purpose: Explain how to implement a Yield Skimming Strategy around an external rate source, using a passive-hold wstETH example. Audience: Developers comfortable with Foundry, exchange-rate accounting, and Octant's shared strategy model. Level: Intermediate Source of truth: [email protected], especially BaseYieldSkimmingStrategy and the shared YSS accounting logic. Use this page when: you need to understand the minimal YSS hook surface and how an external exchange-rate feed maps into Octant's reporting model. Do not use this page for: a complete local deployment guide, a guarantee that every example is production-ready without review, or assumptions about later core revisions.

Before you read this page
Task card — wstETH YSS 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
GoalBuild and test a passive-hold YSS strategy that captures wstETH exchange-rate appreciation as donation shares.
Start repooctant-v2-core (no dedicated YSS starter template)
Core target pinoctant-v2-core@36ed6ad
Files to editsrc/strategies/yieldSkimming/YSS_WstETH_Strategy.sol, test/YSS_WstETH_Strategy.t.sol, script/DeployYSSWstETH.s.sol, .env
Files NOT to editEverything under dependencies/octant-v2-core, src/core/, src/strategies/yieldSkimming/BaseYieldSkimmingStrategy.sol
Required setupFoundry installed, ETH_RPC_URL env var pointing to an Ethereum mainnet RPC
VerificationETH_RPC_URL=<your-mainnet-rpc> forge test --match-contract YSS_WstETH_Test -vvv — all tests pass
Do notModify dependencies/octant-v2-core, invent contract addresses, skip health-check configuration, deploy without an independent audit. (See also: Agent Anti-Patterns)
Code status

Treat this page as a worked implementation reference. The example logic is designed to be concrete, but you should still verify imports, constructor surfaces, and rate-source assumptions against the pinned core release and the external token you integrate.

Worked example: Lido's wstETH

What you'll build. A direct-deposit YSS strategy that holds a yield-bearing token directly and captures exchange-rate appreciation by minting donation shares at report() time. In this worked example, the asset is wstETH, Lido's wrapped, non-rebasing staked ETH.

Users do not receive that appreciation as a rising PPS. Instead, while the strategy remains solvent, user shares track value under the YSS accounting model and appreciation is redirected on-chain to the donation address. If losses exceed the available donation-share buffer, the strategy transitions into insolvency behavior and the normal value-tracking invariant no longer holds.

Assumptions. Ethereum mainnet, Foundry, and direct deposits into the strategy's ERC-4626 surface. Appreciation is captured only when report() runs. At that point, the shared implementation mints donation shares on appreciation and, if burning is enabled, uses donation-share burning as first-loss protection on depreciation before any uncovered loss pushes the strategy into insolvency behavior.

Burn-disable operator path

In 1.2.0-develop.15, management should prefer reportAndDisableBurning() when disabling burn protection after it has been enabled. A direct setEnableBurning(false) can revert with report before disabling burning if the current rate implies a pending dragon-share burn that has not yet been reported.

Trust boundary for YSS examples

The rate model and hook surface described here should match the pinned shared implementation. Still, treat the pinned core contracts as authoritative for normalization, loss handling, and any edge-case accounting behavior.

YSS constructor signature

Signature as of 36ed6ad. Full reference: YieldSkimmingTokenizedStrategy.

ParameterTypeDescription
_assetaddressThe yield-bearing token the strategy holds (e.g., wstETH).
_namestring memoryERC-20 name for the strategy vault token.
_symbolstring memoryERC-20 symbol for the strategy vault token.
_managementaddressManagement role — can set health-check bounds and configuration.
_keeperaddressKeeper role — authorized to call report().
_emergencyAdminaddressEmergency admin role — can trigger emergency actions.
_donationAddressaddressRecipient of minted donation shares (e.g., DragonRouter).
_enableBurningboolWhether donation-share burning is used as first-loss protection on depreciation.
_tokenizedStrategyAddressaddressAddress of the shared YieldSkimmingTokenizedStrategy implementation.

How YSS differs from YDS

Unlike Yield Donating Strategies, which usually report a total-asset delta through _harvestAndReport(), passive-hold YSS strategies are rate-driven:

  • The strategy holds the yield-bearing asset directly.
  • Yield appears as exchange-rate appreciation in the asset itself.
  • report() compares the current normalized rate to the last reported rate and applies YSS accounting.
  • Users do not receive that appreciation as a rising PPS; it is redirected on-chain to the donation address.

That changes the implementation surface. In YDS, most of the protocol-specific work lives in deploy / free / harvest hooks. In YSS, the main protocol-specific input is the exchange-rate source.

The core rate model

At the tokenized-strategy layer, YSS logic works with the current exchange rate in RAY precision (1e27 = 1.0). The shared implementation uses that normalized rate to:

  • measure appreciation since the last report,
  • mint donation shares when value has increased,
  • apply dragon-loss protection on depreciation when enabled, and
  • switch to insolvency behavior if uncovered losses exceed the available protection buffer.

In [email protected], the shared YSS code normalizes the external exchange rate to RAY internally before applying YSS accounting. The strategy provides the raw rate via _getCurrentExchangeRate() and its decimal precision via decimalsOfExchangeRate(); the framework converts it to RAY automatically. BaseYieldSkimmingHealthCheck also exposes getCurrentRateRay() and uses the same normalization path for rate-based health checks.

Worked example: wstETH exchange rate

For wstETH, the relevant rate is the amount of stETH per 1 wstETH. Lido exposes stEthPerToken() exactly for that purpose, and it returns the ratio with 18-decimal precision. The framework converts that value to RAY once you declare the precision through decimalsOfExchangeRate().

Fragment — IWstETH interface
interface IWstETH {
function stEthPerToken() external view returns (uint256);
function tokensPerStEth() external view returns (uint256);
}
Fragment — exchange-rate hook overrides
/// @notice Returns the wstETH/stETH exchange rate in its native 18-decimal precision.
/// The framework reads decimalsOfExchangeRate() and converts to RAY automatically.
function _getCurrentExchangeRate() internal view override returns (uint256) {
return IWstETH(address(asset)).stEthPerToken();
}

/// @notice Tells the framework that stEthPerToken() uses 18-decimal precision.
function decimalsOfExchangeRate() public pure override returns (uint256) {
return 18;
}
caution

Always verify the decimal precision of the external rate source for the token you integrate. If decimalsOfExchangeRate() returns the wrong value, YSS reporting will normalize the rate incorrectly and donation-share accounting will be wrong.

Minimal strategy surface

For a passive-hold YSS, the strategy-specific logic is intentionally small. BaseYieldSkimmingStrategy already provides default no-op implementations of _deployFunds, _freeFunds, and _harvestAndReport() (it returns the current totalAssets()), so a concrete strategy only needs to supply the exchange-rate source.

HookRequiredWhat to do in a passive-hold YSS
_getCurrentExchangeRate()YesReturn the current exchange rate in its native decimal precision.
decimalsOfExchangeRate()YesReturn the decimal count of the rate (for example 18 for stEthPerToken()). The framework converts to RAY automatically.
_deployFunds(uint256)NoInherited as a no-op from BaseYieldSkimmingStrategy. Override only if the strategy actually needs to move assets.
_freeFunds(uint256)NoInherited as a no-op from BaseYieldSkimmingStrategy. Override only if the strategy must actively free assets.
_harvestAndReport()NoInherited from BaseYieldSkimmingStrategy; returns current totalAssets(). Override only if custom accounting is needed.
availableDepositLimit(address)OptionalOverride if intake needs extra gating.
availableWithdrawLimit(address)OptionalOverride if withdrawals need extra constraints.
How YSS differs from YDS in code
AspectYDS (e.g., sDAI strategy)YSS (e.g., wstETH strategy)
What the strategy holdsThe underlying asset (DAI) + external yield position (sDAI shares)The appreciating asset itself (wstETH)
_deployFundsDeposits assets into external protocolNo-op (assets stay in the contract)
_freeFundsWithdraws from external protocolNo-op (assets are already here)
_harvestAndReportReturns total asset value (idle + deployed)Returns totalAssets()
What you implementIntegration with external protocol_getCurrentExchangeRate() and decimalsOfExchangeRate()
Where yield comes fromExternal protocol generates itExchange-rate appreciation of the held asset

Minimal implementation sketch

Mainnet shared implementation

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

Import paths on this page

The code samples below use src/ import paths. This is the convention for working directly inside octant-v2-core (there is no dedicated YSS starter template). If you are working in a starter template or external project with an @octant-core remapping, replace src/ with @octant-core/.

Complete file — src/strategies/yieldSkimming/YSS_WstETH_Strategy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {BaseYieldSkimmingStrategy} from "src/strategies/yieldSkimming/BaseYieldSkimmingStrategy.sol";

interface IWstETH {
function stEthPerToken() external view returns (uint256);
}

contract YSS_WstETH_Strategy is BaseYieldSkimmingStrategy {
constructor(
address _asset,
string memory _name,
string memory _symbol,
address _management,
address _keeper,
address _emergencyAdmin,
address _donationAddress,
bool _enableBurning,
address _tokenizedStrategyAddress
) BaseYieldSkimmingStrategy(
_asset,
_name,
_symbol,
_management,
_keeper,
_emergencyAdmin,
_donationAddress,
_enableBurning,
_tokenizedStrategyAddress
) {}

/// @notice Returns the wstETH/stETH exchange rate in its native 18-decimal precision.
function _getCurrentExchangeRate() internal view override returns (uint256) {
return IWstETH(address(asset)).stEthPerToken();
}

/// @notice Tells the framework that stEthPerToken() uses 18-decimal precision.
/// The framework converts to RAY automatically before applying YSS accounting.
function decimalsOfExchangeRate() public pure override returns (uint256) {
return 18;
}
}

This sketch is deliberately small. BaseYieldSkimmingStrategy already provides no-op implementations of _deployFunds() and _freeFunds() and a default _harvestAndReport() that returns totalAssets(). For a passive-hold YSS, the only protocol-specific logic is the exchange-rate source.

Why BaseYieldSkimmingStrategy is the right default

BaseYieldSkimmingStrategy is the concrete base for passive-hold YSS strategies. In [email protected], it extends BaseYieldSkimmingHealthCheck, which in turn inherits from BaseStrategy and IBaseHealthCheck. BaseYieldSkimmingStrategy adds the default no-op implementations of _deployFunds() / _freeFunds() / _harvestAndReport() and declares the _getCurrentExchangeRate() / decimalsOfExchangeRate() interface that strategy authors override.

The underlying BaseYieldSkimmingHealthCheck layer is what applies rate-delta health checks. YSS strategies use this variant — not BaseHealthCheck directly — because the relevant signal is the relative change in exchange rate between reports, not a total-asset delta. BaseHealthCheck alone has no concept of exchange rates. BaseYieldSkimmingHealthCheck normalizes the external rate to RAY precision, compares it to the previously reported rate, and applies the configured profitLimitRatio / lossLimitRatio bounds to that relative rate move.

If the rate change between the last report and the current report exceeds profitLimitRatio or lossLimitRatio, the report reverts before the strategy records the new state. That is a useful default for yield-bearing assets whose exchange-rate source you want to sanity-check.

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 the relevant baseline passes, but any loss at all will revert report(). For most integrations you should call setProfitLimitRatio() and setLossLimitRatio() with realistic bounds during deployment — otherwise the first depreciation event, however small, will block reporting until management intervenes.

Fragment — setting health-check bounds
// Example: allow up to 5% appreciation and 1% depreciation per report period
strategy.setProfitLimitRatio(500);
strategy.setLossLimitRatio(100);

The health check defaults to on (doHealthCheck = true). If you disable it temporarily with setDoHealthCheck(false), the next successful report turns it back on automatically.

Foundry test patterns that matter for YSS

Some tests overlap with YDS, but several checks are specific to the YSS value model.

1. Deposit / withdraw in solvent mode

  • Deposit wstETH into the strategy.
  • Assert that the minted shares reflect the current value model while solvent.
  • Withdraw or redeem and confirm the user receives the expected amount of wstETH at the current rate.

2. Appreciation mints donation shares

  • Start from a clean solvent state.
  • Increase the mock or fork-observed exchange rate.
  • Call report() as keeper or management.
  • Assert:
    • getLastRateRay() updates,
    • donation-address share balance increases,
    • user value tracking remains unchanged within tolerance,
    • isVaultInsolvent() remains false.

The release implementation mints donation shares when current vault value exceeds combined user debt plus dragon debt.

3. Depreciation uses donation-share protection first

  • Simulate a lower exchange rate.
  • Call report().
  • If enableBurning is on, assert that donation-share protection is applied before uncovered loss affects users.
  • If the loss exceeds the available protection buffer, assert that the strategy transitions into insolvency behavior.

The release implementation explicitly routes this through dragon-loss-protection logic before uncovered loss can force insolvency.

4. Insolvency blocks normal intake

  • Force a large enough depreciation to push the vault below user-debt coverage.
  • Assert isVaultInsolvent() == true.
  • Assert that deposit and mint paths are blocked while insolvent.

The shared YSS implementation blocks normal intake in insolvent mode and falls back to proportional-distribution logic for conversions.

5. Dragon-router restrictions

  • Assert that the dragon router cannot be the receiver for direct deposit() or mint() flows.
  • Assert that self-transfer style edge cases revert where the strategy protects dragon accounting.
  • Assert that dragon-router actions that would violate user-debt coverage are blocked.

These restrictions are worth testing directly because YSS imposes stricter rules on the donation address than a plain ERC-4626 vault does.

6. Health-check bounds

  • Set realistic profitLimitRatio and lossLimitRatio.
  • Force an out-of-bounds rate change.
  • Assert that report() reverts and no donation-share accounting changes are recorded.
  • Re-run with an in-bounds delta and assert success.

Practical guidance

  • Keep reporting cadence regular. Tight health-check bounds only make sense if reports happen frequently.
  • Prefer a deterministic exchange-rate source. Avoid oracle-style logic when the token itself already exposes the rate you need.
  • For assets that are not expected to depreciate in normal operation, add a mock-based loss path so you can still validate donation protection and insolvency behavior.
  • Treat this page as an implementation guide. For exact shared behavior, always check the smart-contract reference pages and the release-tagged code.

Testing and deployment

The test patterns above describe what to check. This section provides a runnable fork-test scaffold and a deployment script template so you can execute the wstETH example end to end in Foundry.

Fork-test scaffold

Create a test file in your project, for example test/YSS_WstETH_Strategy.t.sol:

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

interface IWstETH {
function stEthPerToken() external view returns (uint256);
}

contract YSS_WstETH_Test is Test {
// --- Mainnet constants ---
address constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0;
address constant YIELD_SKIMMING_TOKENIZED_STRATEGY = 0xFe064acA6acFF4eFbE496271A665F0a9D66d6da1;

YSS_WstETH_Strategy public strategy;
ITokenizedStrategy public vault;
address management;
address keeper;
address emergencyAdmin;
address donationAddr;
address user;

function setUp() public {
// Fork mainnet — requires ETH_RPC_URL env var
vm.createSelectFork(vm.envString("ETH_RPC_URL"));

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

vm.startPrank(management);
strategy = new YSS_WstETH_Strategy(
WSTETH,
"YSS wstETH Strategy",
"ysWSTETH",
management,
keeper,
emergencyAdmin,
donationAddr,
true, // enableBurning — first-loss protection via donation shares
YIELD_SKIMMING_TOKENIZED_STRATEGY
);

// Cast to ITokenizedStrategy: the ERC-4626 surface (deposit, redeem,
// balanceOf, convertToAssets) and report() live on the shared
// YieldSkimmingTokenizedStrategy implementation and are only accessible
// through the strategy's delegatecall fallback. Calling them on the
// concrete YSS_WstETH_Strategy type will not compile.
vault = ITokenizedStrategy(address(strategy));

// Set realistic health-check bounds: 5% appreciation, 1% depreciation
strategy.setProfitLimitRatio(500);
strategy.setLossLimitRatio(100);
vm.stopPrank();
}

function test_depositAndWithdraw() public {
uint256 amount = 1 ether;
deal(WSTETH, user, amount);

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

// YSS mints value-shares, not 1:1 asset-shares.
// shares = assets × currentRate / RAY, where currentRate is the
// wstETH exchange rate normalised to RAY (≈1.18e27 at time of
// writing). Use convertToShares() to get the expected value.
uint256 expectedShares = vault.convertToShares(amount);
assertEq(shares, expectedShares, "first deposit must mint rate-based value shares");

vm.startPrank(user);
uint256 redeemed = vault.redeem(shares, user, user);
vm.stopPrank();

assertApproxEqRel(redeemed, amount, 1e15, "should redeem ~original amount");
}

function test_reportMintsDonationShares() public {
// Seed the strategy with a deposit
uint256 amount = 10 ether;
deal(WSTETH, user, amount);
vm.startPrank(user);
IERC20(WSTETH).approve(address(vault), amount);
vault.deposit(amount, user);
vm.stopPrank();

// Record the initial exchange rate from wstETH
uint256 initialRate = IWstETH(WSTETH).stEthPerToken();

// First report to establish baseline — no rate change yet
vm.prank(keeper);
vault.report();

uint256 donationBefore = vault.balanceOf(donationAddr);

// Simulate 1% wstETH appreciation by mocking stEthPerToken().
// vm.warp alone does NOT change the on-chain rate — the repo's own
// tests use vm.mockCall for this (see LidoStrategy.t.sol).
uint256 newRate = (initialRate * 101) / 100;
vm.mockCall(
WSTETH,
abi.encodeWithSignature("stEthPerToken()"),
abi.encode(newRate)
);

// Report as keeper — strategy detects the rate increase and mints donation shares
vm.prank(keeper);
vault.report();

// Clean up mock so it doesn't affect later tests
vm.clearMockedCalls();

uint256 donationAfter = vault.balanceOf(donationAddr);
assertGt(donationAfter, donationBefore, "donation shares must increase after appreciation");

// Verify the magnitude: the new donation shares should represent
// roughly the 1% appreciation. Convert the donation-share increment
// to its underlying asset value and compare against the expected
// profit (~1% of the deposited amount).
uint256 donationSharesMinted = donationAfter - donationBefore;
uint256 donationValue = vault.convertToAssets(donationSharesMinted);
uint256 expectedProfit = (amount * 1) / 100; // 1% of deposit
assertApproxEqRel(
donationValue,
expectedProfit,
0.05e18, // 5% tolerance — accounts for rounding and health-check adjustments
"donation shares must represent ~1% appreciation in asset terms"
);
}

function test_reportWithDepreciationBurnsDonationShares() public {
// --- Step 1: deposit and generate profit to create donation shares ---
uint256 amount = 10 ether;
deal(WSTETH, user, amount);
vm.startPrank(user);
IERC20(WSTETH).approve(address(vault), amount);
vault.deposit(amount, user);
vm.stopPrank();

uint256 initialRate = IWstETH(WSTETH).stEthPerToken();

// Simulate 4% appreciation to build a donation-share buffer.
// This must stay within the 5% profitLimitRatio (500 bps) set in
// setUp(), otherwise the rate health check reverts with "!profit".
vm.mockCall(
WSTETH,
abi.encodeWithSignature("stEthPerToken()"),
abi.encode((initialRate * 104) / 100)
);
vm.prank(keeper);
vault.report();
vm.clearMockedCalls();

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

// --- Step 2: simulate a small depreciation from the appreciated rate ---
// The exchange rate drops back — this could happen during a slashing
// event or a Lido oracle correction. Keep the loss within the 1%
// lossLimitRatio (100 bps) to avoid a "!loss" revert.
uint256 appreciatedRate = (initialRate * 104) / 100;
uint256 depreciatedRate = (appreciatedRate * 999) / 1000; // 0.1% drop
vm.mockCall(
WSTETH,
abi.encodeWithSignature("stEthPerToken()"),
abi.encode(depreciatedRate)
);

// --- 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 from rate depreciation");

// Donation shares should be burned to absorb the loss.
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"
);
}
}

Run with:

Command — run fork tests
ETH_RPC_URL=<your-mainnet-rpc> forge test --match-contract YSS_WstETH_Test -vvv
note

This test requires an Ethereum mainnet RPC for the fork. The profit-path test uses vm.mockCall to simulate wstETH exchange rate appreciation — this is the same pattern used in the core repo's LidoStrategy.t.sol. Simply calling vm.warp on a fork does not change the on-chain stEthPerToken() return value, so mocking the rate is the correct way to exercise the yield-skimming path.

Deployment script

Create script/DeployYSSWstETH.s.sol:

Complete file — script/DeployYSSWstETH.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {Script} from "forge-std/Script.sol";
import {YSS_WstETH_Strategy} from "../src/strategies/yieldSkimming/YSS_WstETH_Strategy.sol";

contract DeployYSSWstETH is Script {
address constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0;
address constant YIELD_SKIMMING_TOKENIZED_STRATEGY = 0xFe064acA6acFF4eFbE496271A665F0a9D66d6da1;

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

// The health-check setters below are onlyManagement, so the
// broadcaster must be the management address for this single-tx flow.
require(
vm.addr(deployerPrivateKey) == management,
"PRIVATE_KEY must derive MANAGEMENT - health-check setters are onlyManagement"
);

vm.startBroadcast(deployerPrivateKey);

YSS_WstETH_Strategy strategy = new YSS_WstETH_Strategy(
WSTETH,
"YSS wstETH Strategy",
"ysWSTETH",
management,
keeper,
emergencyAdmin,
donationAddress,
true,
YIELD_SKIMMING_TOKENIZED_STRATEGY
);

// Set health-check bounds — adjust for your operational cadence
strategy.setProfitLimitRatio(500); // 5%
strategy.setLossLimitRatio(100); // 1%

vm.stopBroadcast();
}
}

Run with:

Command — deploy strategy
source .env # PRIVATE_KEY, MANAGEMENT, KEEPER, EMERGENCY_ADMIN, DONATION_ADDRESS
forge script script/DeployYSSWstETH.s.sol --rpc-url <your-rpc> --broadcast
Deployer must be management

This script calls setProfitLimitRatio and setLossLimitRatio in the same transaction as deployment. Both setters are onlyManagement, so PRIVATE_KEY must derive the same address as MANAGEMENT. If your production setup uses a separate management signer (e.g. a multisig), move those health-check calls into a separate transaction signed by the management key after deployment.

Where to source addresses:

  • YIELD_SKIMMING_TOKENIZED_STRATEGY: the shared implementation at 0xFe064acA6acFF4eFbE496271A665F0a9D66d6da1 — see Deployed Addresses.
  • DONATION_ADDRESS: depends on your routing setup. Use the DragonRouter for Octant's canonical flow, a PaymentSplitter for custom splits, or any recipient address. See Payment Splitter Patterns for guidance.

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.


Sources


Complete files — copy-paste bundle

All files below represent the final compilable state of each artifact produced by this guide. Copy them into your project as-is.

.env keys

Fragment — .env
ETH_RPC_URL=<your-ethereum-mainnet-rpc-url>
PRIVATE_KEY=<deployer-private-key>
MANAGEMENT=<management-address>
KEEPER=<keeper-address>
EMERGENCY_ADMIN=<emergency-admin-address>
DONATION_ADDRESS=<donation-address>

Strategy contract

Complete file — src/strategies/yieldSkimming/YSS_WstETH_Strategy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {BaseYieldSkimmingStrategy} from "src/strategies/yieldSkimming/BaseYieldSkimmingStrategy.sol";

interface IWstETH {
function stEthPerToken() external view returns (uint256);
}

contract YSS_WstETH_Strategy is BaseYieldSkimmingStrategy {
constructor(
address _asset,
string memory _name,
string memory _symbol,
address _management,
address _keeper,
address _emergencyAdmin,
address _donationAddress,
bool _enableBurning,
address _tokenizedStrategyAddress
) BaseYieldSkimmingStrategy(
_asset,
_name,
_symbol,
_management,
_keeper,
_emergencyAdmin,
_donationAddress,
_enableBurning,
_tokenizedStrategyAddress
) {}

/// @notice Returns the wstETH/stETH exchange rate in its native 18-decimal precision.
function _getCurrentExchangeRate() internal view override returns (uint256) {
return IWstETH(address(asset)).stEthPerToken();
}

/// @notice Tells the framework that stEthPerToken() uses 18-decimal precision.
/// The framework converts to RAY automatically before applying YSS accounting.
function decimalsOfExchangeRate() public pure override returns (uint256) {
return 18;
}
}

Fork test

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

interface IWstETH {
function stEthPerToken() external view returns (uint256);
}

contract YSS_WstETH_Test is Test {
// --- Mainnet constants ---
address constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0;
address constant YIELD_SKIMMING_TOKENIZED_STRATEGY = 0xFe064acA6acFF4eFbE496271A665F0a9D66d6da1;

YSS_WstETH_Strategy public strategy;
ITokenizedStrategy public vault;
address management;
address keeper;
address emergencyAdmin;
address donationAddr;
address user;

function setUp() public {
// Fork mainnet — requires ETH_RPC_URL env var
vm.createSelectFork(vm.envString("ETH_RPC_URL"));

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

vm.startPrank(management);
strategy = new YSS_WstETH_Strategy(
WSTETH,
"YSS wstETH Strategy",
"ysWSTETH",
management,
keeper,
emergencyAdmin,
donationAddr,
true, // enableBurning — first-loss protection via donation shares
YIELD_SKIMMING_TOKENIZED_STRATEGY
);

// Cast to ITokenizedStrategy: the ERC-4626 surface (deposit, redeem,
// balanceOf, convertToAssets) and report() live on the shared
// YieldSkimmingTokenizedStrategy implementation and are only accessible
// through the strategy's delegatecall fallback. Calling them on the
// concrete YSS_WstETH_Strategy type will not compile.
vault = ITokenizedStrategy(address(strategy));

// Set realistic health-check bounds: 5% appreciation, 1% depreciation
strategy.setProfitLimitRatio(500);
strategy.setLossLimitRatio(100);
vm.stopPrank();
}

function test_depositAndWithdraw() public {
uint256 amount = 1 ether;
deal(WSTETH, user, amount);

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

// YSS mints value-shares, not 1:1 asset-shares.
// shares = assets × currentRate / RAY
uint256 expectedShares = vault.convertToShares(amount);
assertEq(shares, expectedShares, "first deposit must mint rate-based value shares");

vm.startPrank(user);
uint256 redeemed = vault.redeem(shares, user, user);
vm.stopPrank();

assertApproxEqRel(redeemed, amount, 1e15, "should redeem ~original amount");
}

function test_reportMintsDonationShares() public {
uint256 amount = 10 ether;
deal(WSTETH, user, amount);
vm.startPrank(user);
IERC20(WSTETH).approve(address(vault), amount);
vault.deposit(amount, user);
vm.stopPrank();

uint256 initialRate = IWstETH(WSTETH).stEthPerToken();

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

uint256 donationBefore = vault.balanceOf(donationAddr);

uint256 newRate = (initialRate * 101) / 100;
vm.mockCall(
WSTETH,
abi.encodeWithSignature("stEthPerToken()"),
abi.encode(newRate)
);

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

vm.clearMockedCalls();

uint256 donationAfter = vault.balanceOf(donationAddr);
assertGt(donationAfter, donationBefore, "donation shares must increase after appreciation");

uint256 donationSharesMinted = donationAfter - donationBefore;
uint256 donationValue = vault.convertToAssets(donationSharesMinted);
uint256 expectedProfit = (amount * 1) / 100;
assertApproxEqRel(
donationValue,
expectedProfit,
0.05e18,
"donation shares must represent ~1% appreciation in asset terms"
);
}

function test_reportWithDepreciationBurnsDonationShares() public {
uint256 amount = 10 ether;
deal(WSTETH, user, amount);
vm.startPrank(user);
IERC20(WSTETH).approve(address(vault), amount);
vault.deposit(amount, user);
vm.stopPrank();

uint256 initialRate = IWstETH(WSTETH).stEthPerToken();

// 4% appreciation — must stay within the 5% profitLimitRatio (500 bps)
vm.mockCall(
WSTETH,
abi.encodeWithSignature("stEthPerToken()"),
abi.encode((initialRate * 104) / 100)
);
vm.prank(keeper);
vault.report();
vm.clearMockedCalls();

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

// 0.1% depreciation — must stay within the 1% lossLimitRatio (100 bps)
uint256 appreciatedRate = (initialRate * 104) / 100;
uint256 depreciatedRate = (appreciatedRate * 999) / 1000;
vm.mockCall(
WSTETH,
abi.encodeWithSignature("stEthPerToken()"),
abi.encode(depreciatedRate)
);

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 from rate depreciation");

uint256 donationSharesAfter = vault.balanceOf(donationAddr);
assertLt(
donationSharesAfter,
donationSharesBefore,
"donation shares must be burned to protect users from loss"
);

assertEq(
vault.balanceOf(user),
userSharesBefore,
"user shares must not change during loss"
);
}
}

Deploy script

Complete file — script/DeployYSSWstETH.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {Script} from "forge-std/Script.sol";
import {YSS_WstETH_Strategy} from "../src/strategies/yieldSkimming/YSS_WstETH_Strategy.sol";

contract DeployYSSWstETH is Script {
address constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0;
address constant YIELD_SKIMMING_TOKENIZED_STRATEGY = 0xFe064acA6acFF4eFbE496271A665F0a9D66d6da1;

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);

YSS_WstETH_Strategy strategy = new YSS_WstETH_Strategy(
WSTETH,
"YSS wstETH Strategy",
"ysWSTETH",
management,
keeper,
emergencyAdmin,
donationAddress,
true,
YIELD_SKIMMING_TOKENIZED_STRATEGY
);

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

vm.stopBroadcast();
}
}

Verification commands

Command — verify everything compiles and tests pass
# Compile all contracts
forge build

# Run the YSS wstETH fork tests
ETH_RPC_URL=<your-mainnet-rpc> forge test --match-contract YSS_WstETH_Test -vvv

# Dry-run the deploy script (no broadcast)
source .env
forge script script/DeployYSSWstETH.s.sol --rpc-url $ETH_RPC_URL