Building CanopySplit: A Deep Dive into Octant V2 Yield Donating Strategies

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 thei…


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:

  1. Calculate profit = currentAssets - lastRecordedAssets
  2. If profit > 0: Mint shares to splitter (not to users!)
  3. 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

  1. Epoch-Based Splits: Unlike single-recipient YDS, we route to 3 recipients with dynamic weights
  2. Multi-Adapter Support: Idle, Aave v3, and ERC-4626 strategies all feed the same splitter
  3. Dynamic Asset Detection: Frontend works with any ERC-20 without hardcoding
  4. Hooks Integration: Extended YDS concept to Uniswap v4 swap fees
  5. 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:

  1. ERC-4626 is the perfect abstraction for yield strategies
  2. Epoch-based policies provide flexibility without disrupting current operations
  3. Dynamic asset detection makes frontends future-proof
  4. Testnet gas limits are real—optimize aggressively
  5. Always verify contract addresses exist on-chain before trusting deployment logs

Resources

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


Print Share Comment Cite Upload Translate Updates
APA

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/

MLA
" » Building CanopySplit: A Deep Dive into Octant V2 Yield Donating Strategies." N DIVIJ | Sciencx - Sunday November 9, 2025, https://www.scien.cx/2025/11/09/building-canopysplit-a-deep-dive-into-octant-v2-yield-donating-strategies/
HARVARD
N DIVIJ | Sciencx Sunday November 9, 2025 » Building CanopySplit: A Deep Dive into Octant V2 Yield Donating Strategies., viewed ,<https://www.scien.cx/2025/11/09/building-canopysplit-a-deep-dive-into-octant-v2-yield-donating-strategies/>
VANCOUVER
N DIVIJ | Sciencx - » Building CanopySplit: A Deep Dive into Octant V2 Yield Donating Strategies. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/11/09/building-canopysplit-a-deep-dive-into-octant-v2-yield-donating-strategies/
CHICAGO
" » Building CanopySplit: A Deep Dive into Octant V2 Yield Donating Strategies." N DIVIJ | Sciencx - Accessed . https://www.scien.cx/2025/11/09/building-canopysplit-a-deep-dive-into-octant-v2-yield-donating-strategies/
IEEE
" » Building CanopySplit: A Deep Dive into Octant V2 Yield Donating Strategies." N DIVIJ | Sciencx [Online]. Available: https://www.scien.cx/2025/11/09/building-canopysplit-a-deep-dive-into-octant-v2-yield-donating-strategies/. [Accessed: ]
rf:citation
» Building CanopySplit: A Deep Dive into Octant V2 Yield Donating Strategies | N DIVIJ | Sciencx | 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.

You must be logged in to translate posts. Please log in or register.