ERC-4626으로 Kaia에서 토큰화된 수익 전략 볼트(Vault) 구축하기
ERC-4626으로 Kaia에서 토큰화된 수익 전략 볼트(Vault) 구축
소개
탈중앙화 금융(DeFi)에서 가장 혁신적인 변화 중 하나는 온체인 수익(On-chain Yield), 즉 전통적인 중개자 없이 블록체인 활동만으로 직접 소득을 얻을 수 있는 기능입니다. 지난 10년 동안 DeFi는 단순한 DEX(탈중앙화 거래소) 거래와 이자 농사(Yield Farming)를 넘어 다층적인 수익 경제(Layered Yield Economy)로 진화해 왔습니다.
거의 모든 프로토콜에서 유사한 패턴이 나타납니다. 사용자가 자금을 예치하면 자신의 지분을 나타내는 수취 토큰(Receipt Token)을 받고, 프로토콜은 그 자금을 하나 이상의 수익 전략에 투입합니다. 이후 사용자는 수취 토큰을 상환(Redeem)하여 원금과 함께 발생한 수익을 돌려받습니다.
초기에는 각 프로토콜이 이 패턴을 서로 다르게 구현했습니다. 대출 시장, 수익 애그리게이터(Aggregator), 스테이킹 시스템, 자동 재투자(Auto-compounding) 볼트 등이 모두 독자적인 로직을 구축했기 때문에, 통합이 복잡해지고 결합성(Composability)이 제한되었습니다. ERC-4626은 토큰화된 볼트를 위한 표준화된 인터페이스를 제공하여 이러한 문제를 해결합니다. 이로써 수익 창출형 볼트를 더 쉽게 구축하고, 통합하며, 전체 DeFi 생태계로 확장할 수 있게 되었습니다.
이 가이드는 토큰화된 볼트 표준(ERC-4626)을 소개하고, 이를 통해 수익 창출 프로토콜과의 상호작용을 어떻게 간소화할 수 있는지 설명합니다. 여러분은 ERC-4626 볼트의 작동 원리, 내부적인 수익 창출 메커니즘의 구현 방식, 그리고 DGswap의 유동성 공급과 같은 전략으로 자신만의 볼트를 확장하는 방법을 배우게 됩니다.
사전 준비 사항
- 컴퓨터에 Foundry가 설치되어 있어야 합니다.
- 개발 및 테스트를 위한 Foundry 사용법에 익숙해야 합니다.
- ERC-20 토큰 스마트 컨트랙트에 대한 기본 지식이 필요합니다.
볼트(Vault)와 ERC-4626의 이해
볼트(Vault)란 무엇인가?
DeFi에서 볼트(Vault)는 단순한 암호화폐 자산 저장소 그 이상입니다. 볼트는 사용자가 자신의 자산을 대출(Lending), 차입(Borrowing), 유동성 공급(Liquidity Provisioning), 스테이킹(Staking) 등 다양한 수익 창출 전략에 자동으로 배치할 수 있게 해주는 스마트 컨트랙트 기반의 자산 관리 도구 역할을 합니다.
볼트를 사용하면 사용자는 단일 컨트랙트와 상호작용하는 것만으로 예치(Deposit)나 인출(Withdrawal) 같은 단순한 작업부터 전략 실행, 보상 수확(Harvesting), 수익 재투자(Compounding)와 같은 복잡한 작업까지 처리할 수 있습니다. 이를 통해 볼트는 온체인 수익 창출 과정을 단순화하며, 사용자가 직접 전략을 관리하지 않고도 최적화된 전략의 이점을 누릴 수 있게 합니다.

왜 ERC-4626인가?
볼트가 DeFi의 핵심 요소로 자리 잡으면서 프로토콜들은 각기 다른 아키텍처, 인터페이스, 회계 모델을 사용하여 볼트를 구현했습니다. 각 볼트는 예치, 인출, 지분 계산, 수익 분배에 대해 저마다의 독자적인 로직을 가지고 있었습니다.
이러한 일관성의 부재는 통합하려는 개발자들에게 큰 마찰 요인이었습니다. 유휴 자금을 다른 프로토콜의 볼트로 보내려는 프로젝트는 매번 맞춤형 커넥터를 구축하고, 일회성 통합을 유지보수하며, 구현 방식의 차이를 일일이 파악해야 했습니다. 이는 개발 속도를 늦출 뿐만 아니라 잠재적인 오류, 불일치, 보안 문제의 발생 가능성을 높였습니다.
이러한 파편화 문제를 해결하기 위해 등장한 것이ERC-4626(토큰화된 볼트 표준)입니다. ERC-4626은 수익 창출형 볼트를 구축하고 상호작용하기 위한 통일된 인터페이스를 제공합니다. 표준이 마련됨에 따라 개발자는 호환되지 않는 수십 개의 인터페이스에 맞출 필요 없이 하나의 인터페이스만 지원하면 됩니다. 이는 DeFi 스택 전반에 걸쳐 결합성(Composability)을 크게 향상하고 통합을 단순화하며, 개발자가 기초적인 볼트 메커니즘을 다시 만드는 대신 전략의 혁신에 집중할 수 있게 해줍니다.
작동 원리
수익 창출형 볼트는 사용자가 ERC-20 토큰을 공용 풀(Pool)에 예치하고, 그에 대한 비례적 소유권을 나타내는 수취 토큰을 받도록 합니다. 이 수취 토큰은 사용자의 볼트 지분을 추적하며, 나중에 원금과 누적된 수익을 합친 자산으로 상환할 수 있습니다.
구체적으로 볼트는 일반적으로 다음과 같이 작동합니다:
- 예치 (Deposit): 사용자는 ERC-20 자산을 볼트에 예치하고, 자신의 지분을 나타내는 볼트 전용 토큰(vToken)을 받습니다.
- 전략 실행 (Strategy Execution): 볼트의 스마트 컨트랙트는 모인 자산을 대출 시장, 유동성 풀, 스테이킹 시스템 또는 더 복잡한 다중 프로토콜 전략과 같은 사전 정의된 수익 전략에 투입합니다.
- 수익 수확 (Yield Harvesting): 전략에서 수익이 발생하면 볼트는 주기적으로 그 수익을 수확하여 재투자함으로써 자산을 증식시키고 각 지분의 가치를 높입니다.
- 인출 (Withdrawal): 사용자는 언제든지 vToken을 상환(Redeem)하여 자신의 볼트 총자산 지분(초기 원금 + 시간 경과에 따른 수익)을 받을 수 있습니다.
DeFi 활용 사례
ERC-4626 표준의 도입으로 DeFi에서 창의적인 금융 상품을 만들 수 있는 가능성이 확장되었습니다. 이러한 사례 중 다수는 이전에도 존재했지만, 이제 개발자는 통일되고 표준화된 볼트 인터페이스를 사용하여 이를 구현할 수 있게 되었습니다. 이로써 전략을 더 쉽게 구축하고, 결합하며, 프로토콜 간에 통합할 수 있습니다. ERC-4626을 사용하면 다양한 위험 성향, 투자 목표, 자산 유형에 맞는 볼트를 설계하기가 더 간편해집니다.
다음은 생태계 전반에서 나타난 몇 가지 일반적인 활용 사례입니다:
이자 농사 볼트 (Yield Farming Vaults): 이 볼트들은 사용자 자금을 여러 수익 창출 전략에 배분하여 수익을 극대화하는 것을 목표로 합니다. 자본을 대출 시장, 유동성 풀, 파밍 기회 등으로 자동 라우팅하여 가장 높은 수익을 포착합니다. 대표적인 예로 Yearn Finance가 있으며, 사용자 예치금을 Aave, Compound, Curve와 같은 프로토콜에 배분하여 성과를 최적화합니다.
LP 및 AMM 볼트 (LP and AMM Vaults): 이 볼트들은 자동화된 마켓 메이커(AMM) 풀의 유동성 포지션을 관리합니다. 거래 수수료 수확, 집중화된 유동성(Concentrated Liquidity) 재조정, 비영구적 손실(Impermanent Loss) 노출 관리와 같은 작업을 자동화합니다. 이를 통해 유동성 제공자는 지속적인 수동 조정 없이도 효율적인 포지션을 유지할 수 있습니다.
스테이블코인 볼트 (Stablecoin Vaults): 스테이블코인 중심의 볼트는 예측 가능하고 변동성이 낮은 수익을 제공하는 전략을 우선시합니다. 일반적으로 대출 시장에서 스테이블코인을 대출해주거나 스테이블코인 기반의 이자 농사에 참여하므로, 더 안정적이고 위험을 고려한 수익을 추구하는 사용자에게 매력적입니다.
유동성 스테이킹 볼트 (Liquid Staking Vaults): 이 볼트들은 사용자가 KAIA와 같은 네이티브 블록체인 토큰을 스테이킹하면서, 동시에 스테이킹된 포지션을 나타내는 유동성 토큰(Liquid Token)을 받을 수 있게 합니다. 이 유동성 스테이킹 토큰은 스테이킹 보상을 얻으면서도 다른 DeFi 프로토콜에서 사용할 수 있어 수익과 유동성을 모두 확보할 수 있습니다.
레버리지 이자 농사 볼트 (Leveraged Yield Farming Vaults): 이 볼트들은 추가 자본을 차입하여 이자 농사 포지션을 확대함으로써 잠재 수익을 높입니다. 레버리지 전략은 수익을 크게 증폭시킬 수 있지만, 시장이 불리하게 움직일 경우 청산(Liquidation) 가능성을 포함하여 더 높은 위험을 수반합니다.
시작하기
이 가이드에서는 Kaia 생태계 내의 DEX(탈중앙화 거래소)에 유동성을 공급하여 수익을 얻는 수익 창출형 볼트를 구축해 봅니다. 이를 위해 Kaia 메인넷을 로컬 개발 환경으로 포크(Fork)하고 Foundry를 사용하여 실제와 같은 상호작용을 시뮬레이션할 것입니다.
ERC-4626을 이용한 독립형 수익 전략 볼트 만들기
이 섹션에서는 ERC-4626 토큰화된 볼트 표준을 사용하여 독립형 수익 전략 볼트를 개발합니다. 이 컨트랙트는 DGswap 풀에 유동성을 공급하고 기본 AMM 내에서 발생하는 거래 수수료를 포착할 수 있게 해줍니다. 볼트 구현을 시작하기 전에, ERC-4626이 정의하는 핵심 함수들과 이들이 예치, 인출, 전략 실행의 수명 주기(Lifecycle)를 어떻게 형성하는지 이해하는 것이 중요합니다.
실행 함수 (Execution Functions)
ERC-4626 표준은 자산이 볼트로 들어오고 나가는 것을 제어하는 네 가지 핵심 실행 함수를 정의합니다.
자산 예치 (Asset Deposits)
예치는 두 가지 함수를 통해 수행할 수 있습니다.
- deposit() 함수는 기여하고자 하는 자산의 양을 지정하면, 볼트가 발행(Mint)해야 할 지분(Shares)의 수를 자동으로 계산합니다.
- mint() 함수는 반대로 작동합니다. 원하는 지분의 수를 지정하면, 볼트가 사용자 계정에서 전송해야 할 ERC-20 자산의 양을 결정합니다.
자산 상환 (Asset Redemption)
상환 또한 두 가지 진입점이 있습니다.
- withdraw() 함수는 회수하고자 하는 기초 자산(Underlying Assets)의 양을 지정하면, 볼트가 소각(Burn)해야 할 지분의 수를 계산합니다.
- redeem() 함수는 소각하고자 하는 지분의 수를 정확히 정의할 수 있게 합니다. 그러면 볼트는 사용자가 받게 될 기초 자산의 양을 계산합니다.
변환 함수 (Converter Functions)
ERC-4626은 지분과 자산 간의 변환을 담당하는 두 가지 변환 함수를 제공합니다.
- convertToShares() 함수는 주어진 자산 양에 해당하는 지분의 수를 계산합니다.
- convertToAssets() 함수는 주어진 지분 수에 해당하는 자산의 양을 계산합니다.
이 함수들은 정확한 회계 처리에 필수적이며, 예측 가능한 변환이 필요한 통합 과정을 단순화하는 데 도움을 줍니다.
자산 함수 (Asset Functions)
볼트의 기초 자산을 설명하는 두 가지 조회(View) 함수가 있습니다.
- asset() 함수는 볼트의 기초 자산으로 사용되는 ERC-20 토큰의 주소를 반환합니다. (이 가이드의 경우 LP 컨트랙트 주소)
- totalAssets() 함수는 현재 볼트가 관리하는 기초 자산의 총량을 반환합니다.
최대값 함수 (Max Functions)
ERC-4626 표준은 예치, 발행, 인출, 상환 작업에 대한 한도를 정의합니다.
- maxDeposit() 함수는 사용자가 예치 함수에 전달할 수 있는 자산의 최대량을 반환합니다.
- maxMint() 함수는 사용자가 발행할 수 있는 지분의 최대 수를 반환합니다.
- maxWithdraw() 함수는 사용자의 지분 잔액을 기준으로 인출할 수 있는 자산의 최대량을 반환합니다.
- maxRedeem() 함수는 사용자가 상환할 수 있는 지분의 최대 수를 반환합니다.
미리보기 함수 (Preview Functions)
ERC-4626은 네 가지 미리보기 함수를 제공합니다: previewDeposit, previewMint, previewWithdraw, previewRedeem. 이 함수들은 온체인 또는 오프체인 클라이언트가 볼트의 실시간 회계 정보를 사용하여 특정 작업의 결과를 현재 블록 기준으로 시뮬레이션할 수 있게 해줍니다. 미리보기 기능을 사용하면 상태(State)를 변경하지 않고도 결과를 쉽게 추정할 수 있습니다.
구현 예제
이 가이드에서는 사용자가 DGswap 유동성 풀에 예치하여 수익을 얻을 수 있는 독립형 수익 전략 볼트 컨트랙트를 사용합니다. 이 컨트랙트는 내부적으로 ERC-4626을 구현하며, 사용자 예치금을 LP 포지션으로 변환하고 시간이 지남에 따라 누적되는 거래 수수료를 처리하는 모든 로직을 다룹니다.
/**
* @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;
}
}코드 개요
이 볼트는 DGswap V2 유동성 풀을 ERC-4626 인터페이스로 감싼(Wrap) 형태입니다. ERC-20 토큰을 볼트의 기본 자산으로 취급하는 대신, 이 볼트는 DGswap LP 토큰을 사용합니다. 즉, 볼트의 “자산”은 token0이나 token1이 아니라, AMM 풀에 대한 청구권을 나타내는 LP 토큰입니다.
LP 토큰은 본질적으로 수익을 창출하기 때문에 이 점이 중요합니다. 풀에서 거래가 발생하면 준비금(Reserves)이 증가하고, 모든 LP 토큰은 더 많은 기초 자산 가치를 지니게 됩니다. 단순히 LP 토큰을 보유하는 것만으로도 볼트는 수동적인 수익 전략을 구현하게 됩니다.
사용자는 LP 토큰을 직접 다루지 않습니다. 그들은 token0과 token1을 예치하고, 볼트가 내부적으로 모든 변환과 유동성 공급을 처리합니다. ERC-4626은 내부 훅(Hooks)을 사용하여 이 흐름을 조율합니다.
ERC-4626 훅이 전략을 구동하는 방식
Solmate의 ERC-4626 구현체는 afterDeposit과 beforeWithdraw라는 두 가지 내부 수명 주기 훅을 노출합니다. 이 훅들은 지분이 발행된 직후나 소각되기 직전에 볼트가 맞춤형 전략 로직을 실행할 수 있게 해주며, 이 모든 과정은 ERC-4626의 회계 보장성을 위반하지 않습니다.
이 볼트에서는 유동성 공급 전략을 실행하기 위해 해당 훅들을 사용합니다. 사용자가 예치하면 afterDeposit()은 라우터의 addLiquidity()를 호출하여 공급된 token0과 token1을 DGswap LP 토큰으로 변환합니다. 반대로 인출 시에는 beforeWithdraw()가 역방향 작업을 수행하여 유동성을 제거하고 LP 토큰을 다시 기초 구성 요소로 되돌립니다. 이 훅들은 전략의 동작을 주입하는 핵심 확장 메커니즘 역할을 하며, 지분 발행, 소각, 회계, ERC-20 호환성 등 다른 모든 책임은 기본 ERC-4626 구현체가 안정적으로 처리합니다.
예치 흐름
사용자가 deposit 또는 mint를 호출하면, 볼트는 제공된 자산 가치를 사용자가 자신을 대신해 생성하기를 원하는 LP 토큰의 수로 취급합니다. 이를 위해 먼저 getAssetsAmounts()를 사용하여 해당 LP 목표치에 필요한 token0과 token1의 정확한 양을 계산한 다음, 사용자로부터 해당 기초 토큰들을 가져와 포지션에 대한 청구권을 나타내는 볼트 지분을 발행합니다.
그 직후 제어권은 afterDeposit()으로 넘어갑니다. 여기서 볼트는 DGswap 라우터를 승인(Approve)하고 적절한 슬리피지(Slippage) 보호 장치와 함께 addLiquidity()를 호출하여 공급된 token0과 token1을 볼트가 보유할 실제 LP 토큰으로 변환합니다. 이 과정이 완료되면 볼트의 totalAssets()는 새로운 LP 잔액을 반영하여 증가하며, 사용자는 새로 발행된 지분을 보유하게 됩니다. 이 지분의 가치는 기본 DGswap 풀 내에서 LP 수수료가 누적됨에 따라 자연스럽게 상승합니다.
인출 흐름
withdraw 또는 redeem을 수행하는 동안 프로세스는 반대로 작동합니다. 볼트는 먼저 요청된 자산 양에 대해 소각해야 할 지분의 수를 결정하고, ERC-4626 흐름은 자동으로 beforeWithdraw()를 트리거합니다. beforeWithdraw() 내부에서 볼트는 라우터가 필요한 LP 토큰을 사용할 수 있도록 승인한 후 removeLiquidity()를 호출하여 해당 LP 토큰을 다시 token0과 token1로 변환합니다. 이 기초 토큰들은 지분이 소각될 때까지 볼트에 남아 있습니다. beforeWithdraw()가 완료되면 볼트는 적절한 수의 지분을 소각하고 결과물인 token0과 token1 금액을 사용자에게 전송합니다. ERC-4626 수명 주기는 이 순서가 항상 일관되고 정확한 회계 순서대로 실행되도록 보장합니다.
전략 LP 도우미 함수
전략이 LP 포지션을 기반으로 하기 때문에, 볼트에는 토큰 비율 계산을 처리하는 두 가지 도우미 함수가 포함되어 있습니다. getAssetsAmounts(lpAmount) 함수는 특정 양의 LP 토큰을 생성하는 데 필요한 token0과 token1의 양을 결정하고, getLiquidityAmountOutFor(amount0, amount1)은 주어진 토큰 양으로 발행할 수 있는 최대 LP 토큰을 계산합니다.
두 함수 모두 Uniswap V2 스타일의 준비금 수학(Reserve mathematics)에 의존하므로, 통합자에게 볼트의 동작을 예측 가능하게 만들고 사용자가 수동으로 토큰 비율을 추정할 필요를 없애줍니다. 또한, 볼트는 구성 가능한 슬리피지 매개변수를 적용하여 addLiquidity 및 removeLiquidity 작업이 최소 허용 금액을 준수하고 변화하는 풀 조건에서도 안전하게 유지되도록 합니다.
마지막으로, 이 DGswap 볼트는 ERC-4626이 어떻게 정교한 다중 자산 포지션을 감싸면서도 여전히 단순하고 예측 가능한 API를 제공할 수 있는지 보여줍니다. 예치는 LP 포지션이 됩니다. 인출은 LP를 제거합니다. 수익은 자동으로 발생합니다.
LP 토큰 위에 더 발전된 전략(스테이킹 게이지, 보상 자동 재투자, 유동성 재조정, 헷징 LP 전략 등)을 구축하고 싶다면 이 볼트가 완벽한 기초가 됩니다. 훅에 로직을 연결하거나 전략 컨트랙트에 위임하기만 하면 됩니다.
수익 전략 볼트 컨트랙트 배포 및 상호작용
이 섹션에서는 Foundry를 사용하여 실제 Kaia 메인넷 인스턴스를 포크하고 수익 전략 볼트의 deposit 및 redeem 흐름을 테스트합니다. 이 섹션을 마치면 볼트에 예치하고, 포지션을 상환하며, DGswap에 유동성을 제공하여 발생한 수익을 관찰하는 방법을 이해하게 될 것입니다.
빠르게 시작할 수 있도록 코드 샘플은 DGswapV2ERC4626 예제 리포지토리에 준비되어 있습니다.
리포지토리 복제
예제 프로젝트를 복제(Clone)하여 시작합니다:
git clone https://github.com/ayo-klaytn/dgswapV2ERC4626-example.git
스마트 컨트랙트 테스트
복제된 리포지토리 내의 test/dgswapV2ERC4626.t.sol 경로에서 전체 테스트 모음을 찾을 수 있습니다. 이 테스트 파일은 Solidity로 작성되었으며 포크된 Kaia 메인넷 인스턴스와 상호작용합니다. 이는 볼트의 전체 예치, 수익 창출, 상환 주기를 시연합니다.
테스트 코드는 다음과 같습니다.
// 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;
}
}
테스트 패키지 상세 설명
테스트 패키지는 페어(Pair) 생성, 유동성 공급, 지분 발행, 거래 활동을 통한 수익 창출, 그리고 최종 상환을 포함하여 DGswap ERC-4626 볼트의 전체 수명 주기를 검증합니다.
설정(Setup) 단계에서는 팩토리 주소를 읽고 USDT–ELDE 페어가 이미 존재하는지 확인한 후, 필요하다면 생성하여 환경을 준비합니다. 그런 다음 테스트는 Foundry의 deal 치트코드를 사용하여 유동성 제공자에게 USDT와 ELDE를 할당하고, 라우터가 이 토큰들을 사용할 수 있도록 승인한 뒤, addLiquidity를 호출하여 풀을 초기화합니다. 이는 나중에 볼트가 상호작용할 실제 준비금을 설정합니다. 테스트는 설정 전반에 걸쳐 진단 로그를 출력하므로 주소, 준비금, LP 공급량을 확인할 수 있습니다.
유동성이 존재하면 테스트는 볼트를 배포합니다. 생성자(Constructor)에는 볼트 이름과 심볼, LP 토큰 주소, token0, token1, 라우터, 페어, 슬리피지 값이 전달됩니다. 설정이 끝나면 볼트는 예치를 받고 ERC-4626 수명 주기 훅을 통해 공급된 토큰을 LP 토큰으로 변환할 준비가 됩니다.
첫 번째 주요 테스트인 test_DepositAndEarnFees는 시작부터 끝까지의 수익 창출 주기를 시뮬레이션합니다. 볼트 사용자와 트레이더에게 USDT와 ELDE 잔액을 할당한 다음, getAssetsAmounts를 호출하여 목표 LP 토큰 양을 발행하는 데 필요한 token0과 token1의 양을 결정합니다. 사용자는 볼트가 해당 금액을 가져갈 수 있도록 승인하고 deposit을 호출합니다. 이는 ERC-4626 예치 시퀀스를 실행하고 afterDeposit을 트리거하여, addLiquidity를 수행하고 볼트에 LP 토큰을 발행합니다.
수수료가 발생하기 전에 테스트는 사용자의 LP 양이 나타내는 기초 자산 가치를 계산하고 기록합니다. 그런 다음 트레이더는 번갈아 가며 반복적인 스왑(Swap)을 수행하여 DGswap 풀 내에서 자연스러운 거래량을 생성합니다. 이러한 스왑은 풀 내에 수수료를 모아 LP 토큰의 기초 자산 가치를 높입니다. 스왑 주기가 끝나면 테스트는 LP 토큰 가치를 다시 계산하고 USDT, ELDE 및 총 USD 환산 가치의 증가분을 기록합니다. 이는 볼트가 수익을 얻었고 LP 포지션이 시간이 지남에 따라 성장했음을 확인시켜 줍니다.
두 번째 주요 테스트인 test_RedeemAfterFees는 전체 주기를 완료합니다. 초기 토큰 잔액을 볼트 사용자에게 할당하고, deposit을 처리하고, 또 한 번의 스왑 라운드를 통해 수수료 발생을 트리거한 다음, 마지막으로 redeem을 호출합니다. 상환은 beforeWithdraw를 트리거하여 LP를 USDT와 ELDE로 제거합니다. 사용자는 이 토큰들을 자신의 지갑으로 돌려받습니다. 테스트는 최종 잔액을 출력하고 각 자산의 순이익 또는 손실을 계산합니다. LP 수익이 증가한 토큰 잔액에 반영되므로, 이는 전략 로직과 ERC-4626 훅이 의도한 대로 작동하고 있음을 검증합니다.
도우미 함수들은 테스트 모음의 가독성을 유지해 줍니다. _giveTokens 함수는 사용자와 트레이더의 초기 잔액을 설정합니다. _userDeposit 함수는 승인 및 예치 호출을 처리하면서 필요한 토큰 양을 보고합니다. _calculateValue 함수는 페어 준비금을 사용하여 ELDE를 USDT 등가물로 변환합니다. _traderSwaps 함수는 양방향 거래를 번갈아 수행하고 스왑 금액을 계산하며 블록 번호를 앞으로 이동시켜 현실적인 거래를 시뮬레이션합니다.
전반적으로 이 테스트 모음은 볼트를 엔드 투 엔드(End-to-End)로 검증합니다. 볼트가 비율을 적절히 계산하고, ERC-4626 훅을 통해 예치금을 LP 포지션으로 변환하며, 거래 수수료로 수익을 축적하고, 상환 시 유동성을 제거하며, 전체 과정에서 정확한 회계를 유지함을 보여줍니다. 이는 개발자들에게 볼트가 온체인에서 올바르게 동작할 것이라는 높은 확신을 줍니다.
테스트 실행하기
테스트하기 전에 Kaia 메인넷을 포크하세요:
source .env
anvil --fork-url $KAIA_RPC_URL --fork-block-number 201274793 --fork-chain-id 8217Anvil은 테스트에 사용할 수 있는 10개의 사전 자금 조달된 계정을 로드합니다.
예치 및 수익 창출 기능을 테스트하려면:
forge test --match-test test_DepositAndEarnFees --fork-url $LOCAL_RPC_URL -vv출력 로그는 전체 예치 및 수익 창출 흐름을 보여줍니다.
전체 예치, 수익 창출 및 상환 주기를 테스트하려면:
forge test --match-test test_RedeemAfterFees --fork-url $LOCAL_RPC_URL -vv콘솔 출력은 수익 실현을 포함한 전체 수명 주기를 보여줄 것입니다.
이것으로 완료되었습니다. 여러분은 DGswap에 유동성을 제공하여 온체인 수익을 창출하는 완전한 ERC-4626 기반 수익 전략 볼트를 배포하고 테스트했습니다. 예치 및 수익 창출 흐름뿐만 아니라 전체 예치, 수익 창출, 상환 주기까지 검증했습니다. 이것으로 Kaia 생태계에서 더 발전된 볼트와 전략 모듈을 구축하기 시작하는 데 필요한 모든 것을 갖추게 되었습니다.
결론
이 가이드에서 우리는 ERC-4626 토큰화된 볼트 표준을 살펴보고, 수익 창출형 볼트가 내부적으로 어떻게 작동하는지 확인했으며, 표준을 사용하여 완전히 작동하는 전략 볼트를 구축하는 과정을 실습했습니다. 이 기술적 토대를 바탕으로 Kaia 생태계의 개발자들은 DeFi 전반에 걸쳐 더 통일되고, 결합 가능하며, 효율적인 수익 창출형 볼트 구현체를 만들기 시작할 수 있습니다. 이 지식이 더 많은 실험과 프로덕션 레벨의 볼트, 그리고 Kaia 기반의 더 발전된 금융 상품 구축에 영감이 되기를 바랍니다.
계속 탐구하면서 ERC-4626에 대한 이해를 높이는 데 도움이 될만한 자료들은 다음과 같습니다: