- 게시일
ERC-4626 토큰 (Tokenized Vault)
- 작성자
ERC-4626이란?
Tokenized Vault Standard
ERC-4626은 2022년에 제안된 토큰화된 볼트(Tokenized Vault) 표준입니다. 사용자가 자산을 예치하면 지분을 나타내는 토큰(Share Token) 을 받는 구조를 표준화한 것입니다.
DeFi가 성장하면서 다양한 수익 창출 프로토콜이 등장했습니다. Yearn Finance, Compound, Aave 등 각 프로토콜마다 자체적인 볼트 구현 방식을 사용했고, 이로 인해 통합과 호환성에 문제가 발생했습니다. ERC-4626은 이러한 문제를 해결하기 위해 볼트의 표준 인터페이스를 정의합니다.
ERC-4626 이전의 문제점
- 각 프로토콜마다 다른 인터페이스로 볼트 구현
- 볼트 통합 시 프로토콜별 커스텀 코드 필요
- 수익률 계산, 예치/인출 로직이 제각각
- 지갑, 대시보드에서 볼트 자산 표시 어려움
ERC-4626의 해결책
- 표준 인터페이스로 모든 볼트가 동일하게 작동
- ERC-20 확장으로 기존 토큰 인프라와 호환
- 예치/인출/수익률 계산 로직 표준화
- 지갑, 애그리게이터가 자동으로 모든 볼트 지원
볼트(Vault)란?
볼트는 사용자의 자산을 모아서 수익을 창출하는 스마트 컨트랙트입니다. 펀드와 비슷하게, 여러 사람이 돈을 모아서 투자하고 수익이 나면 지분만큼 나눠 갖는 구조입니다.
볼트의 핵심 개념
Asset vs Share
ERC-4626에서 가장 중요한 두 가지 개념입니다.
Asset (기초 자산)
- 볼트에 예치하는 실제 토큰 (예: USDC, ETH, DAI)
- ERC-20 토큰이어야 함
- 볼트가 관리하는 실제 가치
Share (지분 토큰)
- 볼트가 발행하는 지분 증명 토큰
- 볼트 내 자신의 지분 비율을 나타냄
- ERC-20 토큰 (ERC-4626은 ERC-20을 확장)
핵심 공식
Share 토큰 1개의 가치 = 총 자산 / 총 Share 발행량
예시: USDC 볼트
| 상태 | 총 자산 | 총 Share | 1 vUSDC 가치 |
|---|---|---|---|
| 초기 | 10,000 USDC | 10,000 vUSDC | 1 USDC |
| 수익 발생 후 | 11,000 USDC | 10,000 vUSDC | 1.1 USDC |
Share 발행량은 그대로인데 자산이 늘어나면 → Share 가치 상승
Share 가치 상승 원리
볼트에서 수익이 발생하면 Share 토큰의 가치가 상승합니다.
초기 상태:
- Alice가 1,000 USDC 예치 → 1,000 vUSDC 받음
- Bob이 1,000 USDC 예치 → 1,000 vUSDC 받음
- 총 자산: 2,000 USDC, 총 Share: 2,000 vUSDC
- 1 vUSDC = 1 USDC
수익 발생 후:
- 볼트가 200 USDC 수익 창출
- 총 자산: 2,200 USDC, 총 Share: 2,000 vUSDC
- 1 vUSDC = 1.1 USDC
인출 시:
- Alice가 1,000 vUSDC 반환 → 1,100 USDC 받음 (100 USDC 수익)
- Bob이 1,000 vUSDC 반환 → 1,100 USDC 받음 (100 USDC 수익)
Share 토큰 개수는 변하지 않지만, 각 Share가 나타내는 자산의 양이 증가하는 것입니다.
ERC-4626 데이터 구조
ERC-20 확장
ERC-4626은 ERC-20을 상속합니다. 따라서 Share 토큰은 일반 ERC-20처럼 전송, 거래가 가능합니다.
// ERC-4626 컨트랙트 내부
// 기초 자산 토큰
IERC20 public immutable asset;
// ERC-20에서 상속받은 잔액 매핑 (Share 잔액)
mapping(address => uint256) private _balances;
// 예시:
// _balances[alice] = 1000 // Alice는 1,000 Share 보유
// _balances[bob] = 500 // Bob은 500 Share 보유
자산과 Share의 변환
볼트의 핵심은 Asset ↔ Share 변환 비율입니다.
// Asset → Share 변환 (예치 시)
function convertToShares(uint256 assets) public view returns (uint256) {
uint256 supply = totalSupply();
return supply == 0 ? assets : (assets * supply) / totalAssets();
}
// Share → Asset 변환 (인출 시)
function convertToAssets(uint256 shares) public view returns (uint256) {
uint256 supply = totalSupply();
return supply == 0 ? shares : (shares * totalAssets()) / supply;
}
ERC-4626 핵심 함수들
1. 조회 함수 (View Functions)
// 기초 자산의 컨트랙트 주소 반환
function asset() public view returns (address)
// 볼트가 관리하는 총 자산 반환
function totalAssets() public view returns (uint256)
// 주어진 Asset 수량을 Share로 변환
// 예치 시 받을 Share 계산에 사용
function convertToShares(uint256 assets) public view returns (uint256)
// 주어진 Share 수량을 Asset으로 변환
// 인출 시 받을 Asset 계산에 사용
function convertToAssets(uint256 shares) public view returns (uint256)
// 특정 주소가 한 번에 예치 가능한 최대 자산
function maxDeposit(address receiver) public view returns (uint256)
// 특정 주소가 한 번에 발행 가능한 최대 Share
function maxMint(address receiver) public view returns (uint256)
// 특정 주소가 한 번에 인출 가능한 최대 자산
function maxWithdraw(address owner) public view returns (uint256)
// 특정 주소가 한 번에 상환 가능한 최대 Share
function maxRedeem(address owner) public view returns (uint256)
// 예치 시 받을 Share 미리보기 (수수료 등 반영)
function previewDeposit(uint256 assets) public view returns (uint256)
// 발행 시 필요한 Asset 미리보기
function previewMint(uint256 shares) public view returns (uint256)
// 인출 시 필요한 Share 미리보기
function previewWithdraw(uint256 assets) public view returns (uint256)
// 상환 시 받을 Asset 미리보기
function previewRedeem(uint256 shares) public view returns (uint256)
2. 예치/인출 함수 (Transaction Functions)
// Asset 기준 예치: 특정 양의 자산을 예치하고 Share를 받음
// assets: 예치할 자산 수량
// receiver: Share를 받을 주소
function deposit(uint256 assets, address receiver) public returns (uint256 shares)
// Share 기준 예치: 특정 양의 Share를 받기 위해 필요한 자산 예치
// shares: 받고 싶은 Share 수량
// receiver: Share를 받을 주소
function mint(uint256 shares, address receiver) public returns (uint256 assets)
// Asset 기준 인출: 특정 양의 자산을 인출
// assets: 인출할 자산 수량
// receiver: 자산을 받을 주소
// owner: Share를 소각할 주소
function withdraw(uint256 assets, address receiver, address owner) public returns (uint256 shares)
// Share 기준 인출: 특정 양의 Share를 상환하고 자산 받음
// shares: 상환할 Share 수량
// receiver: 자산을 받을 주소
// owner: Share를 소각할 주소
function redeem(uint256 shares, address receiver, address owner) public returns (uint256 assets)
3. 이벤트 (Events)
// 자산이 예치될 때 발생
event Deposit(
address indexed sender, // 자산을 보낸 주소
address indexed owner, // Share를 받은 주소
uint256 assets, // 예치된 자산 수량
uint256 shares // 발행된 Share 수량
)
// 자산이 인출될 때 발생
event Withdraw(
address indexed sender, // 인출을 실행한 주소
address indexed receiver, // 자산을 받은 주소
address indexed owner, // Share가 소각된 주소
uint256 assets, // 인출된 자산 수량
uint256 shares // 소각된 Share 수량
)
ERC-4626 컨트랙트 구현 예시
실제 ERC-4626 볼트 컨트랙트는 다음과 같이 구현됩니다
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract SimpleVault is ERC20 {
// 기초 자산 토큰
IERC20 public immutable _asset;
// 이벤트
event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares);
event Withdraw(address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 shares);
constructor(IERC20 assetToken) ERC20("Vault Token", "vTKN") {
_asset = assetToken;
}
// 기초 자산 주소 반환
function asset() public view returns (address) {
return address(_asset);
}
// 볼트가 보유한 총 자산
function totalAssets() public view returns (uint256) {
return _asset.balanceOf(address(this));
}
// Asset → Share 변환
function convertToShares(uint256 assets) public view returns (uint256) {
uint256 supply = totalSupply();
return supply == 0 ? assets : (assets * supply) / totalAssets();
}
// Share → Asset 변환
function convertToAssets(uint256 shares) public view returns (uint256) {
uint256 supply = totalSupply();
return supply == 0 ? shares : (shares * totalAssets()) / supply;
}
// 예치 미리보기
function previewDeposit(uint256 assets) public view returns (uint256) {
return convertToShares(assets);
}
// 상환 미리보기
function previewRedeem(uint256 shares) public view returns (uint256) {
return convertToAssets(shares);
}
// Asset 기준 예치
function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
require(assets > 0, "예치할 자산이 없습니다");
shares = previewDeposit(assets);
require(shares > 0, "발행될 Share가 없습니다");
// 자산을 볼트로 전송
_asset.transferFrom(msg.sender, address(this), assets);
// Share 발행
_mint(receiver, shares);
emit Deposit(msg.sender, receiver, assets, shares);
}
// Share 기준 인출 (redeem)
function redeem(uint256 shares, address receiver, address owner) public returns (uint256 assets) {
require(shares > 0, "상환할 Share가 없습니다");
// 호출자가 owner가 아니면 allowance 체크
if (msg.sender != owner) {
uint256 allowed = allowance(owner, msg.sender);
require(allowed >= shares, "승인 한도 초과");
_approve(owner, msg.sender, allowed - shares);
}
assets = previewRedeem(shares);
require(assets > 0, "인출할 자산이 없습니다");
// Share 소각
_burn(owner, shares);
// 자산 전송
_asset.transfer(receiver, assets);
emit Withdraw(msg.sender, receiver, owner, assets, shares);
}
// Asset 기준 인출 (withdraw)
function withdraw(uint256 assets, address receiver, address owner) public returns (uint256 shares) {
require(assets > 0, "인출할 자산이 없습니다");
shares = convertToShares(assets);
// 호출자가 owner가 아니면 allowance 체크
if (msg.sender != owner) {
uint256 allowed = allowance(owner, msg.sender);
require(allowed >= shares, "승인 한도 초과");
_approve(owner, msg.sender, allowed - shares);
}
// Share 소각
_burn(owner, shares);
// 자산 전송
_asset.transfer(receiver, assets);
emit Withdraw(msg.sender, receiver, owner, assets, shares);
}
}
핵심 작동 원리
1. 예치 (Deposit)
Alice가 1,000 USDC를 볼트에 예치하는 과정
- Alice가 볼트 컨트랙트에 USDC 사용 승인 (
approve) - Alice가
deposit(1000, alice)호출 - 볼트가 Alice의 USDC를 가져옴 (
transferFrom) - 볼트가 Share 토큰을 Alice에게 발행 (
_mint) Deposit이벤트 발생
// Alice가 1,000 USDC 예치
usdc.approve(vaultAddress, 1000);
vault.deposit(1000, alice);
// 컨트랙트 내부에서 일어나는 일:
// 1. Share 계산
uint256 shares = convertToShares(1000); // 예: 1,000 vUSDC
// 2. USDC를 볼트로 전송
usdc.transferFrom(alice, vault, 1000);
// 3. Share 토큰 발행
_mint(alice, 1000); // Alice에게 1,000 vUSDC 발행
emit Deposit(alice, alice, 1000, 1000);
2. 인출 (Redeem)
Alice가 Share 토큰을 반환하고 자산을 인출하는 과정
- Alice가
redeem(1000, alice, alice)호출 - 볼트가 Alice의 Share 토큰 소각 (
_burn) - 볼트가 Alice에게 USDC 전송 (
transfer) Withdraw이벤트 발생
// Alice가 1,000 vUSDC 상환 (수익 포함)
vault.redeem(1000, alice, alice);
// 컨트랙트 내부에서 일어나는 일:
// 1. 받을 자산 계산
uint256 assets = convertToAssets(1000); // 예: 1,100 USDC (수익 포함)
// 2. Share 소각
_burn(alice, 1000);
// 3. USDC 전송
usdc.transfer(alice, 1100);
emit Withdraw(alice, alice, alice, 1100, 1000);
3. Deposit vs Mint, Withdraw vs Redeem
ERC-4626은 동일한 작업을 두 가지 관점에서 수행할 수 있도록 함수를 제공합니다. 네 가지 함수 모두 ERC-4626 표준의 필수 함수입니다.
예치: Deposit vs Mint
| 함수 | 입력 | 출력 | 사용자 관점 |
|---|---|---|---|
deposit(assets, receiver) | 예치할 Asset 수량 | 받을 Share 수량 | "1,000 USDC를 넣을래" |
mint(shares, receiver) | 받을 Share 수량 | 필요한 Asset 수량 | "1,000 Share를 받을래" |
두 함수 모두 예치라는 같은 작업을 수행하지만, 입력값이 다릅니다.
deposit: 정확한 Asset 수량을 지정 → Share는 환율에 따라 계산mint: 정확한 Share 수량을 지정 → 필요한 Asset은 환율에 따라 계산
인출: Withdraw vs Redeem
| 함수 | 입력 | 출력 | 사용자 관점 |
|---|---|---|---|
withdraw(assets, receiver, owner) | 인출할 Asset 수량 | 소각될 Share 수량 | "1,000 USDC를 뺄래" |
redeem(shares, receiver, owner) | 반납할 Share 수량 | 받을 Asset 수량 | "1,000 Share를 반납할래" |
두 함수 모두 인출이라는 같은 작업을 수행하지만, 입력값이 다릅니다.
withdraw: 정확한 Asset 수량을 지정 → 소각될 Share는 환율에 따라 계산redeem: 정확한 Share 수량을 지정 → 받을 Asset은 환율에 따라 계산
왜 두 가지 방식이 필요한가?
실제 사용 시나리오에 따라 편리한 함수가 다릅니다.
Asset 기준이 편한 경우
- "지갑에 있는 1,000 USDC를 전부 예치하고 싶어"
- "이번 달 생활비로 500 USDC가 필요해"
Share 기준이 편한 경우
- "내 Share의 절반만 인출하고 싶어"
- "정확히 1,000 Share를 받아서 다른 프로토콜에 스테이킹하고 싶어"
// Asset 기준: 정확히 1,000 USDC 예치
vault.deposit(1000, alice); // Share는 환율에 따라 결정
// Share 기준: 정확히 1,000 vUSDC 받기
vault.mint(1000, alice); // 필요한 USDC는 환율에 따라 결정
// Asset 기준: 정확히 1,000 USDC 인출
vault.withdraw(1000, alice, alice); // 소각될 Share는 환율에 따라 결정
// Share 기준: 정확히 1,000 vUSDC 상환
vault.redeem(1000, alice, alice); // 받을 USDC는 환율에 따라 결정
OpenZeppelin으로 ERC-4626 볼트 만들기
ERC-4626 컨트랙트를 작성할 때는 OpenZeppelin ERC-4626의 검증된 구현체를 사용하는 것이 좋습니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MyVault is ERC4626 {
constructor(IERC20 asset_)
ERC4626(asset_)
ERC20("My Vault Token", "mvTKN")
{}
// 수익 창출 로직은 여기에 구현
// 예: 외부 프로토콜에 자산 예치, 수익 수확 등
}
주의사항
1. 인플레이션 공격 (Inflation Attack)
새로 만들어진 빈 볼트에서 발생할 수 있는 공격입니다.
핵심 아이디어
Share 계산 공식을 다시 보면:
받을 Share = 예치할 자산 × 총 Share / 총 자산
만약 총 자산이 엄청 크고 총 Share가 1개라면? 예치해도 Share를 0개 받게 됩니다.
공격 과정
- 공격자: 빈 볼트에 아주 작은 금액(1 wei) 예치 → 1 Share 받음
- 공격자:
deposit()이 아닌 일반transfer()로 볼트에 100만 토큰을 직접 전송- 이렇게 하면 Share는 그대로 1개인데, 볼트의 자산만 100만이 됨
- 1 Share = 100만 토큰의 가치
- 피해자: 50만 토큰을 예치하려고 함
- 계산: 50만 × 1 / 100만 = 0.5 → 내림하면 0
- 피해자는 50만 토큰을 넣었는데 Share를 0개 받음!
- 공격자: 자신의 1 Share로 볼트의 모든 자산(150만 토큰) 인출
결과: 공격자는 100만 토큰을 투자해서 150만 토큰을 가져감. 피해자는 50만 토큰을 잃음.
왜 이런 일이 발생하나요?
transfer()로 직접 토큰을 보내면 Share가 발행되지 않습니다. deposit()만이 Share를 발행하기 때문입니다. 공격자는 이 점을 악용해서 Share 가격을 인위적으로 높입니다.
방어 방법
// 볼트 생성 시 최소량의 Share를 영구 잠금
constructor() {
_mint(address(0xdead), 1000); // 아무도 사용할 수 없는 주소로 Share 전송
}
이렇게 하면 총 Share가 항상 최소 1000개 이상이므로, Share 가격을 극단적으로 올리기 어려워집니다.
OpenZeppelin의 ERC-4626 구현은 이 공격에 대한 방어 메커니즘을 포함하고 있습니다.
2. 슬리피지 (Slippage)
preview* 함수로 예상한 값과 실제 트랜잭션 결과가 다를 수 있습니다. 블록체인에서 트랜잭션이 실행되기 전에 다른 사용자의 예치/인출로 환율이 변할 수 있기 때문입니다.
예시: 예상과 다른 결과
- Alice가
previewDeposit(1000)호출 → 1000 Share 예상 - Bob이 먼저 대량 예치 → 환율 변동
- Alice의
deposit(1000)실행 → 950 Share만 받음
방어 방법
// 프론트엔드에서 슬리피지 허용 범위 설정
uint256 expectedShares = vault.previewDeposit(assets);
uint256 minShares = expectedShares * 99 / 100; // 1% 슬리피지 허용
uint256 actualShares = vault.deposit(assets, receiver);
require(actualShares >= minShares, "슬리피지 초과");
3. 수수료 처리
볼트가 예치/인출 수수료를 부과하는 경우, 반드시 preview* 함수에 수수료를 반영해야 합니다. 그렇지 않으면 사용자가 예상과 다른 결과를 받게 됩니다.
잘못된 구현 (수수료 미반영)
// preview에서 수수료를 반영하지 않음 - 잘못됨!
function previewDeposit(uint256 assets) public view returns (uint256) {
return convertToShares(assets); // 수수료 미반영
}
function deposit(uint256 assets, address receiver) public returns (uint256) {
uint256 fee = assets * 2 / 100; // 2% 수수료
uint256 shares = convertToShares(assets - fee); // 실제로는 수수료 차감
// 사용자는 예상보다 적은 Share를 받음!
}
올바른 구현 (수수료 반영)
// preview에서 수수료를 정확히 반영
function previewDeposit(uint256 assets) public view returns (uint256) {
uint256 fee = assets * 2 / 100; // 2% 수수료
return convertToShares(assets - fee);
}
function deposit(uint256 assets, address receiver) public returns (uint256) {
uint256 fee = assets * 2 / 100;
uint256 shares = convertToShares(assets - fee);
// preview와 동일한 결과
}