Building a Tokenized Yield Strategy Vault on Kaia with ERC4626

Building a Tokenized Yield Strategy Vault on Kaia with ERC4626

Building a Tokenized Yield Strategy Vault on Kaia with ERC4626

Introduction

One of the most transformative innovations in decentralized finance (DeFi) is on-chain yield, the ability to earn income directly from blockchain activity without relying on traditional intermediaries. Over the last decade, DeFi has evolved from simple DEX trading and yield farming into a layered yield economy.

Across almost every protocol, a familiar pattern emerged: users deposit funds, receive a receipt token representing their share, the protocol deploys those funds into one or more yield strategies, and users later redeem their receipt tokens along with any accrued returns.

In the early days, each protocol implemented this pattern differently. Lending markets, yield aggregators, staking systems, and auto-compounding vaults all built custom logic, making integration complex and limiting composability. ERC-4626 solves this by providing a standardized interface for tokenized vaults, making yield-bearing vaults easier to build, integrate, and extend across the entire DeFi ecosystem.

This guide introduces you to the Tokenized Vault Standard (ERC-4626) and how it streamlines interactions with yield-generating protocols. You will learn how ERC-4626 vaults work, how yield-bearing mechanisms are implemented under the hood, and how to extend your own vault with strategies such as liquidity provisioning on DGswap.

Prerequisites

Understanding a Vault + ERC4626

What is a vault?

In DeFi, a vault is more than a storage mechanism for crypto assets. Vaults act as smart-contract-based asset management tools that allow users to automatically deploy their assets into yield-generating strategies such as lending, borrowing, liquidity provisioning, staking, and more.

With vaults, users interact with a single contract that handles both simple operations like deposits and withdrawals, and complex tasks such as executing strategies, harvesting rewards, and compounding yields. Through this, vaults simplify the process of earning onchain yield and allow users to benefit from optimized strategies without manually managing these strategies.

Why ERC4626

As vaults became foundational to DeFi, protocols implemented them with different architectures, interfaces, and accounting models. Each vault had its own custom logic for deposits, withdrawals, share accounting, and yield distribution.

This lack of consistency created significant friction for integrators. Any project that wanted to route idle funds into another protocol’s vault often had to build custom connectors, maintain one-off integrations, and navigate differences between implementations. This not only slowed development but also increased the surface area for potential errors, inconsistencies, or security issues.

To address this fragmentation, enter ERC-4626, the Tokenized Vault Standard. ERC-4626 provides a unified interface for building and interacting with yield-bearing vaults. With a standard in place, developers only need to support one interface rather than adapting to dozens of incompatible ones. This significantly improves composability, simplifies integrations across the DeFi stack, and allows builders to focus on strategy innovation rather than rebuilding foundational vault mechanics.

How it works

Yield-bearing vaults allow users to deposit ERC-20 tokens into a shared pool and receive receipt tokens that represent their proportional ownership. These receipt tokens track a user’s share of the vault and can later be redeemed for the original deposit plus any accumulated yield.

More concretely, a vault typically operates in the following way:

  • Deposit: Users deposit ERC-20 assets into the vault and receive vault-specific tokens (vTokens) that represent their shares.
  • Strategy Execution: The vault’s smart contract deploys the pooled assets into predefined yield strategies, such as lending markets, liquidity pools, staking systems, or more complex multi-protocol strategies.
  • Yield Harvesting: As the strategy generates yield, the vault periodically harvests and reinvests those returns, compounding growth and increasing the value of each share.
  • Withdrawal: Users can redeem their vTokens at any time, receiving their share of the vault’s total assets — this includes their initial capital plus any yield earned over time.

Use cases in DeFi

The introduction of the ERC-4626 standard has expanded the possibilities for building creative financial products in DeFi. While many of these use cases existed before, developers can now implement them using a unified, standardized vault interface — making strategies easier to build, combine, and integrate across protocols. With ERC-4626, it becomes simpler to design vaults that match different risk profiles, investment objectives, and asset types.

Below are some of the common use cases that have emerged across the ecosystem:

Yield Farming Vaults: These vaults aim to maximize returns by deploying user funds across multiple yield-generating strategies. They automatically route capital between lending markets, liquidity pools, and farming opportunities to capture the highest available yield. A well-known example is Yearn Finance, which allocates user deposits across protocols such as Aave, Compound, and Curve to optimize performance.

LP and AMM Vaults: These vaults manage liquidity positions in Automated Market Maker (AMM) pools. They automate tasks like harvesting trading fees, rebalancing concentrated liquidity, and managing impermanent loss exposure. This helps liquidity providers maintain efficient positions without constant manual adjustments.

Stablecoin Vaults: Stablecoin-focused vaults prioritize strategies that deliver predictable, low-volatility returns. They typically lend stablecoins on lending markets or participate in stablecoin-based yield farms, making them attractive for users seeking more stable, risk-aware yield. Examples of Stablecoin Vaults on Kaia include: Spoon Finance and SuperEarn.

Liquid Staking Vaults:
These vaults enable users to stake native blockchain tokens (such as KAIA) while receiving a liquid token that represents their staked position. This liquid staking token earns staking rewards and can also be used across other DeFi protocols, unlocking both yield and liquidity.

Leveraged Yield Farming Vaults:
These vaults borrow additional capital to amplify yield farming positions, increasing potential returns. While leveraged strategies can significantly boost yields, they also carry higher risk, including the possibility of liquidation when markets move unfavorably.

Getting Started

In this guide, we will build a yield-bearing vault that earns returns by providing liquidity on a DEX within the Kaia ecosystem. To achieve this, we will fork Kaia Mainnet into a local development environment and simulate realistic interactions using Foundry.

Creating a Standalone Yield Strategy Vault with ERC4626

In this section, we will develop a standalone yield strategy vault using the ERC4626 Tokenized Vault Standard. The contract will allow us to supply liquidity to a DGswap pool and capture the trading fees generated within the underlying AMM.

Before we begin implementing the vault, it is important to understand the core functions defined by ERC4626 and how they shape the lifecycle of deposits, withdrawals, and strategy execution.

The Execution Functions

The ERC4626 standard defines four core execution functions that control how assets move into and out of a vault.

Asset Deposits

Deposits can be performed through two functions.

The deposit() function allows you to specify the amount of assets you want to contribute, and the vault automatically calculates the number of shares to mint.

The mint() function works in the opposite direction. You specify the number of shares you want, and the vault determines how many ERC20 assets must be transferred from your account.

Asset Redemption

Redemption also has two entry points.

The withdraw() function lets you specify the amount of underlying assets you want to retrieve, and the vault computes how many shares must be burned.

The redeem() function allows you to define exactly how many shares you want to burn. The vault then calculates how many underlying assets you will receive in return.

The Converter Functions

ERC4626 provides two converter functions that translate between shares and assets.

The convertToShares() function calculates how many shares correspond to a given asset amount.
The convertToAssets() function calculates how many assets correspond to a given number of shares.

These functions are essential for accurate accounting and help simplify integrations that need predictable conversions.

The Asset Functions

Two asset-related view functions describe the vault’s underlying asset.

The asset() function returns the address of the ERC20 token used as the vault’s underlying asset, such as the LP’s contract address in the case of this guide.

The totalAssets() function returns the total amount of the underlying asset currently managed by the vault.

The Max Functions

The ERC4626 standard defines limits for deposit, mint, withdraw, and redeem operations.

The maxDeposit() function returns the maximum amount of assets a user can pass to the deposit function.

The maxMint() function returns the maximum number of shares a user is allowed to mint.
The maxWithdraw() function returns the maximum amount of assets a user can withdraw based on their share balance.
The maxRedeem() function returns the maximum number of shares a user can redeem.

The Preview Functions

ERC4626 provides four preview functions: previewDepositpreviewMintpreviewWithdraw, and previewRedeem. These functions allow on-chain or off-chain clients to simulate the result of a given action at the current block using the vault’s real-time accounting. Previews make it easier to estimate outcomes without modifying state.

Implementation Example

For this guide, we will use a standalone yield strategy vault contract that allows users to earn yield by depositing into a DGswap liquidity pool. The contract implements ERC4626 under the hood and handles all the logic for converting user deposits into LP positions and accruing trading fees over time.

/**
 * @title DgswapV2ERC4626
 * @author oxpampam
 * @notice Educational implementation of an ERC4626 wrapper for DGswap V2 LP positions
 * 
 * ⚠️ EDUCATIONAL PURPOSE ONLY - NOT PRODUCTION READY ⚠️
 * 
 * @dev This is a non-standard ERC4626 implementation that accepts two tokens instead of one.
 * Instead of: deposit(USDT) → get shares
 * This does: deposit(token0 + token1) → get shares
 * 
 * PRODUCTION CONSIDERATIONS NOT IMPLEMENTED:
 * - No handling of first 1000 LP tokens lock (MINIMUM_LIQUIDITY)
 * - No verification of actual LP tokens received
 * - No emergency pause mechanism
 * - No protection against sandwich attacks
 * - Simplified slippage model
 *
 */
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.23;
import {ERC20} from "solmate/tokens/ERC20.sol";
import {ERC4626} from "solmate/tokens/ERC4626.sol";
import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol";
import {
    SafeERC20
} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IUniswapV2Pair} from "./interfaces/IUniswapV2Pair.sol";
import {IUniswapV2Router} from "./interfaces/IUniswapV2Router.sol";
import {UniswapV2Library} from "./utils/UniswapV2Library.sol";
/// @title DGswapV2ERC4626
/// @notice Custom ERC4626 Wrapper for DGswapV2 Pools without swapping, accepting token0/token1 transfers
/// @dev WARNING: Change your assumption about asset/share in context of deposit/mint/redeem/withdraw
/// @notice Basic flow description:
/// @notice Vault (ERC4626) - totalAssets() == lpToken of DGswap Pool
/// @notice deposit(assets) -> assets == lpToken amount to receive
/// @notice - user needs to approve both A,B tokens in X,Y amounts (see getLiquidityAmounts / getAssetsAmounts
/// functions)
/// @notice - check is run if A,B covers requested Z amount of DGswapLP
/// @notice - deposit() safeTransfersFrom A,B to _min Z amount of DGswapLP
/// @notice withdraw() -> withdraws both A,B in accrued X+n,Y+n amounts, burns Z amount of DGswapLP (or Vault's LP, those
/// are 1:1)
/// @dev (USDT-ELDE LP/PAIR on KAIA)
contract DgswapV2ERC4626 is ERC4626 {
    /*//////////////////////////////////////////////////////////////
                        LIBRARIES USAGES
    //////////////////////////////////////////////////////////////*/
    using SafeERC20 for IERC20;
    using FixedPointMathLib for uint256;
    /*//////////////////////////////////////////////////////////////
                      IMMUTABLES & VARIABLES
    //////////////////////////////////////////////////////////////*/
    address public immutable manager;
    uint256 public slippage;
    uint256 public constant MIN_SLIPPAGE_FACTOR = 9000; // 90% = max 10% slippage
    uint256 public constant MAX_SLIPPAGE_FACTOR = 10000; // 100% = no slippage
    uint256 public constant SLIPPAGE_DENOMINATOR = 10000;
    IUniswapV2Pair public immutable pair;
    IUniswapV2Router public immutable router;
    IERC20 public token0;
    IERC20 public token1;
    /*//////////////////////////////////////////////////////////////
                            CONSTRUCTOR
    //////////////////////////////////////////////////////////////*/
    /// @param name_ ERC4626 name
    /// @param symbol_ ERC4626 symbol
    /// @param asset_ ERC4626 asset (LP Token)
    /// @param token0_ ERC20 token0
    /// @param token1_ ERC20 token1
    /// @param router_ DGswapV2Router
    /// @param pair_ DGswapV2Pair
    /// @param slippage_ slippage param
    constructor(
        string memory name_,
        string memory symbol_,
        ERC20 asset_,
        /// Pair address (to opti)
        IERC20 token0_,
        IERC20 token1_,
        IUniswapV2Router router_,
        IUniswapV2Pair pair_,
        /// Pair address (to opti)
        uint256 slippage_
    ) ERC4626(asset_, name_, symbol_) {
        manager = msg.sender;
        pair = pair_;
        router = router_;
        token0 = token0_;
        token1 = token1_;
        slippage = slippage_;
    }
/// @notice Update the slippage protection factor
/// @param slippageFactor_ The minimum percentage of tokens to receive (in basis points)
/// @dev slippageFactor_ = 9500 means we accept minimum 95% of optimal amount (5% slippage)
/// @dev slippageFactor_ = 9900 means we accept minimum 99% of optimal amount (1% slippage)
/// @dev slippageFactor_ = 9000 means we accept minimum 90% of optimal amount (10% slippage)
/// @dev Must be between 9000 (10% max slippage) and 10000 (0% slippage)
function setSlippage(uint256 slippageFactor_) external {
    require(msg.sender == manager, "Only manager can update slippage");
    require(
        slippageFactor_ >= MIN_SLIPPAGE_FACTOR && slippageFactor_ <= MAX_SLIPPAGE_FACTOR,
        "Slippage factor must be between 9000-10000 (max 10% slippage)"
    );
    slippage = slippageFactor_;
    
}
    /// @param amount_ amount of slippage
    function getMinimumAmount(uint256 amount_) internal view returns (uint256) {
        return (amount_ * slippage) / SLIPPAGE_DENOMINATOR;
    }
    /*//////////////////////////////////////////////////////////////
                          INTERNAL HOOKS LOGIC
    //////////////////////////////////////////////////////////////*/
    function beforeWithdraw(
        uint256 assets_,
        uint256 shares_
    ) internal override {
        (uint256 assets0, uint256 assets1) = getAssetsAmounts(assets_);
        asset.approve(address(router), assets_);
        /// temp implementation, we should call directly on a pair
        router.removeLiquidity(
            address(token0),
            address(token1),
            assets_,
            getMinimumAmount(assets0),
            getMinimumAmount(assets1),
            address(this),
            block.timestamp + 100
        );
    }
    function afterDeposit(uint256 assets_, uint256) internal override {
        (uint256 assets0, uint256 assets1) = getAssetsAmounts(assets_);
        /// temp should be more elegant.
        token0.approve(address(router), assets0);
        token1.approve(address(router), assets1);
        /// temp implementation, we should call directly on a pair
        router.addLiquidity(
            address(token0),
            address(token1),
            assets0,
            assets1,
            getMinimumAmount(assets0),
            getMinimumAmount(assets1),
            address(this),
            block.timestamp + 100
        );
    }
    /*//////////////////////////////////////////////////////////////
                        ERC4626 OVERRIDES
    //////////////////////////////////////////////////////////////*/
    /// @notice Deposit pre-calculated amount of token0/1 to get amount of DGswapLP (assets/getDGswapLpFromAssets_)
    /// @notice REQUIREMENT: Calculate amount of assets and have enough of assets0/1 to cover this amount for LP
    /// requested (slippage!)
    /// @param getDGswapLpFromAssets_ Assume caller called getAssetsAmounts() first to know amount of assets to approve to
    /// this contract
    /// @param receiver_ - Who will receive shares (Standard ERC4626)
    /// @return shares - Of this Vault (Standard ERC4626)
    function deposit(
        uint256 getDGswapLpFromAssets_,
        address receiver_
    ) public override returns (uint256 shares) {
        /// From 100 DGswapLP msg.sender gets N shares (of this Vault)
        require(
            (shares = previewDeposit(getDGswapLpFromAssets_)) != 0,
            "ZERO_SHARES"
        );
        /// Ideally, msg.sender should call this function beforehand to get correct "assets" amount
        (uint256 assets0, uint256 assets1) = getAssetsAmounts(
            getDGswapLpFromAssets_
        );
        /// Best if we approve exact amounts
        token0.safeTransferFrom(msg.sender, address(this), assets0);
        token1.safeTransferFrom(msg.sender, address(this), assets1);
        _mint(receiver_, shares);
        /// Custom assumption about assets changes assumptions about this event
        emit Deposit(msg.sender, receiver_, getDGswapLpFromAssets_, shares);
        afterDeposit(getDGswapLpFromAssets_, shares);
    }
    /// @notice Mint amount of shares of this Vault (1:1 with DGswapLP). Requires precalculating amount of assets to
    /// approve to this contract.
    /// @param sharesOfThisVault_ shares value == amount of Vault token (shares) to mint from requested lpToken. (1:1
    /// with lpToken).
    /// @param receiver_ == receiver of shares (Vault token)
    /// @return assets == amount of LPTOKEN minted (1:1 with sharesOfThisVault_ input)
    function mint(
        uint256 sharesOfThisVault_,
        address receiver_
    ) public override returns (uint256 assets) {
        assets = previewMint(sharesOfThisVault_);
        (uint256 assets0, uint256 assets1) = getAssetsAmounts(assets);
        token0.safeTransferFrom(msg.sender, address(this), assets0);
        token1.safeTransferFrom(msg.sender, address(this), assets1);
        _mint(receiver_, sharesOfThisVault_);
        /// Custom assumption about assets changes assumptions about this event
        emit Deposit(msg.sender, receiver_, assets, sharesOfThisVault_);
        afterDeposit(assets, sharesOfThisVault_);
    }
    /// @notice Withdraw amount of token0/1 from burning Vault shares (1:1 with DGswapLP). Ie. User wants to burn 100 DGswapLP
    /// (underlying) for N worth of token0/1
    /// @param assets_ - amount of DGswapLP to burn (calculate amount of expected token0/1 from helper functions)
    /// @param receiver_ - Who will receive shares (Standard ERC4626)
    /// @param owner_ - Who owns shares (Standard ERC4626)
    function withdraw(
        uint256 assets_, // amount of underlying asset (pool Lp) to withdraw
        address receiver_,
        address owner_
    ) public override returns (uint256 shares) {
        shares = previewWithdraw(assets_);
        (uint256 assets0, uint256 assets1) = getAssetsAmounts(assets_);
        if (msg.sender != owner_) {
            uint256 allowed = allowance[owner_][msg.sender];
            if (allowed != type(uint256).max) {
                allowance[owner_][msg.sender] = allowed - shares;
            }
        }
        beforeWithdraw(assets_, shares);
        _burn(owner_, shares);
        /// Custom assumption about assets changes assumptions about this event
        emit Withdraw(msg.sender, receiver_, owner_, assets_, shares);
        token0.safeTransfer(receiver_, assets0);
        token1.safeTransfer(receiver_, assets1);
    }
    /// @notice Redeem amount of Vault shares (1:1 with DGswapLP) for arbitrary amount of token0/1
    /// @param shares_ Amount of vault shares to burn
    /// @param receiver_ Address that will receive the underlying tokens
    /// @param owner_ Address that owns the shares being redeemed
    /// @return assets Amount of LP tokens that were redeemed (for ERC4626 compatibility)
    /// @dev This implementation differs from standard ERC4626 as it returns two tokens instead of one
    function redeem(
        uint256 shares_,
        address receiver_,
        address owner_
    ) public override returns (uint256 assets) {
        // handle allowance if caller is not the owner
        if (msg.sender != owner_) {
            uint256 allowed = allowance[owner_][msg.sender];
            if (allowed != type(uint256).max) {
                allowance[owner_][msg.sender] = allowed - shares_;
            }
        }
        // calculate how many LP tokens these shares represent
        require((assets = previewRedeem(shares_)) != 0, "ZERO_ASSETS");
        // burn the shares first (update state before external calls)
        _burn(owner_, shares_);
        // snapshot current token balances before removing liquidity
        uint256 balance0Before = token0.balanceOf(address(this));
        uint256 balance1Before = token1.balanceOf(address(this));
        // remove liquidity from the DEX (external call)
        // this will send token0 and token1 back to this contract
        beforeWithdraw(assets, shares_);
        // calculate actual amounts received from liquidity removal
        uint256 amount0 = token0.balanceOf(address(this)) - balance0Before;
        uint256 amount1 = token1.balanceOf(address(this)) - balance1Before;
        // transfer the tokens to the receiver
        token0.safeTransfer(receiver_, amount0);
        token1.safeTransfer(receiver_, amount1);
        // emit event after all state changes and transfers are complete
        emit Withdraw(msg.sender, receiver_, owner_, assets, shares_);
        // return the amount of LP tokens that were redeemed
        // Note: Users receive token0 + token1, not the LP tokens themselves
        return assets;
    }
    /// @notice for requested 100 DGswapLP tokens, how much tok0/1 we need to give?
    function getAssetsAmounts(
        uint256 poolLpAmount_
    ) public view returns (uint256 assets0, uint256 assets1) {
        /// get xy=k here, where x=ra0,y=ra1
        (uint256 reserveA, uint256 reserveB) = UniswapV2Library.getReserves(
            address(pair),
            address(token0),
            address(token1)
        );
        /// shares of dgswap pair contract
        uint256 pairSupply = pair.totalSupply();
        /// amount of token0 to provide to receive poolLpAmount_
        assets0 = (reserveA * poolLpAmount_) / pairSupply;
        /// amount of token1 to provide to receive poolLpAmount_
        assets1 = (reserveB * poolLpAmount_) / pairSupply;
    }
    /// @notice For requested N assets0 & N assets1, how much DGswapLP do we get?
    function getLiquidityAmountOutFor(
        uint256 assets0_,
        uint256 assets1_
    ) public view returns (uint256 poolLpAmount) {
        (uint256 reserveA, uint256 reserveB) = UniswapV2Library.getReserves(
            address(pair),
            address(token0),
            address(token1)
        );
        poolLpAmount = _min(
            ((assets0_ * pair.totalSupply()) / reserveA),
            (assets1_ * pair.totalSupply()) / reserveB
        );
    }
    /// @notice Pool's LP token on contract balance
    function totalAssets() public view override returns (uint256) {
        return asset.balanceOf(address(this));
    }
    function _min(uint256 x, uint256 y) internal pure returns (uint256 z) {
        z = x < y ? x : y;
    }
}

Code Overview

This vault wraps a DGswap V2 liquidity pool inside an ERC-4626 interface. Instead of treating an ERC-20 token as the vault’s base asset, the vault uses the DGswap LP token. That means the vault’s “assets” are not token0 or token1 directly; its assets are LP tokens that represent a claim on the AMM pool.

This is important because LP tokens are inherently yield-bearing. As trades occur in the pool, the reserves grow and every LP token becomes worth more underlying assets. By simply holding LP tokens, the vault implements a passive yield strategy.

The user never touches LP tokens directly. They deposit token0 and token1, and the vault handles all conversions and liquidity provisioning internally. ERC-4626 orchestrates this flow using its internal hooks.

How ERC-4626 Hooks Power the Strategy

Solmate’s ERC-4626 implementation exposes two internal lifecycle hooks, afterDeposit and beforeWithdraw, which allow a vault to run custom strategy logic immediately after shares are minted or just before they are burned, all without violating ERC-4626’s accounting guarantees.

In this vault, those hooks are used to execute the liquidity-provision strategy: once a user deposits, afterDeposit() converts the supplied token0 and token1 into DGswap LP tokens by calling the router’s addLiquidity(), while on withdrawal, beforeWithdraw() performs the inverse operation by removing liquidity and turning LP tokens back into their underlying components. These hooks serve as the core extension mechanism through which strategy behavior is injected, while all other responsibilities — share minting, burning, accounting, and ERC-20 compatibility — are reliably handled by the base ERC-4626 implementation.

Deposit Flow

When a user calls deposit or mint, the vault treats the provided assets value as the number of LP tokens the user wants the vault to create on their behalf. To achieve this, it first calculates the exact amounts of token0 and token1 required for that LP target using getAssetsAmounts(), then pulls those underlying tokens from the user and mints vault shares that represent their claim on the position.

Immediately afterward, control flows into afterDeposit(), where the vault approves the DGswap router and calls addLiquidity() with appropriate slippage safeguards to convert the supplied token0 and token1 into actual LP tokens held by the vault. Once this process completes, the vault’s totalAssets() increases to reflect its new LP balance, and the user holds newly minted shares whose value will naturally appreciate as LP fees accumulate within the underlying DGswap pool.

Withdrawal Flow

During withdraw or redeem, the process works in reverse. The vault first determines how many shares need to be burned for the requested amount of assets, and the ERC-4626 flow automatically triggers beforeWithdraw(). Inside beforeWithdraw(), the vault approves the router to spend the required LP tokens and then calls removeLiquidity() to convert those LP tokens back into token0 and token1. These underlying tokens remain in the vault until the shares are burned. Once beforeWithdraw() finishes, the vault burns the appropriate number of shares and transfers the resulting token0 and token1 amounts to the user. The ERC-4626 lifecycle ensures that this sequence is always executed in a consistent and correct accounting order.

Strategy LP Helper Functions

Because the strategy is based on LP positions, the vault includes two helper functions that handle token ratio calculations. The getAssetsAmounts(lpAmount) function determines how much token0 and token1 are needed to produce a specific amount of LP tokens, while getLiquidityAmountOutFor(amount0, amount1) calculates the maximum LP tokens that can be minted from given token amounts.

Both functions rely on Uniswap V2 style reserve mathematics, which keeps the vault’s behavior predictable for integrators and removes the need for users to manually estimate token ratios. In addition, the vault applies a configurable slippage parameter to ensure that addLiquidity and removeLiquidity operations respect minimum acceptable amounts and remain safe under changing pool conditions.

Lastly, this DGswap vault demonstrates how ERC-4626 can wrap sophisticated multi-asset positions while still presenting a simple, predictable API. Deposits become LP positions. Withdrawals remove LP. Yield accrues automatically.

If you want to build more advanced strategies on top of LP tokens (staking gauges, reward auto-compounding, liquidity rebalancing, hedged LP strategies), this vault is already the perfect foundation. All you need to do is plug your logic into the hooks or delegate it to a strategy contract.

Deploying and Interacting with the Yield Strategy Vault Contract

In this section, we will fork a live instance of Kaia Mainnet using Foundry and test the deposit and redeem flows of our yield strategy vault. By the end of this section, you will understand how to deposit into the vault, redeem your position, and observe yield generated by providing liquidity on DGswap.

To get you started quickly, the code sample is readily available at the DGswapV2ERC4626 example repository.

Clone the Repository

Begin by cloning the example project:

git clone https://github.com/ayo-klaytn/dgswapV2ERC4626-example.git

Testing the Smart Contract

Inside the cloned repository, you will find a complete test suite under: test/dgswapV2ERC4626.t.sol

This test file is written in Solidity and interacts with a forked instance of Kaia Mainnet. It demonstrates the full deposit, earn, and redeem cycle of the vault.

The test file should look like this:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
import {Test, console2} from "forge-std/Test.sol";
import {DgswapV2ERC4626} from "../src/erc4626/DgswapV2ERC4626.sol";
import {ERC20} from "solmate/tokens/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IUniswapV2Pair} from "../src/erc4626/interfaces/IUniswapV2Pair.sol";
import {IUniswapV2Router} from "../src/erc4626/interfaces/IUniswapV2Router.sol";
import {
    IUniswapV2Factory
} from "../src/erc4626/interfaces/IUniswapV2Factory.sol";
contract UniswapV2ERC4626Test is Test {
    // Kaia mainnet addresses
    IERC20 usdt = IERC20(0xd077A400968890Eacc75cdc901F0356c943e4fDb);
    IERC20 elde = IERC20(0x8755D2e532b1559454689Bf0E8964Bd78b187Ff6);
    IUniswapV2Router router =
        IUniswapV2Router(0x8203cBc504CE43c3Cad07Be0e057f25B1d4DB578);
    IUniswapV2Factory factory;
    IUniswapV2Pair pair;
    DgswapV2ERC4626 vault;
    // Test actors
    address liquidityProvider = makeAddr("liquidityProvider");
    address vaultUser = makeAddr("vaultUser");
    address trader = makeAddr("trader");
    function setUp() public {
        console2.log("\n====================================");
        console2.log("  Mock USDT/ELDE Vault Setup");
        console2.log("====================================\n");
        // Get factory
        factory = IUniswapV2Factory(0x224302153096E3ba16c4423d9Ba102D365a94B2B);
        console2.log("Router:", address(router));
        console2.log("Factory:", address(factory));
        console2.log("USDT:", address(usdt));
        console2.log("ELDE:", address(elde));
        // Step 1: Create pair (or get existing)
        console2.log("\n[1/4] Creating/Getting pair...");
        address pairAddress = factory.getPair(address(usdt), address(elde));
        if (pairAddress == address(0)) {
            // Create new pair
            vm.prank(liquidityProvider);
            pairAddress = factory.createPair(address(usdt), address(elde));
            console2.log("  New pair created:", pairAddress);
        } else {
            console2.log("  Pair exists:", pairAddress);
        }
        pair = IUniswapV2Pair(pairAddress);
        // Step 2: Add initial liquidity
        console2.log("\n[2/4] Adding initial liquidity...");
        _addInitialLiquidity();
        // Step 3: Deploy vault
        console2.log("\n[3/4] Deploying vault...");
        vault = new DgswapV2ERC4626(
            "vUSDTe",
            "vUSDTe",
            ERC20(address(pair)),
            usdt,
            elde,
            router,
            pair,
            9500 // 95% slippage
        );
        console2.log("  Vault deployed:", address(vault));
        // Step 4: Verify setup
        console2.log("\n[4/4] Verifying setup...");
        (uint112 reserve0, uint112 reserve1, ) = pair.getReserves();
        console2.log("  Pool reserves:");
        console2.log("    Reserve0:", reserve0);
        console2.log("    Reserve1:", reserve1);
        uint256 lpSupply = pair.totalSupply();
        console2.log("  LP total supply:", lpSupply);
        console2.log("\nSetup complete!\n");
    }
    function _addInitialLiquidity() internal {
        // Give liquidity provider tokens using deal
        uint256 usdtAmount = 10_000 * 1e6; // 10k USDT
        uint256 eldeAmount = 4_000_000 * 1e18; // 4M ELDE (400:1 ratio)
        deal(address(usdt), liquidityProvider, usdtAmount);
        deal(address(elde), liquidityProvider, eldeAmount);
        console2.log("  LP Provider balance:");
        console2.log("    USDT:");
        console2.log(usdtAmount / 1e6);
        console2.log("    ELDE:");
        console2.log(eldeAmount / 1e18);
        // Add liquidity
        vm.startPrank(liquidityProvider);
        usdt.approve(address(router), usdtAmount);
        elde.approve(address(router), eldeAmount);
        (uint256 amountA, uint256 amountB, uint256 liquidity) = router
            .addLiquidity(
                address(usdt),
                address(elde),
                usdtAmount,
                eldeAmount,
                0,
                0,
                liquidityProvider,
                block.timestamp + 100
            );
        vm.stopPrank();
        console2.log("  Liquidity added:");
        console2.log("    USDT:");
        console2.log(amountA / 1e6);
        console2.log("    ELDE:");
        console2.log(amountB / 1e18);
        console2.log("    LP tokens:");
        console2.log(liquidity);
    }
    function test_DepositAndEarnFees() public {
        console2.log("=== Deposit - Swap - Earn ===\n");
        // Step 1: Give vault user tokens
        console2.log("Step 1: Giving vault user tokens...");
        _giveTokens();
        // Step 2: User deposits
        console2.log("\nStep 2: Vault user deposits...");
        uint256 depositAmount = 1_000_000_000_000_000_000; // 1.0 LP
        uint256 shares = _userDeposit(depositAmount);
        // Step 3: Record BEFORE
        console2.log("\nStep 3: Recording BEFORE state...");
        (uint256 usdtBefore, uint256 eldeBefore) = vault.getAssetsAmounts(
            depositAmount
        );
        console2.log("LP Value BEFORE:");
        console2.log("  USDT wei:", usdtBefore);
        console2.log("  USDT:", usdtBefore / 1e6);
        console2.log("  ELDE wei:", eldeBefore);
        console2.log("  ELDE:", eldeBefore / 1e18);
        uint256 valueBefore = _calculateValue(usdtBefore, eldeBefore);
        console2.log("  Total USD:", valueBefore / 1e6);
        // Step 4: Trader swaps
        console2.log("\nStep 4: Trader generating fees...");
        _traderSwaps(100);
        // Step 5: Record AFTER
        console2.log("\nStep 5: Recording AFTER state...");
        (uint256 usdtAfter, uint256 eldeAfter) = vault.getAssetsAmounts(
            depositAmount
        );
        console2.log("LP Value AFTER:");
        console2.log("  USDT wei:", usdtAfter);
        console2.log("  USDT:", usdtAfter / 1e6);
        console2.log("  ELDE wei:", eldeAfter);
        console2.log("  ELDE:", eldeAfter / 1e18);
        uint256 valueAfter = _calculateValue(usdtAfter, eldeAfter);
        console2.log("  Total USD:", valueAfter / 1e6);
        // Step 6: Calculate profit
        console2.log("\n=== PROFIT ===");
        int256 usdtChange = int256(usdtAfter) - int256(usdtBefore);
        int256 eldeChange = int256(eldeAfter) - int256(eldeBefore);
        int256 valueChange = int256(valueAfter) - int256(valueBefore);
        console2.log("USDT change:");
        if (usdtChange >= 0) {
            console2.log("  Plus wei:", uint256(usdtChange));
            console2.log("  Plus USDT:", uint256(usdtChange) / 1e6);
        } else {
            console2.log("  Minus wei:", uint256(-usdtChange));
            console2.log("  Minus USDT:", uint256(-usdtChange) / 1e6);
        }
        console2.log("ELDE change:");
        if (eldeChange >= 0) {
            console2.log("  Plus wei:", uint256(eldeChange));
            console2.log("  Plus ELDE:", uint256(eldeChange) / 1e18);
        } else {
            console2.log("  Minus wei:", uint256(-eldeChange));
            console2.log("  Minus ELDE:", uint256(-eldeChange) / 1e18);
        }
        console2.log("Total value change:");
        if (valueChange >= 0) {
            console2.log("  Plus USD:", uint256(valueChange) / 1e6);
        } else {
            console2.log("  Minus USD:", uint256(-valueChange) / 1e6);
        }
        if (usdtChange > 0 && eldeChange > 0) {
            console2.log("\nYou earned fees on both assets with minimal IL!");
        } else if (valueChange > 0) {
            console2.log("\nNet positive despite some rebalancing!");
        } else if (usdtChange > 0) {
            console2.log("\nYou earned fees!");
        } else {
            console2.log("\nImpermanent loss exceeded fees earned");
        }
        assertGt(shares, 0, "Should have shares");
    }
    function test_RedeemAfterFees() public {
        console2.log("=== Full Cycle ===\n");
        _giveTokens();
        // Record initial
        uint256 usdtInitial = usdt.balanceOf(vaultUser);
        uint256 eldeInitial = elde.balanceOf(vaultUser);
        console2.log("Initial balances:");
        console2.log("  USDT:", usdtInitial / 1e6);
        console2.log("  ELDE:", eldeInitial / 1e18);
        // Deposit
        uint256 depositAmount = 1_000_000_000_000_000_000;
        uint256 shares = _userDeposit(depositAmount);
        console2.log("\nAfter deposit:");
        console2.log("  Shares:", shares);
        // Generate fees
        console2.log("\nGenerating fees...");
        _traderSwaps(100);
        // Redeem
        console2.log("\nRedeeming...");
        vm.startPrank(vaultUser);
        vault.redeem(shares, vaultUser, vaultUser);
        vm.stopPrank();
        // Final balances
        uint256 usdtFinal = usdt.balanceOf(vaultUser);
        uint256 eldeFinal = elde.balanceOf(vaultUser);
        console2.log("\nFinal balances:");
        console2.log("  USDT:", usdtFinal / 1e6);
        console2.log("  ELDE:", eldeFinal / 1e18);
        console2.log("\nNet profit:");
        int256 netUsdt = int256(usdtFinal) - int256(usdtInitial);
        int256 netElde = int256(eldeFinal) - int256(eldeInitial);
        if (netUsdt >= 0) {
            console2.log("  USDT plus:", uint256(netUsdt) / 1e6);
        } else {
            console2.log("  USDT minus:", uint256(-netUsdt) / 1e6);
        }
        if (netElde >= 0) {
            console2.log("  ELDE plus:", uint256(netElde) / 1e18);
        } else {
            console2.log("  ELDE minus:", uint256(-netElde) / 1e18);
        }
        console2.log("\nComplete!");
    }
    function _giveTokens() internal {
        deal(address(usdt), vaultUser, 500_000 * 1e6);
        deal(address(elde), vaultUser, 200_000_000 * 1e18);
        deal(address(usdt), trader, 500_000 * 1e6);
        deal(address(elde), trader, 200_000_000 * 1e18);
        console2.log("  Tokens given to users");
    }
    function _userDeposit(uint256 lpAmount) internal returns (uint256 shares) {
        (uint256 usdtNeeded, uint256 eldeNeeded) = vault.getAssetsAmounts(
            lpAmount
        );
        console2.log("  Required:");
        console2.log("    USDT:", usdtNeeded / 1e6);
        console2.log("    ELDE:", eldeNeeded / 1e18);
        vm.startPrank(vaultUser);
        usdt.approve(address(vault), (usdtNeeded * 120) / 100);
        elde.approve(address(vault), (eldeNeeded * 120) / 100);
        shares = vault.deposit(lpAmount, vaultUser);
        vm.stopPrank();
        console2.log("  Deposited! Shares:", shares);
        return shares;
    }
function _traderSwaps(uint256 numSwaps) internal {
    (uint112 reserve0, uint112 reserve1,) = pair.getReserves();
    
    // Determine which token is which
    bool isToken0USDT = pair.token0() == address(usdt);
    
    // Store initial reserves for comparison
    uint256[2] memory initialReserves;
    initialReserves[0] = isToken0USDT ? uint256(reserve0) : uint256(reserve1); // USDT
    initialReserves[1] = isToken0USDT ? uint256(reserve1) : uint256(reserve0); // ELDE
    
    // Calculate swap amounts - 0.1% of pool per swap
    uint256[2] memory swapAmounts;
    swapAmounts[0] = initialReserves[0] / 1000; // USDT amount
    swapAmounts[1] = initialReserves[1] / 1000; // ELDE amount
    
    console2.log("  Pool reserves:");
    console2.log("    USDT:", initialReserves[0] / 1e6);
    console2.log("    ELDE:", initialReserves[1] / 1e18);
    console2.log("  Swap amounts per trade:");
    console2.log("    USDT:", swapAmounts[0] / 1e6);
    console2.log("    ELDE:", swapAmounts[1] / 1e18);
    
    // Execute swaps
    for (uint256 i = 0; i < numSwaps; i++) {
        vm.startPrank(trader);
        
        address[] memory path = new address[](2);
        
        if (i % 2 == 0) {
            // Swap USDT → ELDE
            usdt.approve(address(router), swapAmounts[0]);
            path[0] = address(usdt);
            path[1] = address(elde);
            
            router.swapExactTokensForTokens(
                swapAmounts[0],
                0,
                path,
                trader,
                block.timestamp + 100
            );
        } else {
            // Swap ELDE → USDT
            elde.approve(address(router), swapAmounts[1]);
            path[0] = address(elde);
            path[1] = address(usdt);
            
            router.swapExactTokensForTokens(
                swapAmounts[1],
                0,
                path,
                trader,
                block.timestamp + 100
            );
        }
        
        vm.stopPrank();
        vm.roll(block.number + 1);
    }
    
    // Calculate approximate fees (0.3% of volume)
    uint256[2] memory feesGenerated;
    feesGenerated[0] = (swapAmounts[0] * (numSwaps / 2) * 30) / 10000;
    feesGenerated[1] = (swapAmounts[1] * ((numSwaps + 1) / 2) * 30) / 10000;
    
    console2.log("\n  Trading complete!");
    console2.log("  Swaps executed:");
    console2.log("    USDT->ELDE:", numSwaps / 2);
    console2.log("    ELDE->USDT:", (numSwaps + 1) / 2);
    console2.log("  Estimated fees generated (0.3% per swap):");
    console2.log("    USDT fees:", feesGenerated[0] / 1e6);
    console2.log("    ELDE fees:", feesGenerated[1] / 1e18);
    
    // Get final state
    (reserve0, reserve1,) = pair.getReserves();
    console2.log("  Final pool reserves:");
    console2.log("    USDT:", (isToken0USDT ? uint256(reserve0) : uint256(reserve1)) / 1e6);
    console2.log("    ELDE:", (isToken0USDT ? uint256(reserve1) : uint256(reserve0)) / 1e18);
}
    function _calculateValue(
        uint256 usdtAmount,
        uint256 eldeAmount
    ) internal view returns (uint256) {
        (uint112 reserve0, uint112 reserve1, ) = pair.getReserves();
        // Calculate ELDE price in USDT
        uint256 eldePrice = (uint256(reserve0) * 1e18) / uint256(reserve1);
        uint256 eldeValueInUsdt = (eldeAmount * eldePrice) / 1e18;
        return usdtAmount + eldeValueInUsdt;
    }
}

Walkthrough of the Test Suite

The test suite validates the entire lifecycle of the DGswap ERC4626 vault, including pair creation, liquidity provisioning, share minting, yield generation through trading activity, and final redemption.

The setup stage prepares the environment by reading the factory address, checking whether the USDT–ELDE pair already exists, and creating it if necessary. The test then allocates USDT and ELDE to a liquidity provider using Foundry’s deal cheatcode, approves the router to spend these tokens, and initializes the pool by calling addLiquidity. This establishes real reserves that the vault will later interact with. The test prints diagnostic logs throughout setup so you can verify addresses, reserves, and LP supply.

Once liquidity exists, the test deploys the vault. The constructor is passed the vault name and symbol, the LP token address, token0, token1, the router, the pair, and a slippage value. By the end of setup, the vault is ready to accept deposits and convert supplied tokens into LP tokens through its ERC4626 lifecycle hooks.

The first main test, test_DepositAndEarnFees, simulates the earning cycle from start to finish. It assigns balances of USDT and ELDE to the vault user and trader, then determines how much token0 and token1 are needed to mint a target amount of LP tokens by calling getAssetsAmounts. The user approves the vault to pull those amounts and calls deposit. This executes the ERC4626 deposit sequence and triggers afterDeposit, which performs addLiquidity and mints LP tokens into the vault.

Before any fees are generated, the test calculates the underlying value represented by the user’s LP amount and logs it. The trader then performs repeated swaps in alternating directions, creating natural trading volume inside the DGswap pool. These swaps collect fees inside the pool, increasing the underlying value of LP tokens. After the swap cycle, the test recalculates the LP token value and logs the increase in USDT, ELDE, and total USD-equivalent value. This confirms that the vault earned yield and that the LP position grew over time.

The second main test, test_RedeemAfterFees, completes the full cycle. It assigns initial token balances to the vault user, processes a deposit, triggers fee generation through another round of swaps, and finally calls redeem. Redeeming triggers beforeWithdraw, which removes LP into USDT and ELDE. The user receives these tokens back in their wallet. The test prints the final balances and computes the net gain or loss in each asset. Because LP yield is reflected in increased token balances, this verifies that the strategy logic and ERC4626 hooks are functioning as intended.

Helper functions keep the test suite readable. The _giveTokens function sets initial balances for the user and trader. The _userDeposit function handles approvals and deposit calls while reporting required token amounts. The _calculateValue function converts ELDE into a USDT-equivalent using the pair reserves. The _traderSwaps function alternates between both trading directions, computes swap amounts, and moves block numbers forward to simulate realistic trading.

Overall, the test suite validates the vault end to end. It shows that the vault properly calculates ratios, converts deposits into LP positions through ERC4626 hooks, accumulates yield from trading fees, removes liquidity during redemption, and maintains accurate accounting throughout. This gives developers high confidence that the vault behaves correctly on-chain.

Running the Tests

Before testing, fork Kaia Mainnet:

source .env
anvil --fork-url $KAIA_RPC_URL --fork-block-number 201274793 --fork-chain-id 8217

Anvil will load ten pre-funded accounts you can use for testing.

To test the deposit and earn functionality:

forge test --match-test test_DepositAndEarnFees --fork-url $LOCAL_RPC_URL -vv

The output logs will show the entire deposit and yield generation flow.

To test the full deposit, earn, and redeem cycle:

forge test --match-test test_RedeemAfterFees --fork-url $LOCAL_RPC_URL -vv

Again, your console output will demonstrate the full lifecycle including profit realization.

That is it. You have deployed and tested a complete ERC4626-based yield strategy vault that generates on-chain yield by providing liquidity on DGswap. You have validated both the deposit and earn flow, and the full deposit, earn, and redeem cycle. This gives you everything you need to start building more advanced vaults and strategy modules in the Kaia ecosystem.

Conclusion

In this guide, we explored the ERC-4626 Tokenized Vault Standard, examined how yield-bearing vaults function under the hood, and walked through the process of building a fully operational strategy vault using the standard. With this technical foundation, developers in the Kaia ecosystem can begin creating more unified, composable, and efficient yield-bearing vault implementations across DeFi. The hope is that this knowledge inspires more experimentation, more production-ready vaults, and more advanced financial products built on Kaia.

Below are some valuable resources to deepen your understanding of ERC-4626 as you continue exploring: