This content originally appeared on DEV Community and was authored by N DIVIJ
Introduction
CanopySplit is a multi-layer DeFi protocol that transforms idle capital into climate impact. Users deposit WETH, earn yield through Aave v3, and automatically split 100% of profits among climate recipients—all while keeping their principal withdrawable at any time.
This technical deep-dive covers:
- Octant V2 Yield Donating Strategy architecture
- ERC-4626 vault integration with Aave v3
- Epoch-based donation splitting mechanics
- Custom Uniswap v4 hook for swap fee donations
- Production deployment challenges on Sepolia
Live on Sepolia:
- Strategy:
0x0D1d8AE2dD0e4B06ca0Ef2949150eb021cAf6Ce9 - Splitter:
0xda5fA1c26Ec29497C2B103B385569286B30EC248 - Asset (WETH):
0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c
Architecture Overview
┌─────────────┐
│ User Wallet │
└──────┬──────┘
│ deposit WETH
▼
┌──────────────────────────────┐
│ Aave4626YieldDonatingStrategy│ (ERC-4626 vault)
│ - Deposits to Aave v3 │
│ - Mints shares to users │
│ - Reports profit → splitter │
└──────────────┬───────────────┘
│ mint donation shares
▼
┌──────────────────────────────┐
│ TriSplitDonationSplitter │
│ - Holds strategy shares │
│ - Epoch-based allocation │
│ - 3 climate recipients │
└──────┬───────────────────────┘
│ distribute()
▼
┌─────────────────────────────────┐
│ Recipients (50% / 30% / 20%) │
│ - Planters │
│ - MRV (Monitoring/Verification) │
│ - Maintenance │
└─────────────────────────────────┘
Part 1: Octant V2 Yield Donating Strategy
The Core Concept
Octant V2 introduces Yield Donating Strategies (YDS), which extend ERC-4626 with donation mechanics:
interface IYieldDonatingStrategy is IERC4626 {
function report() external returns (uint256 profit, uint256 loss);
function setDonationSplitter(address splitter) external;
}
Key innovation: When report() is called:
- Calculate
profit = currentAssets - lastRecordedAssets - If profit > 0: Mint shares to splitter (not to users!)
- If loss > 0: Burn donation shares first, protecting user capital
This means:
- Users keep 100% of principal
- Donations absorb losses first
- No performance fees
Implementation: Aave4626YieldDonatingStrategy
We built an ERC-4626 wrapper around Aave v3:
contract Aave4626YieldDonatingStrategy is YieldDonatingTokenizedStrategy {
IERC4626 public immutable vault; // ATokenVault
constructor(
IERC20 asset_,
IERC4626 vault_,
string memory name_,
address management_,
address keeper_
) YieldDonatingTokenizedStrategy(asset_, name_, management_, keeper_) {
vault = vault_;
asset_.forceApprove(address(vault_), type(uint256).max);
}
function totalAssets() public view override returns (uint256) {
uint256 shares = vault.balanceOf(address(this));
return vault.convertToAssets(shares);
}
function _deployFunds(uint256 amount) internal override {
vault.deposit(amount, address(this));
}
function _freeFunds(uint256 amount) internal override {
vault.withdraw(amount, address(this), address(this));
}
}
Why ERC-4626?
- Composability: Any ERC-4626 vault can plug in
-
Non-rebasing: Fixed shares, yield in
convertToAssets() - Safety: Vault enforces supply caps and liquidity checks
The report() Flow
function report() external onlyKeepers returns (uint256 profit, uint256 loss) {
uint256 currentAssets = totalAssets();
uint256 lastAssets = lastRecordedAssets;
if (currentAssets > lastAssets) {
profit = currentAssets - lastAssets;
// Mint shares to splitter (donation\!)
uint256 sharesToMint = convertToShares(profit);
_mint(donationSplitter, sharesToMint);
emit Reported(profit, 0);
} else if (currentAssets < lastAssets) {
loss = lastAssets - currentAssets;
// Burn donation shares first
uint256 splitterShares = balanceOf(donationSplitter);
uint256 splitterAssets = convertToAssets(splitterShares);
if (splitterAssets >= loss) {
uint256 sharesToBurn = convertToShares(loss);
_burn(donationSplitter, sharesToBurn);
} else {
_burn(donationSplitter, splitterShares);
// Remaining loss socialized across all shares
}
emit Reported(0, loss);
}
lastRecordedAssets = totalAssets();
}
Critical insight: Minting shares (instead of transferring assets) means donations continue earning yield until distributed.
Part 2: Aave v3 Integration
ATokenVault: ERC-4626 Wrapper for Aave
contract ATokenVault is ERC4626, Ownable {
IPool public immutable aavePool;
IERC20 public immutable aToken;
constructor(
IERC20 asset_,
IPoolAddressesProvider provider
) ERC4626(asset_) ERC20("Aave WETH Vault", "aWETHv") {
aavePool = IPool(provider.getPool());
aToken = IERC20(aavePool.getReserveData(address(asset_)).aTokenAddress);
}
function totalAssets() public view override returns (uint256) {
return aToken.balanceOf(address(this)); // aTokens auto-accrue
}
function _deposit(address caller, address receiver, uint256 assets, uint256 shares)
internal override
{
SafeERC20.safeTransferFrom(asset(), caller, address(this), assets);
SafeERC20.forceApprove(asset(), address(aavePool), assets);
aavePool.supply(address(asset()), assets, address(this), 0);
_mint(receiver, shares);
emit Deposit(caller, receiver, assets, shares);
}
function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares)
internal override
{
if (caller \!= owner) {
_spendAllowance(owner, caller, shares);
}
_burn(owner, shares);
aavePool.withdraw(address(asset()), assets, receiver);
emit Withdraw(caller, receiver, owner, assets, shares);
}
}
Deployed on Sepolia:
- Vault:
0x6938238e57CBe1b4B51Eb3B51389cEf8d3a88521 - Asset: WETH (
0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c) - Aave Provider:
0x012bAC54348C0E635dCAc9D5FB99f06F24136C9A
Part 3: TriSplit Donation Splitter
Epoch-Based Allocation
struct Recipient {
address addr;
string role;
}
struct Policy {
uint256 epoch;
uint256[3] basisPoints; // [5000, 3000, 2000] = 50%, 30%, 20%
}
contract TriSplitDonationSplitter {
Recipient[3] public recipients;
Policy public currentPolicy;
Policy public upcomingPolicy;
IERC4626 public vault;
function distribute() external {
uint256 shares = vault.balanceOf(address(this));
uint256 assets = vault.redeem(shares, address(this), address(this));
uint256[3] memory amounts;
for (uint256 i = 0; i < 3; i++) {
amounts[i] = (assets * currentPolicy.basisPoints[i]) / 10_000;
IERC20(vault.asset()).safeTransfer(recipients[i].addr, amounts[i]);
}
emit Distributed(currentPolicy.epoch, amounts, recipients);
}
function rollEpoch() external onlyOwner {
currentPolicy = upcomingPolicy;
upcomingPolicy.epoch++;
emit EpochRolled(currentPolicy.epoch);
}
function setUpcomingPolicy(uint256[3] calldata bps) external onlyOwner {
require(bps[0] + bps[1] + bps[2] == 10_000, "Must sum to 100%");
upcomingPolicy.basisPoints = bps;
emit UpcomingPolicySet(upcomingPolicy.epoch, bps);
}
}
Why epochs?
- Recipients know their allocation in advance
- Owner can adjust future weights without disrupting current distributions
- Full on-chain transparency via events
Current allocation:
- Planters: 50% (
0xF9b2eFCAcc1B93c1bd7F898d0a8c4b34aBD78E53) - MRV: 30% (
0x9261432cab3c0F83E86fa6e41E4a88dA06E7ecc6) - Maintenance: 20% (
0x89C13e8e5a81E775160322df9d7869893926A8Cc)
Part 4: Uniswap v4 Hook (Local PoC)
Extending Donations to Swap Fees
contract TriSplitDonationHook is BaseHook {
struct HookConfig {
address donationTarget;
uint256 donationBps; // 100 = 1%
}
mapping(PoolId => HookConfig) public configs;
function afterSwap(
address,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
BalanceDelta delta,
bytes calldata
) external override returns (bytes4, int128) {
PoolId poolId = key.toId();
HookConfig memory config = configs[poolId];
if (config.donationTarget \!= address(0)) {
Currency currency = params.zeroForOne ? key.currency0 : key.currency1;
uint256 amountIn = params.zeroForOne
? uint256(int256(-delta.amount0()))
: uint256(int256(-delta.amount1()));
uint256 donation = (amountIn * config.donationBps) / 10_000;
poolManager.take(currency, config.donationTarget, donation);
emit DonationExecuted(poolId, donation, config.donationTarget);
}
return (this.afterSwap.selector, 0);
}
}
Testing on Anvil:
# Deploy v4 infrastructure
forge script script/00_DeployV4.s.sol --broadcast --rpc-url http://127.0.0.1:8545
# Deploy hook
forge script script/00_DeployHook.s.sol --broadcast --rpc-url http://127.0.0.1:8545
# Initialize pool
forge script script/05_EnsureInitialized.s.sol --broadcast --rpc-url http://127.0.0.1:8545
# Add liquidity
forge script script/02_AddLiquidity.s.sol --broadcast --rpc-url http://127.0.0.1:8545
# Configure hook (1% donation)
forge script script/04_SetHookConfig.s.sol --broadcast --rpc-url http://127.0.0.1:8545
# Execute swap (triggers donation\!)
forge script script/03_Swap.s.sol --broadcast --rpc-url http://127.0.0.1:8545
Part 5: Frontend Architecture
Dynamic Asset Detection
Instead of hardcoding USDC/WETH, we read the asset dynamically:
export function useAssetInfo(strategyAddress: Address) {
const { data: assetAddress } = useReadContract({
address: strategyAddress,
abi: StrategyABI,
functionName: 'asset',
});
const { data: symbol } = useReadContract({
address: assetAddress,
abi: ERC20ABI,
functionName: 'symbol',
});
const { data: decimals } = useReadContract({
address: assetAddress,
abi: ERC20ABI,
functionName: 'decimals',
});
return { assetAddress, symbol, decimals };
}
Benefits:
- Works with any ERC-20 asset
- No frontend redeployment needed
- Correct decimal formatting
Role-Based Access Control
function AppPage() {
const { address } = useAccount();
const { data: management } = useReadContract({
address: ADDRS.sepolia.strategy,
abi: StrategyABI,
functionName: 'management',
});
const isManagement = address === management;
return (
<>
<DepositForm />
<WithdrawForm />
{isManagement && (
<Button onClick={handleReport}>Report Profit/Loss</Button>
)}
</>
);
}
Event Tracking
export function useLifetimeDonations(splitterAddress: Address) {
const { data: logs } = useContractEvent({
address: splitterAddress,
abi: SplitterABI,
eventName: 'Distributed',
fromBlock: 0n,
});
const totalDonated = useMemo(() => {
if (\!logs) return 0n;
return logs.reduce((sum, log) => {
const [epoch, amounts] = log.args;
return sum + amounts.reduce((a, b) => a + b, 0n);
}, 0n);
}, [logs]);
return { totalDonated, eventCount: logs?.length ?? 0 };
}
Real-World Challenges
Challenge 1: Sepolia Gas Cap (16.7M)
Problem: Contract creation bytecode exceeded Sepolia's per-tx gas limit.
Solution:
# foundry.toml
optimizer = true
optimizer_runs = 1
via_ir = false
bytecode_hash = "none"
cbor_metadata = false
forge clean && forge build
forge create YieldDonatingTokenizedStrategy --gas-limit 0xFD0000 --legacy
Challenge 2: WETH Pivot
Problem: Aave v3 Sepolia USDC had supply cap issues.
Solution: Switched to WETH (18 decimals, no supply cap).
export USDC_UNDERLYING=0xC558DBdd856501FCd9aaF1E62eae57A9F0629a3c # WETH\!
Challenge 3: Permit2 Allowance
Problem: Swaps reverted with InsufficientAllowance.
Root cause: Granted allowance to PoolManager, but Router calls transferFrom().
Solution:
// Grant allowance to Router, not PoolManager
permit2.approve(address(token0), address(swapRouter), type(uint160).max, type(uint48).max);
Challenge 4: Token Decimals
Problem: USDC (6 decimals) vs WETH (18 decimals) mismatches.
Solution: Created patching script for local testing:
FakeUSDC fake = new FakeUSDC(); // decimals() = 6
vm.etch(token0Addr, address(fake).code);
Key Innovations
- Epoch-Based Splits: Unlike single-recipient YDS, we route to 3 recipients with dynamic weights
- Multi-Adapter Support: Idle, Aave v3, and ERC-4626 strategies all feed the same splitter
- Dynamic Asset Detection: Frontend works with any ERC-20 without hardcoding
- Hooks Integration: Extended YDS concept to Uniswap v4 swap fees
- Loss Protection: Donation shares burn first, protecting user capital
Conclusion
CanopySplit demonstrates how Octant V2's Yield Donating Strategies can be extended with:
- Multi-protocol integrations (Aave v3, Uniswap v4)
- Flexible donation mechanics (epoch-based splits, loss protection)
- Production-ready UX (dynamic asset detection, role-based access)
The architecture is composable, transparent, and gas-efficient. All code is open-source and ready for judges to test.
Key takeaways for builders:
- ERC-4626 is the perfect abstraction for yield strategies
- Epoch-based policies provide flexibility without disrupting current operations
- Dynamic asset detection makes frontends future-proof
- Testnet gas limits are real—optimize aggressively
- Always verify contract addresses exist on-chain before trusting deployment logs
Resources
- GitHub: https://github.com/N-45div/CanopySplit
-
Sepolia Strategy:
0x0D1d8AE2dD0e4B06ca0Ef2949150eb021cAf6Ce9 -
Sepolia Splitter:
0xda5fA1c26Ec29497C2B103B385569286B30EC248 - Octant V2 Docs: docs.octant.app
Built with ❤️ for the Octant V2 hackathon. Questions? Reach out on Twitter @godlovesu_n
This content originally appeared on DEV Community and was authored by N DIVIJ
N DIVIJ | Sciencx (2025-11-09T22:02:34+00:00) Building CanopySplit: A Deep Dive into Octant V2 Yield Donating Strategies. Retrieved from https://www.scien.cx/2025/11/09/building-canopysplit-a-deep-dive-into-octant-v2-yield-donating-strategies/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.