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], especiallyBaseYieldSkimmingStrategyand 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.
- Required: Introduction to YSS and Local Development Quickstart
- Helpful: Writing a YDS Strategy (for comparison)
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.
| Field | Value |
|---|---|
| Goal | Build and test a passive-hold YSS strategy that captures wstETH exchange-rate appreciation as donation shares. |
| Start repo | octant-v2-core (no dedicated YSS starter template) |
| Core target pin | octant-v2-core@36ed6ad |
| Files to edit | src/strategies/yieldSkimming/YSS_WstETH_Strategy.sol, test/YSS_WstETH_Strategy.t.sol, script/DeployYSSWstETH.s.sol, .env |
| Files NOT to edit | Everything under dependencies/octant-v2-core, src/core/, src/strategies/yieldSkimming/BaseYieldSkimmingStrategy.sol |
| Required setup | Foundry installed, ETH_RPC_URL env var pointing to an Ethereum mainnet RPC |
| Verification | ETH_RPC_URL=<your-mainnet-rpc> forge test --match-contract YSS_WstETH_Test -vvv — all tests pass |
| Do not | Modify dependencies/octant-v2-core, invent contract addresses, skip health-check configuration, deploy without an independent audit. (See also: Agent Anti-Patterns) |
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.
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.
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.
| Parameter | Type | Description |
|---|---|---|
_asset | address | The yield-bearing token the strategy holds (e.g., wstETH). |
_name | string memory | ERC-20 name for the strategy vault token. |
_symbol | string memory | ERC-20 symbol for the strategy vault token. |
_management | address | Management role — can set health-check bounds and configuration. |
_keeper | address | Keeper role — authorized to call report(). |
_emergencyAdmin | address | Emergency admin role — can trigger emergency actions. |
_donationAddress | address | Recipient of minted donation shares (e.g., DragonRouter). |
_enableBurning | bool | Whether donation-share burning is used as first-loss protection on depreciation. |
_tokenizedStrategyAddress | address | Address 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().
interface IWstETH {
function stEthPerToken() external view returns (uint256);
function tokensPerStEth() external view returns (uint256);
}
/// @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;
}
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.
| Hook | Required | What to do in a passive-hold YSS |
|---|---|---|
_getCurrentExchangeRate() | Yes | Return the current exchange rate in its native decimal precision. |
decimalsOfExchangeRate() | Yes | Return the decimal count of the rate (for example 18 for stEthPerToken()). The framework converts to RAY automatically. |
_deployFunds(uint256) | No | Inherited as a no-op from BaseYieldSkimmingStrategy. Override only if the strategy actually needs to move assets. |
_freeFunds(uint256) | No | Inherited as a no-op from BaseYieldSkimmingStrategy. Override only if the strategy must actively free assets. |
_harvestAndReport() | No | Inherited from BaseYieldSkimmingStrategy; returns current totalAssets(). Override only if custom accounting is needed. |
availableDepositLimit(address) | Optional | Override if intake needs extra gating. |
availableWithdrawLimit(address) | Optional | Override if withdrawals need extra constraints. |
| Aspect | YDS (e.g., sDAI strategy) | YSS (e.g., wstETH strategy) |
|---|---|---|
| What the strategy holds | The underlying asset (DAI) + external yield position (sDAI shares) | The appreciating asset itself (wstETH) |
| _deployFunds | Deposits assets into external protocol | No-op (assets stay in the contract) |
| _freeFunds | Withdraws from external protocol | No-op (assets are already here) |
| _harvestAndReport | Returns total asset value (idle + deployed) | Returns totalAssets() |
| What you implement | Integration with external protocol | _getCurrentExchangeRate() and decimalsOfExchangeRate() |
| Where yield comes from | External protocol generates it | Exchange-rate appreciation of the held asset |
Minimal implementation sketch
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.
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/.
// 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.
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.
// 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()remainsfalse.
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
enableBurningis 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()ormint()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
profitLimitRatioandlossLimitRatio. - 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:
// 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:
ETH_RPC_URL=<your-mainnet-rpc> forge test --match-contract YSS_WstETH_Test -vvv
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:
// 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:
source .env # PRIVATE_KEY, MANAGEMENT, KEEPER, EMERGENCY_ADMIN, DONATION_ADDRESS
forge script script/DeployYSSWstETH.s.sol --rpc-url <your-rpc> --broadcast
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 at0xFe064acA6acFF4eFbE496271A665F0a9D66d6da1— see Deployed Addresses.DONATION_ADDRESS: depends on your routing setup. Use theDragonRouterfor Octant's canonical flow, aPaymentSplitterfor 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
- wstETH (Lido) documentation: https://docs.lido.fi/contracts/wsteth
- Lido token integration guide: https://docs.lido.fi/guides/lido-tokens-integration-guide/
- YieldSkimmingTokenizedStrategy smart contract reference: YieldSkimmingTokenizedStrategy
- BaseYieldSkimmingStrategy smart contract reference: BaseYieldSkimmingStrategy
- BaseYieldSkimmingHealthCheck smart contract reference: BaseYieldSkimmingHealthCheck
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
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
// 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
// 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
// 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
# 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