Skip to main content

How to Create a Yield Donating Strategy

Purpose: Build a custom Yield Donating Strategy quickly using the starter template. Audience: Developers who want a faster template-based path than starting directly inside octant-v2-core. Level: Intermediate Source of truth: [email protected]; for the template-based path on this page, use the tested snapshot octant-v2-strategy-foundry-mix@ddd405c18bb0c765c0256ca952d4d9f4034cf3ec and reconcile it against that pinned core release. Use this page when: you are actively using octant-v2-strategy-foundry-mix to scaffold a custom YDS. Do not use this page for: assuming the template is version-aligned by default or that the example is production-ready without extension, review, and added tests.

Before you read this page
Task card

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 an Aave v3 Yield Donating Strategy using the Foundry starter template.
Start repooctant-v2-strategy-foundry-mix@ddd405c
Core target pinoctant-v2-core@36ed6ad tag 1.2.0-develop.15
Files to edit/createsrc/strategies/yieldDonating/YieldDonatingStrategy.sol, src/strategies/yieldDonating/YieldDonatingStrategyFactory.sol, src/test/yieldDonating/YieldDonatingSetup.sol, src/test/yieldDonating/YieldDonatingFunctionSignature.t.sol, script/Deploy.s.sol, .env, and, for Forge >= 1.5, foundry.toml
Files NOT to editdependencies/octant-v2-core/*
Required setupFoundry, Node.js, yarn, git submodule update --init --recursive, forge soldeer install, submodule checkout at 36ed6ad, working Ethereum mainnet RPC URL
Verificationafter the complete Aave file bundle is applied, run forge build and make test on a mainnet fork
Do notModify anything under dependencies/octant-v2-core/, invent contract addresses not sourced from on-chain registries, skip the compatibility-fix step, assume the default branch matches this walkthrough

(See also: Agent Anti-Patterns)

Source-of-truth rule

When the starter template, a code snippet on this page, and the pinned core repo appear to disagree, prefer the pinned [email protected] source first. For the template-based workflow, this page assumes octant-v2-strategy-foundry-mix@ddd405c18bb0c765c0256ca952d4d9f4034cf3ec rather than the moving default branch.

This guide walks you through creating a Yield Donating Strategy using the strategy starter template. For publication of this docs set, the worked example targets octant-v2-strategy-foundry-mix@ddd405c18bb0c765c0256ca952d4d9f4034cf3ec. The worked example uses an Aave v3 integration.

How to use this page

The Hello World tutorial used sDAI — a simple ERC-4626 wrapper where deposit and withdraw are one-line calls. This guide uses Aave v3, a full lending protocol with its own pool mechanics. If you haven't done the Hello World tutorial yet, start there first.

This guide uses the starter template, which is the fastest way to get a custom strategy compiling and tested. If you want the architecture-first route, or you want to compare your implementation against the patterns already present in [email protected], also read Writing a YDS Strategy.

Page type: Worked example built on the starter template. Best for: Developers who want a faster path than building directly inside octant-v2-core. Use in production only after: reconciling the template against the pinned core release, replacing tutorial assumptions, extending the test suite for your integration, and getting an independent security review.

What is a Yield Donating Strategy?

A Yield Donating Strategy:

  • deploys user assets into an external yield source,
  • reports profits to the shared Octant implementation,
  • donates 100% of realized profit to a configured donation address by minting shares to that address,
  • can optionally burn donation shares first on loss,
  • does not charge performance fees to users.

For the conceptual model behind this pattern, see the YDS Introduction.

What is Aave v3?

Aave is a decentralized lending protocol where users deposit tokens into a lending pool and earn interest from borrowers who borrow against those deposits. The lending pool is maintained by governance and protocol parameters.

When you deposit tokens into Aave v3, you receive aTokens (for example, aDAI when you deposit DAI) that represent your claim on the underlying deposit plus all accrued interest. The aToken balance grows automatically as interest accrues, and you can redeem aTokens for underlying at any time (subject to pool liquidity).

Unlike sDAI, which implements ERC-4626 and wraps the entire flow into standard deposit and withdraw semantics, Aave has its own Pool interface with supply() and withdraw() methods. That is why the strategy code in this guide looks different from the Hello World example — you must integrate Aave's own protocol interface rather than relying on a single ERC-4626 wrapper.

Version compatibility

This guide is written against [email protected].

Before you start, reconcile the tested starter-template snapshot against that pinned core release. In particular, confirm:

  • the constructor surface of BaseStrategy or BaseHealthCheck,
  • the expected hook signatures,
  • the current test setup in the template,
  • the current remappings and import paths.
  • the 1_000 minimum-liquidity lock on the first empty YDS deposit or mint.

YDS constructor signature

ParameterTypeDescription
_addressesProvideraddressAave v3 PoolAddressesProvider — pool and current data-provider are derived from this
_rewardsControlleraddressAave v3 RewardsController for supply-side incentive claims; may be address(0) if claims are intentionally disabled
_assetaddressUnderlying ERC-20 token (e.g. USDC)
_namestring memoryHuman-readable strategy name
_symbolstring memoryStrategy token symbol
_managementaddressManagement role address
_keeperaddressKeeper role address
_emergencyAdminaddressEmergency-admin role address
_donationAddressaddressDonation recipient — profit shares are minted here
_enableBurningboolIf true, donation shares are burned first on loss
_tokenizedStrategyAddressaddressAddress of the shared TokenizedStrategy implementation

Strategy-specific signature as of 36ed6ad. For the shared base constructor, see YieldDonatingTokenizedStrategy; for the Aave-specific pattern, see AaveV3Strategy.

Prerequisites

  1. Install Foundry
  2. Install Node.js
  3. Clone the tested starter-template snapshot:

Command — clone and initialize the starter template

git clone https://github.com/golemfoundation/octant-v2-strategy-foundry-mix.git
cd octant-v2-strategy-foundry-mix
git checkout ddd405c18bb0c765c0256ca952d4d9f4034cf3ec
yarn
git submodule update --init --recursive # populate the octant-v2-core submodule
git -C dependencies/octant-v2-core checkout 36ed6ad6665661a18f83394d561fa75c68ccf4ac
forge soldeer install

Do not assume the default branch is safe for this walkthrough. This page is written against that exact starter commit plus [email protected].

Step 1: Set up the environment

  1. Copy .env.example to .env
  2. Configure the variables used by the Aave starter path

Fragment — .env variables required by the Aave starter tests

# Example asset for local fork testing
TEST_ASSET_ADDRESS=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48

# Mainnet fork RPC; use an archive-capable endpoint for fork tests
ETH_RPC_URL=https://mainnet.infura.io/v3/YOUR_INFURA_API_KEY

# Aave v3 PoolAddressesProvider on Ethereum mainnet
AAVE_ADDRESSES_PROVIDER=0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e

# Aave v3 RewardsController on Ethereum mainnet
AAVE_REWARDS_CONTROLLER=0x8164Cc65827dcFe994AB23944CBC90e0aa80bFcb

After you apply the YieldDonatingSetup.sol file from this guide, the Aave starter tests read AAVE_ADDRESSES_PROVIDER and AAVE_REWARDS_CONTROLLER. They do not read the starter template's older TEST_YIELD_SOURCE variable.

Fix required before writing any code

At the tested starter-template snapshot, the scaffolded contracts still target an older constructor and initialize(...) shape. With [email protected], the base strategy constructor and ITokenizedStrategy.initialize() both require _symbol.

The complete files in this Aave guide apply those compatibility fixes and also replace the starter template's generic TEST_YIELD_SOURCE setup with the Aave AAVE_ADDRESSES_PROVIDER / AAVE_REWARDS_CONTROLLER setup.

If you are making the changes incrementally instead of copying the complete files, apply all four compatibility changes before the final verification:

  1. src/strategies/yieldDonating/YieldDonatingStrategy.sol — add string memory _symbol after _name in the constructor, and pass _symbol through the base-constructor call.
  2. src/test/yieldDonating/YieldDonatingSetup.sol — add a _symbol argument (e.g. "YDS") to the new Strategy(...) call.
  3. src/strategies/yieldDonating/YieldDonatingStrategyFactory.sol — add string calldata _symbol to newStrategy(), and pass _symbol into the new YieldDonatingStrategy(...) call inside that function.
  4. src/test/yieldDonating/YieldDonatingFunctionSignature.t.sol — update the hardcoded initialize(...) ABI string from "initialize(address,string,address,address,address,address,bool)" to "initialize(address,string,string,address,address,address,address,bool)", and add a symbol argument (e.g. "SYM") to the encoded call data.

For the detailed before/after diffs of each file, see the Hello World Strategy — Compatibility note.

Do not run make test against a half-updated scaffold. At this snapshot, the starter tests are runnable for this Aave walkthrough only after YieldDonatingSetup.sol, YieldDonatingStrategyFactory.sol, YieldDonatingFunctionSignature.t.sol, and .env have all been updated to the Aave versions in the complete-file bundle.

Forge >= 1.5 lint-on-build note: If forge build compiles successfully but then fails during lint with errors about unresolved src/... imports inside the core submodule, your Forge version is running its linter during build. The linter cannot resolve the core submodule's internal import paths from the parent project context. Add the following top-level section to your foundry.toml:

Fragment — foundry.toml lint override

[lint]
lint_on_build = false

Then re-run forge build. This disables lint during compilation without affecting correctness. See foundry-rs/foundry#12721 for background.

Step 2: Choose the Aave integration pattern

For [email protected], the safer Aave v3 pattern is:

  • take an Aave addresses provider as input,
  • take an Aave RewardsController address as input if incentive claiming is enabled,
  • derive the pool from the addresses provider,
  • resolve the current pool data provider from the addresses provider at runtime instead of caching it forever,
  • derive the correct aToken for the chosen asset from Aave's registry,
  • use exact per-deposit approvals and clear the allowance after supplying,
  • use that information to implement deposit, withdraw, reward-claim, and airdrop-sweep behavior correctly.

This is more robust than hardcoding an arbitrary pool address and passing an aToken manually, because it reduces the chance of wiring the strategy to the wrong reserve configuration.

You will need the Aave interfaces below:

Fragment — Aave v3 interface definitions

interface IPool {
function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external;
function withdraw(address asset, uint256 amount, address to) external returns (uint256);
function getReserveNormalizedIncome(address asset) external view returns (uint256);
}

interface IPoolDataProviderSlim {
function getReserveData(
address asset
) external view returns (uint256 unbacked, uint256 accruedToTreasuryScaled, uint256 totalAToken);
}

interface IPoolDataProvider {
function getReserveCaps(address asset) external view returns (uint256 borrowCap, uint256 supplyCap);
function getATokenTotalSupply(address asset) external view returns (uint256);
function getReserveTokensAddresses(
address asset
) external view returns (address aTokenAddress, address stableDebtTokenAddress, address variableDebtTokenAddress);
function getReserveConfigurationData(
address asset
)
external
view
returns (
uint256 decimals,
uint256 ltv,
uint256 liquidationThreshold,
uint256 liquidationBonus,
uint256 reserveFactor,
bool usageAsCollateralEnabled,
bool borrowingEnabled,
bool stableBorrowRateEnabled,
bool isActive,
bool isFrozen
);
function getPaused(address asset) external view returns (bool);
function getReserveData(
address asset
)
external
view
returns (
uint256 unbacked,
uint256 accruedToTreasuryScaled,
uint256 totalAToken,
uint256 totalStableDebt,
uint256 totalVariableDebt,
uint256 liquidityRate,
uint256 variableBorrowRate,
uint256 stableBorrowRate,
uint256 averageStableBorrowRate,
uint256 liquidityIndex,
uint256 variableBorrowIndex,
uint40 lastUpdateTimestamp
);
}

interface IPoolAddressesProvider {
function getPool() external view returns (address);
function getPoolDataProvider() external view returns (address);
}

interface IRewardsController {
function claimAllRewards(
address[] calldata assets,
address to
) external returns (address[] memory rewardsList, uint256[] memory claimedAmounts);
}

Step 3: Implement the strategy constructor

The core repo already includes an AaveV3Strategy that uses BaseHealthCheck. If you want parity with that pattern, build your template example the same way.

A minimal Aave-oriented constructor looks like this:

Fragment — YieldDonatingStrategy constructor

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

import {BaseHealthCheck} from "@octant-core/strategies/periphery/BaseHealthCheck.sol";
import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract YieldDonatingStrategy is BaseHealthCheck {
using SafeERC20 for IERC20;

IPoolAddressesProvider public immutable addressesProvider;
IPool public immutable pool;
address public immutable aToken;
IRewardsController public immutable rewardsController;

constructor(
address _addressesProvider,
address _rewardsController,
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
) {
require(_addressesProvider != address(0), "Zero addressesProvider");

addressesProvider = IPoolAddressesProvider(_addressesProvider);
pool = IPool(addressesProvider.getPool());
rewardsController = IRewardsController(_rewardsController);

IPoolDataProvider initialDataProvider = IPoolDataProvider(addressesProvider.getPoolDataProvider());
(address _aToken, , ) = initialDataProvider.getReserveTokensAddresses(_asset);
require(_aToken != address(0), "Asset not supported by pool");
aToken = _aToken;
}

function dataProvider() public view returns (IPoolDataProvider) {
return IPoolDataProvider(addressesProvider.getPoolDataProvider());
}
}

Why this is better than a looser tutorial setup:

  • it derives the correct pool from the addresses provider,
  • it resolves the current data provider through dataProvider() so governance rotations are picked up,
  • it derives the correct aToken from Aave's own registry,
  • it wires an optional RewardsController for future incentive claims,
  • it fails early if the chosen asset is not supported,
  • it does not grant the Aave pool a standing max allowance in the constructor,
  • it mirrors the pattern used by AaveV3Strategy in [email protected].

Step 4: Implement the core strategy hooks

A. Deploy funds with _deployFunds

Fragment — _deployFunds hook

function _deployFunds(uint256 _amount) internal override {
IERC20(address(asset)).forceApprove(address(pool), _amount);
pool.supply(address(asset), _amount, address(this), 0);
IERC20(address(asset)).forceApprove(address(pool), 0);
}

This should remain simple and deterministic. Do not add swaps or path-dependent logic to your deposit path. Follow the exact-approve-then-clear pattern: approve exactly _amount for the Aave pool call and clear the allowance back to zero afterward.

B. Free funds with _freeFunds

Fragment — _freeFunds hook

function _freeFunds(uint256 _amount) internal override {
pool.withdraw(address(asset), _amount, address(this));
}

This is called when the strategy needs to return underlying assets for withdrawals or redemptions.

C. Report total assets with _harvestAndReport

Fragment — _harvestAndReport hook

function _harvestAndReport() internal view override returns (uint256 _totalAssets) {
uint256 deployedAssets = IERC20(aToken).balanceOf(address(this));
uint256 idleAssets = IERC20(address(asset)).balanceOf(address(this));
_totalAssets = deployedAssets + idleAssets;
}

For Aave v3, the aToken balance tracks the strategy's claim on the supplied underlying. The shared Octant implementation compares the returned total against the previous report and handles profit or loss accounting automatically.

D. Claiming incentives and sweeping stray tokens

[email protected] also includes two keeper-callable operational hooks on its Aave strategy:

  • claimAaveRewards() claims configured Aave supply-side rewards on the strategy's aToken and forwards them to TokenizedStrategy.dragonRouter().
  • sweepAirdrop(address token) forwards non-critical ERC-20 balances to the dragon router, while refusing to sweep the strategy asset or the aToken.

These hooks are not part of the three core strategy callbacks, but they matter for real Aave operations because incentives, airdrops, and off-chain reward distributions can leave extra ERC-20 balances on the strategy address.

Step 5: Add the operational overrides that matter for Aave

This is where the original shortcut most often goes wrong.

A. availableDepositLimit

Aave v3 does have reserve-level supply caps. Deposit limits should account for those caps and subtract any idle balance already held by the strategy. The cap-aware helper below also needs IERC20Metadata and Math, as shown in the complete file.

Fragment — availableDepositLimit override

function availableDepositLimit(address /*_owner*/) public view override returns (uint256) {
if (dataProvider().getPaused(address(asset))) return 0;
(, , , , , , , , bool isActive, bool isFrozen) = dataProvider().getReserveConfigurationData(address(asset));
if (!isActive || isFrozen) return 0;

(, uint256 supplyCap) = dataProvider().getReserveCaps(address(asset));

if (supplyCap == 0) {
return type(uint256).max;
}

uint256 totalSupply = _cappedTotalSupply();
uint256 supplyCapScaled = supplyCap * 10 ** IERC20Metadata(address(asset)).decimals();

if (supplyCapScaled <= totalSupply) {
return 0;
}

uint256 availableCapacity = supplyCapScaled - totalSupply;
uint256 idleBalance = IERC20(address(asset)).balanceOf(address(this));

if (availableCapacity <= idleBalance) {
return 0;
}

return availableCapacity - idleBalance;
}

function _cappedTotalSupply() internal view returns (uint256) {
(, uint256 accruedToTreasuryScaled, uint256 totalAToken) = IPoolDataProviderSlim(address(dataProvider()))
.getReserveData(address(asset));
return
totalAToken +
Math.mulDiv(
accruedToTreasuryScaled,
pool.getReserveNormalizedIncome(address(asset)),
1e27,
Math.Rounding.Ceil
);
}

Do not leave this as type(uint256).max for an Aave strategy unless you have consciously decided to ignore upstream caps.

B. availableWithdrawLimit

Withdrawals should also respect pool liquidity.

Fragment — availableWithdrawLimit override

function availableWithdrawLimit(address /*_owner*/) public view override returns (uint256) {
uint256 idleBalance = IERC20(address(asset)).balanceOf(address(this));

if (dataProvider().getPaused(address(asset))) return idleBalance;
(, , , , , , , , bool isActive, ) = dataProvider().getReserveConfigurationData(address(asset));
if (!isActive) return idleBalance;

uint256 aTokenBalance = IERC20(aToken).balanceOf(address(this));
uint256 poolLiquidity = IERC20(address(asset)).balanceOf(aToken);
uint256 withdrawableFromPool = aTokenBalance < poolLiquidity ? aTokenBalance : poolLiquidity;

return withdrawableFromPool + idleBalance;
}

Without this override, the strategy can advertise more withdrawable value than Aave can actually return immediately.

C. _emergencyWithdraw

For a strategy that keeps capital deployed in an external protocol, emergency withdrawal should not be left unimplemented.

Fragment — _emergencyWithdraw hook

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

D. _tend and _tendTrigger

A plain Aave v3 supply strategy does not require intermediate tending. The base implementation already provides a no-op _tend and a false _tendTrigger, so the complete file below does not override them.

Step 6: Complete worked example

Complete file — src/strategies/yieldDonating/YieldDonatingStrategy.sol

// SPDX-License-Identifier: AGPL-3.0
pragma solidity >=0.8.25;

import { BaseHealthCheck } from "@octant-core/strategies/periphery/BaseHealthCheck.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol";
import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";

interface IPool {
/// @notice Supplies an asset to the Aave pool
function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external;
/// @notice Withdraws an asset from the Aave pool
function withdraw(address asset, uint256 amount, address to) external returns (uint256);
/// @notice Returns the projected liquidity index for a reserve, accounting for the
/// accrual since `lastUpdateTimestamp` — this is the `nextLiquidityIndex`
/// Aave's own `validateSupply` uses, so it matches the cap check exactly.
function getReserveNormalizedIncome(address asset) external view returns (uint256);
}

/// @dev Slim view of `IPoolDataProvider.getReserveData` that only decodes the first
/// three return slots (`unbacked`, `accruedToTreasuryScaled`, `totalAToken`).
/// The cap path needs only `accruedToTreasuryScaled` and `totalAToken`; reading
/// the full 12-slot tuple trips a stack-too-deep on the ABI decoder under the
/// `forge coverage` build profile (viaIR + optimizer disabled). Same underlying
/// contract, narrower signature.
interface IPoolDataProviderSlim {
function getReserveData(
address asset
) external view returns (uint256 unbacked, uint256 accruedToTreasuryScaled, uint256 totalAToken);
}

interface IPoolDataProvider {
/// @notice Returns the supply and borrow caps for a reserve
function getReserveCaps(address asset) external view returns (uint256 borrowCap, uint256 supplyCap);

/// @notice Returns the total aToken supply for a specific asset
function getATokenTotalSupply(address asset) external view returns (uint256);

/// @notice Returns the token addresses of a reserve
function getReserveTokensAddresses(
address asset
) external view returns (address aTokenAddress, address stableDebtTokenAddress, address variableDebtTokenAddress);

/// @notice Returns reserve configuration flags including isActive and isFrozen
function getReserveConfigurationData(
address asset
)
external
view
returns (
uint256 decimals,
uint256 ltv,
uint256 liquidationThreshold,
uint256 liquidationBonus,
uint256 reserveFactor,
bool usageAsCollateralEnabled,
bool borrowingEnabled,
bool stableBorrowRateEnabled,
bool isActive,
bool isFrozen
);

/// @notice Returns whether the reserve is paused (governance/risk admin emergency switch)
function getPaused(address asset) external view returns (bool);

/// @notice Returns full reserve data including totalAToken and accruedToTreasuryScaled.
/// @dev `accruedToTreasuryScaled` is treasury-bound reserve that consumes supply-cap
/// headroom in `ValidationLogic.validateSupply` but is missing from
/// `getATokenTotalSupply`. We add it conservatively to the cap denominator.
function getReserveData(
address asset
)
external
view
returns (
uint256 unbacked,
uint256 accruedToTreasuryScaled,
uint256 totalAToken,
uint256 totalStableDebt,
uint256 totalVariableDebt,
uint256 liquidityRate,
uint256 variableBorrowRate,
uint256 stableBorrowRate,
uint256 averageStableBorrowRate,
uint256 liquidityIndex,
uint256 variableBorrowIndex,
uint40 lastUpdateTimestamp
);
}

interface IPoolAddressesProvider {
/// @notice Returns the address of the Pool contract
function getPool() external view returns (address);
/// @notice Returns the address of the PoolDataProvider contract
function getPoolDataProvider() external view returns (address);
}

interface IRewardsController {
/// @notice Claims all accrued rewards across the supplied assets to `to`.
/// @dev On Aave V3 mainnet (`0x8164Cc65827dcFe994AB23944CBC90e0aa80bFcb`) this is
/// callable from any contract -- there is no permission gate. When no rewards
/// are configured for the supplied assets the call is effectively a no-op.
function claimAllRewards(
address[] calldata assets,
address to
) external returns (address[] memory rewardsList, uint256[] memory claimedAmounts);
}

/**
* @title YieldDonatingStrategy
* @author [Golem Foundation](https://golem.foundation)
* @custom:security-contact [email protected]
* @notice Yield-donating strategy that earns yield from Aave V3
* @dev Deposits assets into Aave V3 lending pool to earn interest
*
* WARNING: THIS CONTRACT IS UNAUDITED AND NOT INTENDED FOR PRODUCTION USE.
* USE AT YOUR OWN RISK.
*
* YIELD FLOW:
* 1. Deposits assets into Aave V3 pool
* 2. Receives aTokens that automatically accrue interest
* 3. On report, profit is minted as shares to donation address
*
* DEPOSIT/WITHDRAW LIMITS:
* - Aave V3 has supply caps per asset that limit total deposits
* - Strategy checks available capacity before deposits
* - Withdrawals limited by available liquidity in the pool
*
* @custom:security Aave pool must be trusted and not manipulatable
*/
contract YieldDonatingStrategy is BaseHealthCheck {
using SafeERC20 for IERC20;

/// @notice Address of the Aave V3 addresses provider
IPoolAddressesProvider public immutable addressesProvider;

/// @notice Address of the Aave V3 pool
IPool public immutable pool;

/// @notice Address of the aToken for the underlying asset
address public immutable aToken;

/// @notice Address of Aave V3's RewardsController for liquidity-mining incentives.
/// @dev Wired at construction so the strategy can claim supply-side emissions if/when
/// Aave governance enables them on a market we use. May be `address(0)` for
/// chains/markets where no RewardsController exists; in that case
/// `claimAaveRewards` reverts with a clear message instead of silently no-op'ing
/// on a zero target.
IRewardsController public immutable rewardsController;

/// @notice Emitted on a successful `claimAaveRewards` call.
event AaveRewardsClaimed(address indexed to, address[] rewardsList, uint256[] amounts);

/// @notice Emitted on a successful `sweepAirdrop` call.
event TokenSwept(address indexed token, uint256 amount, address indexed recipient);

/**
* @notice Initializes the Aave V3 strategy
* @dev Sets up connections to Aave V3 pool, derives aToken from pool registry, and wires the
* optional RewardsController for incentive claims.
* @param _addressesProvider Address of Aave V3 addresses provider
* @param _rewardsController Address of Aave V3 RewardsController (may be `address(0)` to disable)
* @param _asset Address of the underlying asset (must be supported by Aave pool)
* @param _name Strategy display name (e.g., "Octant Aave V3 USDC Strategy")
* @param _symbol Strategy share token symbol (e.g., "osAAVE")
* @param _management Address with management permissions
* @param _keeper Address authorized to call report() and tend()
* @param _emergencyAdmin Address authorized for emergency shutdown
* @param _donationAddress Address receiving minted profit shares
* @param _enableBurning True to enable loss protection via share burning
* @param _tokenizedStrategyAddress Address of TokenizedStrategy implementation contract
*/
constructor(
address _addressesProvider,
address _rewardsController,
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
)
{
require(_addressesProvider != address(0), "Zero addressesProvider");

addressesProvider = IPoolAddressesProvider(_addressesProvider);
pool = IPool(addressesProvider.getPool());
// _rewardsController may be address(0) — see `claimAaveRewards` for the explicit guard.
rewardsController = IRewardsController(_rewardsController);

// Do NOT cache the data provider as immutable. Aave's `addressesProvider`
// rotates the `PoolDataProvider` over time (the Pool itself is a transparent
// proxy and is stable, but the data provider is a fresh deployment per Aave
// AIP). A cached pointer would keep reading stale supply caps, totals, and
// pause flags forever. Read it inline for the constructor-only aToken
// derivation; runtime calls go through the public `dataProvider()` view.
IPoolDataProvider initialDataProvider = IPoolDataProvider(addressesProvider.getPoolDataProvider());
(address _aToken, , ) = initialDataProvider.getReserveTokensAddresses(_asset);
require(_aToken != address(0), "Asset not supported by pool");
aToken = _aToken;
}

/**
* @notice Claims all accrued Aave V3 supply-side rewards on this strategy's aToken
* position and forwards them directly to the dragon router.
* @dev The hook is keeper-callable so it can be folded into the same cron that calls
* `report()`. When no emissions are configured for the aToken the call is a no-op
* (returns empty arrays). Off-chain Merit/Merkl claims are out of scope -- those
* rely on Merkle proofs against an external curator and must be handled by the
* Octant multisig, not the strategy.
*
* Reverts on `address(0)` rewardsController so a misconfigured deployment is
* surfaced loudly instead of silently swallowing claim attempts.
*
* The dragon router used as `to` MUST be capable of accepting arbitrary
* ERC-20s. EOA / splitter routers are fine; an immutable single-asset router
* would lose any non-asset reward token.
* @return rewardsList Reward token addresses claimed (may be empty)
* @return amounts Reward amounts transferred to the dragon router
*/
function claimAaveRewards() external onlyKeepers returns (address[] memory rewardsList, uint256[] memory amounts) {
require(address(rewardsController) != address(0), "YieldDonatingStrategy: RewardsController not configured");
address[] memory assets = new address[](1);
assets[0] = aToken;
address dragon = TokenizedStrategy.dragonRouter();
(rewardsList, amounts) = rewardsController.claimAllRewards(assets, dragon);
emit AaveRewardsClaimed(dragon, rewardsList, amounts);
}

/**
* @notice Sweeps non-critical ERC-20 balances on this strategy address to the dragon
* router. Mirrors `SparkStrategy.sweepAirdrop` so the operational interface
* is consistent across strategies.
* @dev Used to forward airdrops, off-chain reward distributions (Merkl/Merit settled
* by an off-chain curator), and any stray ERC-20 that lands on the strategy
* address. Excludes the strategy asset and the aToken to protect the deployed
* position.
* @param _token Address of the token to sweep (must NOT be `asset` or `aToken`)
*/
function sweepAirdrop(address _token) external onlyKeepers {
require(_token != address(asset), "YieldDonatingStrategy: Cannot sweep main asset");
require(_token != aToken, "YieldDonatingStrategy: Cannot sweep aToken");
uint256 balance = IERC20(_token).balanceOf(address(this));
require(balance > 0, "YieldDonatingStrategy: No balance to sweep");
address dragon = TokenizedStrategy.dragonRouter();
IERC20(_token).safeTransfer(dragon, balance);
emit TokenSwept(_token, balance, dragon);
}

/**
* @notice Returns the current Aave V3 pool data provider.
* @dev Resolved from `addressesProvider` on every call so the strategy picks up
* governance-driven `PoolDataProvider` rotations without redeployment. The
* few hundred extra gas per limit query is the cost of not silently reading
* stale supply caps / pause flags.
* @return Current `IPoolDataProvider` instance reported by the addresses provider.
*/
function dataProvider() public view returns (IPoolDataProvider) {
return IPoolDataProvider(addressesProvider.getPoolDataProvider());
}

/**
* @notice Returns maximum additional assets that can be deposited
* @dev Checks Aave V3 supply cap and subtracts current supply.
*
* DUST CAVEAT: Aave mints aTokens proportional to `amount / liquidityIndex`.
* For sub-unit deposits on a high-index reserve, the scaled mint amount can round
* to zero and `pool.supply` reverts with `INVALID_MINT_AMOUNT`. This view does
* NOT pre-filter such dust; integrators relying on `maxDeposit` should be prepared
* for Aave reverts on amounts below the per-reserve scaled-unit floor.
* A pre-flight check is intentionally omitted to keep the hot path cheap for
* the typical case where deposits are far above the dust threshold.
* @return limit Maximum additional deposit amount in asset base units
*/
function availableDepositLimit(address /*_owner*/) public view override returns (uint256) {
// Aave-side blockers make `pool.supply` revert when the reserve is paused,
// inactive, or frozen; surfacing capacity through `maxDeposit` would only
// route users into failing transactions.
if (dataProvider().getPaused(address(asset))) return 0;
(, , , , , , , , bool isActive, bool isFrozen) = dataProvider().getReserveConfigurationData(address(asset));
if (!isActive || isFrozen) return 0;

(, uint256 supplyCap) = dataProvider().getReserveCaps(address(asset));

// If supply cap is 0, it means unlimited (see https://github.com/aave/aave-v3-core/blob/782f51917056a53a2c228701058a6c3fb233684a/contracts/protocol/libraries/types/DataTypes.sol#L53)
if (supplyCap == 0) {
return type(uint256).max;
}

// Aave's `validateSupply` enforces
// (scaledTotalSupply + accruedToTreasury).rayMul(nextLiquidityIndex) + amount
// <= supplyCap * 10^decimals
// `getATokenTotalSupply` omits the `accruedToTreasury` contribution, so a
// headroom check that uses `totalAToken` alone over-reports cap room and
// lets users hit a downstream `SUPPLY_CAP_EXCEEDED` revert. The helper
// rayMul-scales treasury into underlying units and keeps its locals out
// of this function's stack frame (needed for the `--no-via-ir` coverage
// build profile, which otherwise hits a stack-too-deep on the 12-return
// decoder combined with the surrounding locals).
uint256 totalSupply = _cappedTotalSupply();

// Cap is in whole tokens, need to adjust for decimals (see https://github.com/aave/aave-v3-core/blob/782f51917056a53a2c228701058a6c3fb233684a/contracts/protocol/libraries/types/DataTypes.sol#L53)
uint256 supplyCapScaled = supplyCap * 10 ** IERC20Metadata(address(asset)).decimals();

if (supplyCapScaled > totalSupply) {
uint256 availableCapacity = supplyCapScaled - totalSupply;
uint256 idleBalance = IERC20(address(asset)).balanceOf(address(this));

// Safely subtract idle balance to avoid underflow
if (availableCapacity <= idleBalance) {
return 0;
} else {
return availableCapacity - idleBalance;
}
} else {
return 0;
}
}

/// @dev Returns the cap-denominator view of total supplied assets, rayMul-scaled
/// into underlying units to match Aave's `validateSupply`:
/// `totalAToken + rayMul(accruedToTreasuryScaled, nextLiquidityIndex)`,
/// ceiling-rounded so headroom stays conservative.
///
/// `liquidityIndex` is read via `IPool.getReserveNormalizedIncome` — this is
/// the projected `nextLiquidityIndex` Aave uses, avoiding the few-seconds-of-
/// accrual gap vs the stored `liquidityIndex` on the data provider.
///
/// Uses `IPoolDataProviderSlim` (3-return view of `getReserveData`) instead
/// of the full 12-return interface: the narrower ABI decoder keeps the call
/// site within the EVM stack budget under the `--no-via-ir` + `--no-optimizer`
/// `forge coverage` build. Identical on-chain behavior — Solidity decodes the
/// first three 32-byte slots of the return data and stops.
function _cappedTotalSupply() internal view returns (uint256) {
(, uint256 accruedToTreasuryScaled, uint256 totalAToken) = IPoolDataProviderSlim(address(dataProvider()))
.getReserveData(address(asset));
return
totalAToken +
Math.mulDiv(
accruedToTreasuryScaled,
pool.getReserveNormalizedIncome(address(asset)),
1e27,
Math.Rounding.Ceil
);
}

/**
* @notice Returns maximum assets withdrawable without expected loss
* @dev Checks pool liquidity to ensure withdrawals won't fail due to high utilization.
*
* DUST CAVEAT: mirrors `availableDepositLimit`. Aave burns aTokens proportional
* to `amount / liquidityIndex`; sub-unit withdrawals can round to a zero scaled
* burn amount and revert with `INVALID_BURN_AMOUNT`. This view does NOT pre-filter
* dust amounts; callers must handle Aave reverts on amounts below the per-reserve
* scaled-unit floor.
* @return limit Maximum withdrawal amount in asset base units
*/
function availableWithdrawLimit(address /*_owner*/) public view override returns (uint256) {
// Idle balance is always withdrawable -- it lives on this contract, no Aave
// pool interaction is required to move it out. This matters on a paused or
// inactive reserve (short-circuit below) AND after `emergencyWithdraw`
// pulls everything out of Aave into idle.
uint256 idleBalance = IERC20(address(asset)).balanceOf(address(this));

// `pool.withdraw` reverts when the reserve is paused or inactive.
// (Withdrawals are NOT blocked by `isFrozen` -- a frozen reserve still allows
// exits.) Cap `maxWithdraw`/`maxRedeem` at idle-only so users can still exit
// any balance already out of the pool even while the Aave side is blocked.
if (dataProvider().getPaused(address(asset))) return idleBalance;
(, , , , , , , , bool isActive, ) = dataProvider().getReserveConfigurationData(address(asset));
if (!isActive) return idleBalance;

// Get our aToken balance which represents our deposited assets
uint256 aTokenBalance = IERC20(aToken).balanceOf(address(this));

// Check pool liquidity - the underlying asset balance held by the aToken contract
uint256 poolLiquidity = IERC20(address(asset)).balanceOf(aToken);

// We can only withdraw up to the pool's available liquidity
uint256 withdrawableFromPool = aTokenBalance < poolLiquidity ? aTokenBalance : poolLiquidity;

return withdrawableFromPool + idleBalance;
}

/**
* @dev Deposits idle assets into Aave V3 pool.
*
* We used to grant `type(uint256).max` allowance to the pool in the constructor.
* The Aave pool is an upgradeable proxy controlled by external governance, so a
* standing max-approval would let a hostile upgrade drain the strategy's idle
* balance at any time. We now approve exactly `_amount` for the call and clear
* the allowance afterward, even if a broken counterparty returns after pulling
* less than `_amount`. `forceApprove` handles non-standard tokens that require
* clearing an existing non-zero allowance first.
* @param _amount Amount of assets to deploy in asset base units
*/
function _deployFunds(uint256 _amount) internal override {
IERC20(address(asset)).forceApprove(address(pool), _amount);
pool.supply(address(asset), _amount, address(this), 0);
IERC20(address(asset)).forceApprove(address(pool), 0);
}

/**
* @dev Withdraws assets from Aave V3 pool
* @param _amount Amount of assets to withdraw in asset base units
*/
function _freeFunds(uint256 _amount) internal override {
pool.withdraw(address(asset), _amount, address(this));
}

/**
* @dev Emergency withdrawal after strategy shutdown.
*
* AAVE DEPENDENCY: this path delegates to `_freeFunds`, which calls
* `pool.withdraw(asset, amount, this)` with no fallback. Emergency
* withdrawal therefore depends on Aave pool state -- if Aave has
* paused the reserve, marked it inactive, or utilization is too high
* for the requested amount, the call reverts and no funds are pulled.
*
* OPERATIONAL RUNBOOK when Aave blocks an emergency exit:
* 1. Call `shutdownStrategy()` first -- this marks the strategy
* shutdown for new deposits independently of Aave state.
* 2. Call `emergencyWithdraw(amount)` with `amount` capped at the
* Aave-backed portion of `availableWithdrawLimit(address(0))`.
* The view already accounts for pool liquidity; on a paused or
* inactive reserve it surfaces only idle assets, which are already
* outside Aave and remain withdrawable through regular user exits.
* 3. Repeat step 2 as Aave liquidity returns or pauses lift. A
* strategy stuck on a paused reserve recovers automatically once
* Aave governance resumes the pool; no contract-level rescue is
* possible without trusting the Aave counterparty.
* @param _amount Amount of assets to withdraw in asset base units
*/
function _emergencyWithdraw(uint256 _amount) internal override {
_freeFunds(_amount);
}

/**
* @dev Reports current total assets under management
* @return _totalAssets Sum of aToken balance and idle assets in asset base units
*/
function _harvestAndReport() internal view override returns (uint256 _totalAssets) {
// aTokens have 1:1 value with underlying asset
uint256 aTokenBalance = IERC20(aToken).balanceOf(address(this));

// Include idle funds as per BaseStrategy specification
uint256 idleAssets = IERC20(address(asset)).balanceOf(address(this));

_totalAssets = aTokenBalance + idleAssets;

return _totalAssets;
}
}

Step 7: Wire the starter harness to Aave

Before testing, update the starter-template support files so they instantiate the Aave strategy you just wrote:

  • replace src/test/yieldDonating/YieldDonatingSetup.sol,
  • replace src/strategies/yieldDonating/YieldDonatingStrategyFactory.sol,
  • replace src/test/yieldDonating/YieldDonatingFunctionSignature.t.sol,
  • create script/Deploy.s.sol if your checkout does not already have a script/ directory,
  • update .env with TEST_ASSET_ADDRESS, ETH_RPC_URL, AAVE_ADDRESSES_PROVIDER, and AAVE_REWARDS_CONTROLLER,
  • add the foundry.toml lint override if your Forge version runs lint during forge build.

Command — create the deploy-script directory if needed

mkdir -p script

Use the complete-file bundle at the end of this page for these files. This step is required because the pinned starter snapshot still wires tests through a generic TEST_YIELD_SOURCE variable and an older constructor shape.

Step 8: Test your strategy

Treat the starter tests as scaffolding, not as proof that your integration is correct.

After wiring the strategy, verify at least the following:

  • assets are supplied to Aave correctly,
  • withdrawals work while liquidity is available,
  • availableDepositLimit reacts correctly to Aave reserve caps,
  • availableWithdrawLimit reacts correctly to pool liquidity,
  • profits mint shares to the donation address,
  • donation shares are burned first on loss when burning is enabled,
  • emergency withdrawals work after shutdown.

After the complete Aave bundle is applied, run the starter tests on a mainnet fork:

Command — run Aave starter verification

forge build
make test-contract contract=YieldDonatingFunctionSignatureTest
make test-contract contract=YieldDonatingOperationTest
make test-contract contract=YieldDonatingShutdownTest
make test

For debugging a specific failure, use make trace-contract contract=YieldDonatingOperationTest. These commands require ETH_RPC_URL to point at an Ethereum mainnet RPC because the Aave PoolAddressesProvider, pool, aToken, and USDC addresses are resolved on the fork.

Step 9: Know when not to use this path

For [email protected], the core repo already includes:

  • AaveV3Strategy,
  • AaveV3StrategyFactory.

So there are two valid paths:

  1. use the starter template to learn the pattern or build a custom variant,
  2. compare your work against the built-in core implementation and factory if you want the repo's existing Aave deployment pattern.

If your goal is simply to deploy a standard Aave v3 USDC yield-donating strategy on the supported network, the built-in core pattern may be a better reference than a hand-rolled starter-template example.

Before production

Before adapting this template-based strategy to a live deployment, confirm that you have:

  • reconciled the starter template against the exact octant-v2-core release you are targeting,
  • replaced example addresses and tutorial assumptions,
  • validated real fund movement in _deployFunds, _freeFunds, _harvestAndReport, and _emergencyWithdraw,
  • implemented upstream-aware deposit and withdraw limits where the protocol needs them,
  • reviewed the relevant reference pages for inherited behaviour and role-gated actions,
  • chosen a production donation flow, including whether the strategy should donate directly or via a Payment Splitter,
  • tested shutdown and loss scenarios, not just happy-path deposits,
  • arranged an independent security review before handling real funds.
caution

Code examples in this guide 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 for details.


Complete files — copy-paste bundle

All files below reflect the final tutorial state after completing every step in this guide. Apply them to the corresponding paths in your octant-v2-strategy-foundry-mix checkout, then run the verification commands and apply the foundry.toml patch if your Forge version needs it.

src/strategies/yieldDonating/YieldDonatingStrategy.sol

Complete file — src/strategies/yieldDonating/YieldDonatingStrategy.sol

// SPDX-License-Identifier: AGPL-3.0
pragma solidity >=0.8.25;

import { BaseHealthCheck } from "@octant-core/strategies/periphery/BaseHealthCheck.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol";
import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";

interface IPool {
/// @notice Supplies an asset to the Aave pool
function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external;
/// @notice Withdraws an asset from the Aave pool
function withdraw(address asset, uint256 amount, address to) external returns (uint256);
/// @notice Returns the projected liquidity index for a reserve, accounting for the
/// accrual since `lastUpdateTimestamp` — this is the `nextLiquidityIndex`
/// Aave's own `validateSupply` uses, so it matches the cap check exactly.
function getReserveNormalizedIncome(address asset) external view returns (uint256);
}

/// @dev Slim view of `IPoolDataProvider.getReserveData` that only decodes the first
/// three return slots (`unbacked`, `accruedToTreasuryScaled`, `totalAToken`).
/// The cap path needs only `accruedToTreasuryScaled` and `totalAToken`; reading
/// the full 12-slot tuple trips a stack-too-deep on the ABI decoder under the
/// `forge coverage` build profile (viaIR + optimizer disabled). Same underlying
/// contract, narrower signature.
interface IPoolDataProviderSlim {
function getReserveData(
address asset
) external view returns (uint256 unbacked, uint256 accruedToTreasuryScaled, uint256 totalAToken);
}

interface IPoolDataProvider {
/// @notice Returns the supply and borrow caps for a reserve
function getReserveCaps(address asset) external view returns (uint256 borrowCap, uint256 supplyCap);

/// @notice Returns the total aToken supply for a specific asset
function getATokenTotalSupply(address asset) external view returns (uint256);

/// @notice Returns the token addresses of a reserve
function getReserveTokensAddresses(
address asset
) external view returns (address aTokenAddress, address stableDebtTokenAddress, address variableDebtTokenAddress);

/// @notice Returns reserve configuration flags including isActive and isFrozen
function getReserveConfigurationData(
address asset
)
external
view
returns (
uint256 decimals,
uint256 ltv,
uint256 liquidationThreshold,
uint256 liquidationBonus,
uint256 reserveFactor,
bool usageAsCollateralEnabled,
bool borrowingEnabled,
bool stableBorrowRateEnabled,
bool isActive,
bool isFrozen
);

/// @notice Returns whether the reserve is paused (governance/risk admin emergency switch)
function getPaused(address asset) external view returns (bool);

/// @notice Returns full reserve data including totalAToken and accruedToTreasuryScaled.
/// @dev `accruedToTreasuryScaled` is treasury-bound reserve that consumes supply-cap
/// headroom in `ValidationLogic.validateSupply` but is missing from
/// `getATokenTotalSupply`. We add it conservatively to the cap denominator.
function getReserveData(
address asset
)
external
view
returns (
uint256 unbacked,
uint256 accruedToTreasuryScaled,
uint256 totalAToken,
uint256 totalStableDebt,
uint256 totalVariableDebt,
uint256 liquidityRate,
uint256 variableBorrowRate,
uint256 stableBorrowRate,
uint256 averageStableBorrowRate,
uint256 liquidityIndex,
uint256 variableBorrowIndex,
uint40 lastUpdateTimestamp
);
}

interface IPoolAddressesProvider {
/// @notice Returns the address of the Pool contract
function getPool() external view returns (address);
/// @notice Returns the address of the PoolDataProvider contract
function getPoolDataProvider() external view returns (address);
}

interface IRewardsController {
/// @notice Claims all accrued rewards across the supplied assets to `to`.
/// @dev On Aave V3 mainnet (`0x8164Cc65827dcFe994AB23944CBC90e0aa80bFcb`) this is
/// callable from any contract -- there is no permission gate. When no rewards
/// are configured for the supplied assets the call is effectively a no-op.
function claimAllRewards(
address[] calldata assets,
address to
) external returns (address[] memory rewardsList, uint256[] memory claimedAmounts);
}

/**
* @title YieldDonatingStrategy
* @author [Golem Foundation](https://golem.foundation)
* @custom:security-contact [email protected]
* @notice Yield-donating strategy that earns yield from Aave V3
* @dev Deposits assets into Aave V3 lending pool to earn interest
*
* WARNING: THIS CONTRACT IS UNAUDITED AND NOT INTENDED FOR PRODUCTION USE.
* USE AT YOUR OWN RISK.
*
* YIELD FLOW:
* 1. Deposits assets into Aave V3 pool
* 2. Receives aTokens that automatically accrue interest
* 3. On report, profit is minted as shares to donation address
*
* DEPOSIT/WITHDRAW LIMITS:
* - Aave V3 has supply caps per asset that limit total deposits
* - Strategy checks available capacity before deposits
* - Withdrawals limited by available liquidity in the pool
*
* @custom:security Aave pool must be trusted and not manipulatable
*/
contract YieldDonatingStrategy is BaseHealthCheck {
using SafeERC20 for IERC20;

/// @notice Address of the Aave V3 addresses provider
IPoolAddressesProvider public immutable addressesProvider;

/// @notice Address of the Aave V3 pool
IPool public immutable pool;

/// @notice Address of the aToken for the underlying asset
address public immutable aToken;

/// @notice Address of Aave V3's RewardsController for liquidity-mining incentives.
/// @dev Wired at construction so the strategy can claim supply-side emissions if/when
/// Aave governance enables them on a market we use. May be `address(0)` for
/// chains/markets where no RewardsController exists; in that case
/// `claimAaveRewards` reverts with a clear message instead of silently no-op'ing
/// on a zero target.
IRewardsController public immutable rewardsController;

/// @notice Emitted on a successful `claimAaveRewards` call.
event AaveRewardsClaimed(address indexed to, address[] rewardsList, uint256[] amounts);

/// @notice Emitted on a successful `sweepAirdrop` call.
event TokenSwept(address indexed token, uint256 amount, address indexed recipient);

/**
* @notice Initializes the Aave V3 strategy
* @dev Sets up connections to Aave V3 pool, derives aToken from pool registry, and wires the
* optional RewardsController for incentive claims.
* @param _addressesProvider Address of Aave V3 addresses provider
* @param _rewardsController Address of Aave V3 RewardsController (may be `address(0)` to disable)
* @param _asset Address of the underlying asset (must be supported by Aave pool)
* @param _name Strategy display name (e.g., "Octant Aave V3 USDC Strategy")
* @param _symbol Strategy share token symbol (e.g., "osAAVE")
* @param _management Address with management permissions
* @param _keeper Address authorized to call report() and tend()
* @param _emergencyAdmin Address authorized for emergency shutdown
* @param _donationAddress Address receiving minted profit shares
* @param _enableBurning True to enable loss protection via share burning
* @param _tokenizedStrategyAddress Address of TokenizedStrategy implementation contract
*/
constructor(
address _addressesProvider,
address _rewardsController,
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
)
{
require(_addressesProvider != address(0), "Zero addressesProvider");

addressesProvider = IPoolAddressesProvider(_addressesProvider);
pool = IPool(addressesProvider.getPool());
// _rewardsController may be address(0) — see `claimAaveRewards` for the explicit guard.
rewardsController = IRewardsController(_rewardsController);

// Do NOT cache the data provider as immutable. Aave's `addressesProvider`
// rotates the `PoolDataProvider` over time (the Pool itself is a transparent
// proxy and is stable, but the data provider is a fresh deployment per Aave
// AIP). A cached pointer would keep reading stale supply caps, totals, and
// pause flags forever. Read it inline for the constructor-only aToken
// derivation; runtime calls go through the public `dataProvider()` view.
IPoolDataProvider initialDataProvider = IPoolDataProvider(addressesProvider.getPoolDataProvider());
(address _aToken, , ) = initialDataProvider.getReserveTokensAddresses(_asset);
require(_aToken != address(0), "Asset not supported by pool");
aToken = _aToken;
}

/**
* @notice Claims all accrued Aave V3 supply-side rewards on this strategy's aToken
* position and forwards them directly to the dragon router.
* @dev The hook is keeper-callable so it can be folded into the same cron that calls
* `report()`. When no emissions are configured for the aToken the call is a no-op
* (returns empty arrays). Off-chain Merit/Merkl claims are out of scope -- those
* rely on Merkle proofs against an external curator and must be handled by the
* Octant multisig, not the strategy.
*
* Reverts on `address(0)` rewardsController so a misconfigured deployment is
* surfaced loudly instead of silently swallowing claim attempts.
*
* The dragon router used as `to` MUST be capable of accepting arbitrary
* ERC-20s. EOA / splitter routers are fine; an immutable single-asset router
* would lose any non-asset reward token.
* @return rewardsList Reward token addresses claimed (may be empty)
* @return amounts Reward amounts transferred to the dragon router
*/
function claimAaveRewards() external onlyKeepers returns (address[] memory rewardsList, uint256[] memory amounts) {
require(address(rewardsController) != address(0), "YieldDonatingStrategy: RewardsController not configured");
address[] memory assets = new address[](1);
assets[0] = aToken;
address dragon = TokenizedStrategy.dragonRouter();
(rewardsList, amounts) = rewardsController.claimAllRewards(assets, dragon);
emit AaveRewardsClaimed(dragon, rewardsList, amounts);
}

/**
* @notice Sweeps non-critical ERC-20 balances on this strategy address to the dragon
* router. Mirrors `SparkStrategy.sweepAirdrop` so the operational interface
* is consistent across strategies.
* @dev Used to forward airdrops, off-chain reward distributions (Merkl/Merit settled
* by an off-chain curator), and any stray ERC-20 that lands on the strategy
* address. Excludes the strategy asset and the aToken to protect the deployed
* position.
* @param _token Address of the token to sweep (must NOT be `asset` or `aToken`)
*/
function sweepAirdrop(address _token) external onlyKeepers {
require(_token != address(asset), "YieldDonatingStrategy: Cannot sweep main asset");
require(_token != aToken, "YieldDonatingStrategy: Cannot sweep aToken");
uint256 balance = IERC20(_token).balanceOf(address(this));
require(balance > 0, "YieldDonatingStrategy: No balance to sweep");
address dragon = TokenizedStrategy.dragonRouter();
IERC20(_token).safeTransfer(dragon, balance);
emit TokenSwept(_token, balance, dragon);
}

/**
* @notice Returns the current Aave V3 pool data provider.
* @dev Resolved from `addressesProvider` on every call so the strategy picks up
* governance-driven `PoolDataProvider` rotations without redeployment. The
* few hundred extra gas per limit query is the cost of not silently reading
* stale supply caps / pause flags.
* @return Current `IPoolDataProvider` instance reported by the addresses provider.
*/
function dataProvider() public view returns (IPoolDataProvider) {
return IPoolDataProvider(addressesProvider.getPoolDataProvider());
}

/**
* @notice Returns maximum additional assets that can be deposited
* @dev Checks Aave V3 supply cap and subtracts current supply.
*
* DUST CAVEAT: Aave mints aTokens proportional to `amount / liquidityIndex`.
* For sub-unit deposits on a high-index reserve, the scaled mint amount can round
* to zero and `pool.supply` reverts with `INVALID_MINT_AMOUNT`. This view does
* NOT pre-filter such dust; integrators relying on `maxDeposit` should be prepared
* for Aave reverts on amounts below the per-reserve scaled-unit floor.
* A pre-flight check is intentionally omitted to keep the hot path cheap for
* the typical case where deposits are far above the dust threshold.
* @return limit Maximum additional deposit amount in asset base units
*/
function availableDepositLimit(address /*_owner*/) public view override returns (uint256) {
// Aave-side blockers make `pool.supply` revert when the reserve is paused,
// inactive, or frozen; surfacing capacity through `maxDeposit` would only
// route users into failing transactions.
if (dataProvider().getPaused(address(asset))) return 0;
(, , , , , , , , bool isActive, bool isFrozen) = dataProvider().getReserveConfigurationData(address(asset));
if (!isActive || isFrozen) return 0;

(, uint256 supplyCap) = dataProvider().getReserveCaps(address(asset));

// If supply cap is 0, it means unlimited (see https://github.com/aave/aave-v3-core/blob/782f51917056a53a2c228701058a6c3fb233684a/contracts/protocol/libraries/types/DataTypes.sol#L53)
if (supplyCap == 0) {
return type(uint256).max;
}

// Aave's `validateSupply` enforces
// (scaledTotalSupply + accruedToTreasury).rayMul(nextLiquidityIndex) + amount
// <= supplyCap * 10^decimals
// `getATokenTotalSupply` omits the `accruedToTreasury` contribution, so a
// headroom check that uses `totalAToken` alone over-reports cap room and
// lets users hit a downstream `SUPPLY_CAP_EXCEEDED` revert. The helper
// rayMul-scales treasury into underlying units and keeps its locals out
// of this function's stack frame (needed for the `--no-via-ir` coverage
// build profile, which otherwise hits a stack-too-deep on the 12-return
// decoder combined with the surrounding locals).
uint256 totalSupply = _cappedTotalSupply();

// Cap is in whole tokens, need to adjust for decimals (see https://github.com/aave/aave-v3-core/blob/782f51917056a53a2c228701058a6c3fb233684a/contracts/protocol/libraries/types/DataTypes.sol#L53)
uint256 supplyCapScaled = supplyCap * 10 ** IERC20Metadata(address(asset)).decimals();

if (supplyCapScaled > totalSupply) {
uint256 availableCapacity = supplyCapScaled - totalSupply;
uint256 idleBalance = IERC20(address(asset)).balanceOf(address(this));

// Safely subtract idle balance to avoid underflow
if (availableCapacity <= idleBalance) {
return 0;
} else {
return availableCapacity - idleBalance;
}
} else {
return 0;
}
}

/// @dev Returns the cap-denominator view of total supplied assets, rayMul-scaled
/// into underlying units to match Aave's `validateSupply`:
/// `totalAToken + rayMul(accruedToTreasuryScaled, nextLiquidityIndex)`,
/// ceiling-rounded so headroom stays conservative.
///
/// `liquidityIndex` is read via `IPool.getReserveNormalizedIncome` — this is
/// the projected `nextLiquidityIndex` Aave uses, avoiding the few-seconds-of-
/// accrual gap vs the stored `liquidityIndex` on the data provider.
///
/// Uses `IPoolDataProviderSlim` (3-return view of `getReserveData`) instead
/// of the full 12-return interface: the narrower ABI decoder keeps the call
/// site within the EVM stack budget under the `--no-via-ir` + `--no-optimizer`
/// `forge coverage` build. Identical on-chain behavior — Solidity decodes the
/// first three 32-byte slots of the return data and stops.
function _cappedTotalSupply() internal view returns (uint256) {
(, uint256 accruedToTreasuryScaled, uint256 totalAToken) = IPoolDataProviderSlim(address(dataProvider()))
.getReserveData(address(asset));
return
totalAToken +
Math.mulDiv(
accruedToTreasuryScaled,
pool.getReserveNormalizedIncome(address(asset)),
1e27,
Math.Rounding.Ceil
);
}

/**
* @notice Returns maximum assets withdrawable without expected loss
* @dev Checks pool liquidity to ensure withdrawals won't fail due to high utilization.
*
* DUST CAVEAT: mirrors `availableDepositLimit`. Aave burns aTokens proportional
* to `amount / liquidityIndex`; sub-unit withdrawals can round to a zero scaled
* burn amount and revert with `INVALID_BURN_AMOUNT`. This view does NOT pre-filter
* dust amounts; callers must handle Aave reverts on amounts below the per-reserve
* scaled-unit floor.
* @return limit Maximum withdrawal amount in asset base units
*/
function availableWithdrawLimit(address /*_owner*/) public view override returns (uint256) {
// Idle balance is always withdrawable -- it lives on this contract, no Aave
// pool interaction is required to move it out. This matters on a paused or
// inactive reserve (short-circuit below) AND after `emergencyWithdraw`
// pulls everything out of Aave into idle.
uint256 idleBalance = IERC20(address(asset)).balanceOf(address(this));

// `pool.withdraw` reverts when the reserve is paused or inactive.
// (Withdrawals are NOT blocked by `isFrozen` -- a frozen reserve still allows
// exits.) Cap `maxWithdraw`/`maxRedeem` at idle-only so users can still exit
// any balance already out of the pool even while the Aave side is blocked.
if (dataProvider().getPaused(address(asset))) return idleBalance;
(, , , , , , , , bool isActive, ) = dataProvider().getReserveConfigurationData(address(asset));
if (!isActive) return idleBalance;

// Get our aToken balance which represents our deposited assets
uint256 aTokenBalance = IERC20(aToken).balanceOf(address(this));

// Check pool liquidity - the underlying asset balance held by the aToken contract
uint256 poolLiquidity = IERC20(address(asset)).balanceOf(aToken);

// We can only withdraw up to the pool's available liquidity
uint256 withdrawableFromPool = aTokenBalance < poolLiquidity ? aTokenBalance : poolLiquidity;

return withdrawableFromPool + idleBalance;
}

/**
* @dev Deposits idle assets into Aave V3 pool.
*
* We used to grant `type(uint256).max` allowance to the pool in the constructor.
* The Aave pool is an upgradeable proxy controlled by external governance, so a
* standing max-approval would let a hostile upgrade drain the strategy's idle
* balance at any time. We now approve exactly `_amount` for the call and clear
* the allowance afterward, even if a broken counterparty returns after pulling
* less than `_amount`. `forceApprove` handles non-standard tokens that require
* clearing an existing non-zero allowance first.
* @param _amount Amount of assets to deploy in asset base units
*/
function _deployFunds(uint256 _amount) internal override {
IERC20(address(asset)).forceApprove(address(pool), _amount);
pool.supply(address(asset), _amount, address(this), 0);
IERC20(address(asset)).forceApprove(address(pool), 0);
}

/**
* @dev Withdraws assets from Aave V3 pool
* @param _amount Amount of assets to withdraw in asset base units
*/
function _freeFunds(uint256 _amount) internal override {
pool.withdraw(address(asset), _amount, address(this));
}

/**
* @dev Emergency withdrawal after strategy shutdown.
*
* AAVE DEPENDENCY: this path delegates to `_freeFunds`, which calls
* `pool.withdraw(asset, amount, this)` with no fallback. Emergency
* withdrawal therefore depends on Aave pool state -- if Aave has
* paused the reserve, marked it inactive, or utilization is too high
* for the requested amount, the call reverts and no funds are pulled.
*
* OPERATIONAL RUNBOOK when Aave blocks an emergency exit:
* 1. Call `shutdownStrategy()` first -- this marks the strategy
* shutdown for new deposits independently of Aave state.
* 2. Call `emergencyWithdraw(amount)` with `amount` capped at the
* Aave-backed portion of `availableWithdrawLimit(address(0))`.
* The view already accounts for pool liquidity; on a paused or
* inactive reserve it surfaces only idle assets, which are already
* outside Aave and remain withdrawable through regular user exits.
* 3. Repeat step 2 as Aave liquidity returns or pauses lift. A
* strategy stuck on a paused reserve recovers automatically once
* Aave governance resumes the pool; no contract-level rescue is
* possible without trusting the Aave counterparty.
* @param _amount Amount of assets to withdraw in asset base units
*/
function _emergencyWithdraw(uint256 _amount) internal override {
_freeFunds(_amount);
}

/**
* @dev Reports current total assets under management
* @return _totalAssets Sum of aToken balance and idle assets in asset base units
*/
function _harvestAndReport() internal view override returns (uint256 _totalAssets) {
// aTokens have 1:1 value with underlying asset
uint256 aTokenBalance = IERC20(aToken).balanceOf(address(this));

// Include idle funds as per BaseStrategy specification
uint256 idleAssets = IERC20(address(asset)).balanceOf(address(this));

_totalAssets = aTokenBalance + idleAssets;

return _totalAssets;
}
}

src/test/yieldDonating/YieldDonatingSetup.sol

Complete file — src/test/yieldDonating/YieldDonatingSetup.sol

// SPDX-License-Identifier: AGPL-3.0
pragma solidity ^0.8.25;

import "forge-std/console2.sol";
import {Test} from "forge-std/Test.sol";

import {YieldDonatingStrategy as Strategy} from "../../strategies/yieldDonating/YieldDonatingStrategy.sol";
import {YieldDonatingStrategyFactory as StrategyFactory} from "../../strategies/yieldDonating/YieldDonatingStrategyFactory.sol";
import {IStrategyInterface} from "../../interfaces/IStrategyInterface.sol";
import {ITokenizedStrategy} from "@octant-core/core/interfaces/ITokenizedStrategy.sol";
import {YieldDonatingTokenizedStrategy} from "@octant-core/strategies/yieldDonating/YieldDonatingTokenizedStrategy.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

// Inherit the events so they can be checked if desired.
import {IEvents} from "@tokenized-strategy/interfaces/IEvents.sol";

contract YieldDonatingSetup is Test, IEvents {
// Contract instances that we will use repeatedly.
ERC20 public asset;
IStrategyInterface public strategy;

StrategyFactory public strategyFactory;

// Addresses for different roles we will use repeatedly.
address public user = address(10);
address public keeper = address(4);
address public management = address(1);
address public dragonRouter = address(3); // This is the donation address.
address public emergencyAdmin = address(5);

// YieldDonating specific variables.
bool public enableBurning = true;
address public tokenizedStrategyAddress;
address public addressesProvider;
address public rewardsController;

// Integer variables that will be used repeatedly.
uint256 public decimals;
uint256 public MAX_BPS = 10_000;

// Fuzz from $0.01 of 1e6 stable coins up to 1,000,000 of the asset.
uint256 public maxFuzzAmount;
uint256 public minFuzzAmount = 10_000;

// Default profit max unlock time is set for 10 days.
uint256 public profitMaxUnlockTime = 10 days;

function setUp() public virtual {
address testAssetAddress = vm.envAddress("TEST_ASSET_ADDRESS");
require(testAssetAddress != address(0), "TEST_ASSET_ADDRESS not set in .env");

addressesProvider = vm.envAddress("AAVE_ADDRESSES_PROVIDER");
require(addressesProvider != address(0), "AAVE_ADDRESSES_PROVIDER not set in .env");

rewardsController = vm.envAddress("AAVE_REWARDS_CONTROLLER");
require(rewardsController != address(0), "AAVE_REWARDS_CONTROLLER not set in .env");

asset = ERC20(testAssetAddress);
decimals = asset.decimals();
maxFuzzAmount = 1_000_000 * 10 ** decimals;

tokenizedStrategyAddress = address(new YieldDonatingTokenizedStrategy());
strategyFactory = new StrategyFactory(management, dragonRouter, keeper, emergencyAdmin, tokenizedStrategyAddress);

strategy = IStrategyInterface(setUpStrategy());

vm.label(keeper, "keeper");
vm.label(address(asset), "asset");
vm.label(management, "management");
vm.label(address(strategy), "strategy");
vm.label(dragonRouter, "dragonRouter");
vm.label(addressesProvider, "AaveAddressesProvider");
vm.label(rewardsController, "AaveRewardsController");
}

function setUpStrategy() public returns (address) {
IStrategyInterface _strategy = IStrategyInterface(
address(
new Strategy(
addressesProvider,
rewardsController,
address(asset),
"Aave V3 YieldDonating Strategy",
"aYDS",
management,
keeper,
emergencyAdmin,
dragonRouter,
enableBurning,
tokenizedStrategyAddress
)
)
);

return address(_strategy);
}

function depositIntoStrategy(IStrategyInterface _strategy, address _user, uint256 _amount) public {
vm.prank(_user);
asset.approve(address(_strategy), _amount);

vm.prank(_user);
_strategy.deposit(_amount, _user);
}

function mintAndDepositIntoStrategy(IStrategyInterface _strategy, address _user, uint256 _amount) public {
airdrop(asset, _user, _amount);
depositIntoStrategy(_strategy, _user, _amount);
}

// For checking the amounts in the strategy.
function checkStrategyTotals(
IStrategyInterface _strategy,
uint256 _totalAssets,
uint256 _totalDebt,
uint256 _totalIdle
) public {
uint256 _assets = _strategy.totalAssets();
uint256 _balance = ERC20(_strategy.asset()).balanceOf(address(_strategy));
uint256 _idle = _balance > _assets ? _assets : _balance;
uint256 _debt = _assets - _idle;
assertEq(_assets, _totalAssets, "!totalAssets");
assertEq(_debt, _totalDebt, "!totalDebt");
assertEq(_idle, _totalIdle, "!totalIdle");
assertEq(_totalAssets, _totalDebt + _totalIdle, "!Added");
}

function airdrop(ERC20 _asset, address _to, uint256 _amount) public {
uint256 balanceBefore = _asset.balanceOf(_to);
deal(address(_asset), _to, balanceBefore + _amount);
}

function setDragonRouter(address _newDragonRouter) public {
vm.prank(management);
ITokenizedStrategy(address(strategy)).setDragonRouter(_newDragonRouter);

// Fast forward past the full DRAGON_ROUTER_COOLDOWN (14 days).
skip(14 days);

// Anyone can finalize after cooldown.
ITokenizedStrategy(address(strategy)).finalizeDragonRouterChange();
}

function setEnableBurning(bool _enableBurning) public {
vm.prank(management);
(bool success, ) = address(strategy).call(abi.encodeWithSignature("setEnableBurning(bool)", _enableBurning));
require(success, "setEnableBurning failed");
}

function reportAndDisableBurning() public {
vm.prank(management);
(bool success, ) = address(strategy).call(abi.encodeWithSignature("reportAndDisableBurning()"));
require(success, "reportAndDisableBurning failed");
}
}

src/strategies/yieldDonating/YieldDonatingStrategyFactory.sol

Complete file — src/strategies/yieldDonating/YieldDonatingStrategyFactory.sol

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

import {YieldDonatingStrategy} from "./YieldDonatingStrategy.sol";
import {IStrategyInterface} from "../../interfaces/IStrategyInterface.sol";

contract YieldDonatingStrategyFactory {
event NewStrategy(address indexed strategy, address indexed asset);

address public immutable emergencyAdmin;
address public immutable tokenizedStrategyAddress;

address public management;
address public donationAddress;
address public keeper;
bool public enableBurning = true;

/// @notice Track the latest deployment for each asset.
mapping(address => address) public deployments;

constructor(
address _management,
address _donationAddress,
address _keeper,
address _emergencyAdmin,
address _tokenizedStrategyAddress
) {
require(_tokenizedStrategyAddress != address(0), "zero tokenized strategy");

management = _management;
donationAddress = _donationAddress;
keeper = _keeper;
emergencyAdmin = _emergencyAdmin;
tokenizedStrategyAddress = _tokenizedStrategyAddress;
}

/**
* @notice Deploy a new Aave-backed YieldDonating strategy.
* @param _addressesProvider Aave v3 PoolAddressesProvider.
* @param _rewardsController Aave v3 RewardsController.
* @param _asset The underlying asset for the strategy.
* @param _name The name for the strategy.
* @param _symbol The symbol for the strategy shares.
* @return The address of the new strategy.
*/
function newStrategy(
address _addressesProvider,
address _rewardsController,
address _asset,
string calldata _name,
string calldata _symbol
) external virtual returns (address) {
IStrategyInterface _newStrategy = IStrategyInterface(
address(
new YieldDonatingStrategy(
_addressesProvider,
_rewardsController,
_asset,
_name,
_symbol,
management,
keeper,
emergencyAdmin,
donationAddress,
enableBurning,
tokenizedStrategyAddress
)
)
);

emit NewStrategy(address(_newStrategy), _asset);

deployments[_asset] = address(_newStrategy);
return address(_newStrategy);
}

function setAddresses(address _management, address _donationAddress, address _keeper) external {
require(msg.sender == management, "!management");
management = _management;
donationAddress = _donationAddress;
keeper = _keeper;
}

function setEnableBurning(bool _enableBurning) external {
require(msg.sender == management, "!management");
enableBurning = _enableBurning;
}

function isDeployedStrategy(address _strategy) external view returns (bool) {
address _asset = IStrategyInterface(_strategy).asset();
return deployments[_asset] == _strategy;
}
}

src/test/yieldDonating/YieldDonatingFunctionSignature.t.sol

Complete file — src/test/yieldDonating/YieldDonatingFunctionSignature.t.sol

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

import "forge-std/console2.sol";
import {YieldDonatingSetup as Setup, ERC20, IStrategyInterface, ITokenizedStrategy} from "./YieldDonatingSetup.sol";

contract YieldDonatingFunctionSignatureTest is Setup {
function setUp() public virtual override {
super.setUp();
}

// This test should not be overridden and checks that
// no function signature collisions occurred from the custom functions.
// Does not check functions that are strategy dependent and will be checked in other tests.
function test_functionCollisions() public {
uint256 wad = 1e18;
vm.expectRevert("initialized");
ITokenizedStrategy(address(strategy)).initialize(
address(asset),
"name",
"SYM",
management,
keeper,
emergencyAdmin,
dragonRouter,
true
);

// Check view functions.
assertEq(strategy.convertToAssets(wad), wad, "convert to assets");
assertEq(strategy.convertToShares(wad), wad - 1_000, "convert to shares includes minimum liquidity");
assertEq(strategy.previewDeposit(wad), wad - 1_000, "preview deposit includes minimum liquidity");
assertEq(strategy.previewMint(wad), wad + 1_000, "preview mint includes minimum liquidity");
assertEq(strategy.previewWithdraw(wad), wad - 1_000, "preview withdraw includes minimum liquidity");
assertEq(strategy.previewRedeem(wad), wad, "preview redeem");
assertEq(strategy.totalAssets(), 0, "total assets");
assertEq(strategy.totalSupply(), 0, "total supply");
assertEq(strategy.asset(), address(asset), "asset");
assertEq(strategy.apiVersion(), "1.1.0", "api");
assertGt(strategy.lastReport(), 0, "last report");
assertEq(strategy.pricePerShare(), 10 ** asset.decimals(), "pps");
assertTrue(!strategy.isShutdown());
assertEq(strategy.symbol(), "aYDS", "symbol");
assertEq(strategy.decimals(), asset.decimals(), "decimals");

// Assure modifiers are working.
vm.startPrank(user);
vm.expectRevert("!management");
strategy.setPendingManagement(user);
vm.expectRevert("!pending");
strategy.acceptManagement();
vm.expectRevert("!management");
strategy.setKeeper(user);
vm.expectRevert("!management");
strategy.setEmergencyAdmin(user);
vm.stopPrank();

uint256 depositAmount = 100 * 10 ** decimals;
mintAndDepositIntoStrategy(strategy, user, depositAmount);
assertEq(strategy.balanceOf(user), depositAmount, "balance");

vm.prank(user);
strategy.transfer(keeper, depositAmount);
assertEq(strategy.balanceOf(user), 0, "second balance");
assertEq(strategy.balanceOf(keeper), depositAmount, "keeper balance");
assertEq(strategy.allowance(keeper, user), 0, "allowance");

vm.prank(keeper);
assertTrue(strategy.approve(user, depositAmount), "approval");
assertEq(strategy.allowance(keeper, user), depositAmount, "second allowance");

vm.prank(user);
assertTrue(strategy.transferFrom(keeper, user, depositAmount), "transfer from");
assertEq(strategy.balanceOf(user), depositAmount, "second balance");
assertEq(strategy.balanceOf(keeper), 0, "keeper balance");
}
}

script/Deploy.s.sol

Complete file — script/Deploy.s.sol

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

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

contract DeployYDS is Script {
function run() external returns (YieldDonatingStrategy strategy) {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");

address addressesProvider = vm.envAddress("AAVE_ADDRESSES_PROVIDER");
address rewardsController = vm.envAddress("AAVE_REWARDS_CONTROLLER");
address asset = vm.envAddress("TEST_ASSET_ADDRESS");
address management = vm.envAddress("MANAGEMENT");
address keeper = vm.envAddress("KEEPER");
address emergencyAdmin = vm.envAddress("EMERGENCY_ADMIN");
address donationAddress = vm.envAddress("DONATION_ADDRESS");
address tokenizedStrategy = vm.envAddress("TOKENIZED_STRATEGY");

vm.startBroadcast(deployerPrivateKey);

strategy = new YieldDonatingStrategy(
addressesProvider,
rewardsController,
asset,
"Aave V3 YDS USDC",
"ydsUSDC",
management,
keeper,
emergencyAdmin,
donationAddress,
true,
tokenizedStrategy
);

vm.stopBroadcast();
}
}

foundry.toml lint compatibility

Patch — add this section when forge build compiles but Forge >= 1.5 fails during lint with unresolved src/... imports from dependencies/octant-v2-core

[lint]
lint_on_build = false

.env (complete example)

Complete file — .env

# Asset for local fork testing (USDC on mainnet)
TEST_ASSET_ADDRESS=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48

# Mainnet fork RPC; use an archive-capable endpoint for fork tests
ETH_RPC_URL=https://mainnet.infura.io/v3/YOUR_INFURA_API_KEY

# Aave v3 PoolAddressesProvider on Ethereum mainnet
AAVE_ADDRESSES_PROVIDER=0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e

# Aave v3 RewardsController on Ethereum mainnet
AAVE_REWARDS_CONTROLLER=0x8164Cc65827dcFe994AB23944CBC90e0aa80bFcb

# Only needed when running script/Deploy.s.sol.
# These dummy role addresses are fine for local simulation; replace them for any real deployment.
PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # Anvil default account 0; replace before any live broadcast
MANAGEMENT=0x0000000000000000000000000000000000000101
KEEPER=0x0000000000000000000000000000000000000102
EMERGENCY_ADMIN=0x0000000000000000000000000000000000000103
DONATION_ADDRESS=0x0000000000000000000000000000000000000104

# YieldDonatingTokenizedStrategy shared implementation on Ethereum mainnet. See /docs/developers/getting_started/deployed-addresses for the canonical list.
TOKENIZED_STRATEGY=0xE8797A98710518A6973Cc8612f98154EECF2C711

Verification commands

Command — full verification sequence

forge build
make test-contract contract=YieldDonatingFunctionSignatureTest
make test-contract contract=YieldDonatingOperationTest
make test-contract contract=YieldDonatingShutdownTest
make test