ERC-4626 토큰화 금고 표준 (Tokenized Vaults)
Final · 금고·DeFi · 2021-12-22 작성
하나의 ERC-20 토큰을 맡기고 그 지분을 토큰으로 받는 금고(Vault)에 공통 입출금 인터페이스를 정합니다. 지갑과 앱이 금고마다 다른 연동 코드를 짜지 않아도 됩니다.
왜 필요한가
이더리움에는 토큰을 맡기면 지분 토큰을 돌려주는 금고 형태의 서비스가 많습니다. 대출 시장, 여러 곳에 자금을 나눠 넣는 애그리게이터(aggregator), 보유하기만 해도 이자가 붙는 토큰이 대표적인 예입니다. 그런데 이런 금고들은 표준이 없어 구현 세부가 저마다 다릅니다. 그래서 여러 표준을 한꺼번에 맞춰야 하는 애그리게이터나 플러그인 계층에서는 연동이 어렵고 프로토콜마다 자체 어댑터를 따로 만들어야 했습니다. 어댑터는 실수가 생기기 쉽고 개발 자원도 낭비합니다. 이 표준은 지분 토큰을 ERC-20으로 통일하고 입금, 출금, 조회 함수를 정해 수익형 금고를 연동하는 수고를 줄이고 구현 방식을 더 일관되고 튼튼하게 만드는 것을 목표로 합니다.
구현해야 하는 것
- name: asset type: function stateMutability: view inputs: [] outputs: - name: assetTokenAddress type: address: 금고가 회계와 입출금에 쓰는 기초 토큰의 주소를 돌려줍니다. 되돌리면 안 됩니다.- name: totalAssets type: function stateMutability: view inputs: [] outputs: - name: totalManagedAssets type: uint256: 금고가 관리하는 기초 자산의 총량을 돌려줍니다. 수익으로 불어난 몫과 수수료를 포함합니다.- name: convertToShares type: function stateMutability: view inputs: - name: assets type: uint256 outputs: - name: shares type: uint256: 주어진 자산량을 이상적인 조건에서 지분으로 바꾸면 얼마인지 계산합니다. 수수료와 슬리피지는 빼고 내림합니다.- name: convertToAssets type: function stateMutability: view inputs: - name: shares type: uint256 outputs: - name: assets type: uint256: 주어진 지분량을 이상적인 조건에서 자산으로 바꾸면 얼마인지 계산합니다. 수수료와 슬리피지는 빼고 내림합니다.- name: maxDeposit type: function stateMutability: view inputs: - name: receiver type: address outputs: - name: maxAssets type: uint256: receiver를 위해 deposit으로 넣을 수 있는 최대 자산량을 돌려줍니다. 한도가 없으면 2 ** 256 - 1입니다.- name: previewDeposit type: function stateMutability: view inputs: - name: assets type: uint256 outputs: - name: shares type: uint256: 지금 블록에서 assets를 맡기면 받을 지분을 미리 계산합니다. 실제 deposit은 이 값과 같거나 더 많이 줍니다.- name: deposit type: function stateMutability: nonpayable inputs: - name: assets type: uint256 - name: receiver type: address outputs: - name: shares type: uint256: 정확히 assets만큼 맡기고 지분을 receiver에게 발행합니다. Deposit 이벤트를 냅니다.- name: maxMint type: function stateMutability: view inputs: - name: receiver type: address outputs: - name: maxShares type: uint256: receiver를 위해 mint로 발행할 수 있는 최대 지분량을 돌려줍니다. 한도가 없으면 2 ** 256 - 1입니다.- name: previewMint type: function stateMutability: view inputs: - name: shares type: uint256 outputs: - name: assets type: uint256: 지금 블록에서 shares만큼 발행받으려면 낼 자산을 미리 계산합니다. 실제 mint는 이 값과 같거나 더 적게 가져갑니다.- name: mint type: function stateMutability: nonpayable inputs: - name: shares type: uint256 - name: receiver type: address outputs: - name: assets type: uint256: 정확히 shares만큼 지분을 receiver에게 발행하고 필요한 자산을 가져갑니다. Deposit 이벤트를 냅니다.- name: maxWithdraw type: function stateMutability: view inputs: - name: owner type: address outputs: - name: maxAssets type: uint256: owner 잔고에서 withdraw로 꺼낼 수 있는 최대 자산량을 돌려줍니다.- name: previewWithdraw type: function stateMutability: view inputs: - name: assets type: uint256 outputs: - name: shares type: uint256: 지금 블록에서 assets를 꺼내려면 태울 지분을 미리 계산합니다. 실제 withdraw는 이 값과 같거나 더 적게 태웁니다.- name: withdraw type: function stateMutability: nonpayable inputs: - name: assets type: uint256 - name: receiver type: address - name: owner type: address outputs: - name: shares type: uint256: owner의 지분을 태워 정확히 assets만큼 receiver에게 보냅니다. Withdraw 이벤트를 냅니다.- name: maxRedeem type: function stateMutability: view inputs: - name: owner type: address outputs: - name: maxShares type: uint256: owner 잔고에서 redeem으로 상환할 수 있는 최대 지분량을 돌려줍니다.- name: previewRedeem type: function stateMutability: view inputs: - name: shares type: uint256 outputs: - name: assets type: uint256: 지금 블록에서 shares를 상환하면 받을 자산을 미리 계산합니다. 실제 redeem은 이 값과 같거나 더 많이 줍니다.- name: redeem type: function stateMutability: nonpayable inputs: - name: shares type: uint256 - name: receiver type: address - name: owner type: address outputs: - name: assets type: uint256: owner의 지분을 정확히 shares만큼 태우고 자산을 receiver에게 보냅니다. Withdraw 이벤트를 냅니다.- name: Deposit type: event inputs: - name: sender indexed: true type: address - name: owner indexed: true type: address - name: assets indexed: false type: uint256 - name: shares indexed: false type: uint256: sender가 assets를 shares로 바꿔 owner에게 전달했을 때 발생합니다. deposit과 mint에서 냅니다.- name: Withdraw type: event inputs: - name: sender indexed: true type: address - name: receiver indexed: true type: address - name: owner indexed: true type: address - name: assets indexed: false type: uint256 - name: shares indexed: false type: uint256: sender가 owner의 shares를 assets로 바꿔 receiver에게 전달했을 때 발생합니다. withdraw와 redeem에서 냅니다.
심층 설명
지분은 ERC-20 토큰이어야 합니다
표준을 따르는 금고는 반드시 EIP-20을 구현해 지분(share)을 표현합니다. balanceOf, transfer, totalSupply 같은 EIP-20 동작은 모두 금고의 지분에 적용됩니다. 지분은 금고가 보유한 기초 자산 일부에 대한 소유권을 뜻합니다. 지분을 양도 불가로 만들고 싶다면 transfer와 transferFrom 호출을 되돌려도 됩니다(MAY). EIP-20의 선택 사항인 메타데이터 확장은 반드시 구현해야 합니다. name과 symbol은 기초 토큰의 name과 symbol을 어떤 식으로든 반영해야 합니다(SHOULD). 여러 서비스에서 지분 승인 경험을 개선하려면 EIP-2612를 구현할 수 있습니다(MAY).
네 가지 용어: asset, share, fee, slippage
asset은 금고가 관리하는 기초 토큰으로, 단위는 해당 EIP-20 컨트랙트가 정합니다. share는 금고의 토큰이며 mint, deposit, withdraw, redeem 때 기초 자산과 교환되는 비율은 금고가 정합니다. fee는 금고가 사용자에게 물리는 자산 또는 지분의 양으로, 입금, 수익, 운용 자산(AUM), 출금 등 금고가 정한 어느 항목에든 붙을 수 있습니다. slippage는 안내된 지분 가격과 실제 입출금 결과 사이의 차이 가운데 수수료로 설명되지 않는 부분입니다.
조회 함수: asset, totalAssets, convertToShares, convertToAssets
asset은 회계, 입금, 출금에 쓰는 기초 토큰 주소를 돌려주며 반드시 EIP-20 토큰 컨트랙트여야 하고 되돌리면 안 됩니다. totalAssets는 금고가 관리하는 기초 자산 총량으로, 수익에서 생긴 복리를 포함해야 하고(SHOULD) 금고 자산에 부과되는 수수료를 포함해야 하며(MUST) 되돌리면 안 됩니다. convertToShares와 convertToAssets는 모든 조건이 이상적일 때 금고가 교환해 줄 지분 또는 자산의 양입니다. 수수료를 포함하지 않고 호출자에 따라 달라지지 않습니다. 슬리피지 같은 온체인 조건도 반영하지 않습니다. 비정상적으로 큰 입력으로 정수 오버플로가 나는 경우 외에는 되돌리면 안 되고 반드시 0 쪽으로 내림합니다. 이 값은 특정 사용자가 아니라 평균적인 사용자가 기대할 지분당 가격을 보여 줍니다.
max 함수: 지금 가능한 최대량
maxDeposit과 maxMint는 receiver를 위해 deposit 또는 mint로 넣을 수 있는 최대 자산량과 최대 지분량입니다. maxWithdraw와 maxRedeem은 owner 잔고에서 withdraw 또는 redeem으로 꺼낼 수 있는 최대 자산량과 최대 지분량입니다. 네 함수 모두 실제 허용되는 최대치보다 높은 값을 돌려주면 안 되며 필요하면 더 적게 추정합니다. 전역 한도와 사용자별 한도를 모두 반영해야 하고 일시적으로라도 해당 동작이 막혀 있으면 0을 돌려줍니다. maxDeposit과 maxMint는 한도가 없으면 2 ** 256 - 1을 돌려줍니다. 사용자가 자산을 무한히 가진 것으로 가정하므로 asset의 balanceOf에 기대면 안 됩니다. 네 함수 모두 되돌리면 안 됩니다.
preview 함수: 같은 블록에서의 정확한 모의 계산
previewDeposit, previewMint, previewWithdraw, previewRedeem은 현재 블록의 온체인 조건에서 실제 호출 결과를 미리 계산합니다. previewDeposit은 deposit이 발행할 지분보다 많지 않은 값을, previewRedeem은 redeem이 돌려줄 자산보다 많지 않은 값을 돌려줍니다. previewMint는 mint가 가져갈 자산보다 적지 않은 값을, previewWithdraw는 withdraw가 태울 지분보다 적지 않은 값을 돌려줍니다. 즉 같은 거래 안에서 실제 함수는 preview보다 사용자에게 같거나 유리한 결과를 냅니다. 입금 또는 출금 수수료를 포함해야 하고 max 함수가 주는 한도나 승인 여부는 무시한 채 항상 받아들여진다고 가정합니다. 금고의 한도 때문에 되돌리면 안 되지만 실제 함수를 되돌리게 하는 다른 조건으로는 되돌릴 수 있습니다. convertTo 값과 preview 값이 불리하게 차이 나면 지분 가격의 슬리피지로 보아야 합니다.
입출금 함수: deposit, mint, withdraw, redeem
deposit은 정확히 assets만큼 기초 토큰을 맡기고 그에 해당하는 지분을 receiver에게 발행합니다. mint는 정확히 shares만큼 지분을 발행하고 필요한 자산을 가져갑니다. 둘 다 Deposit 이벤트를 내야 하고 asset에 대한 EIP-20 approve와 transferFrom 흐름을 지원해야 합니다. 실행 전에 기초 토큰이 금고로 미리 옮겨진 흐름도 추가로 지원할 수 있습니다. 전부 처리하지 못하면 되돌려야 합니다. withdraw는 owner의 지분을 태워 정확히 assets만큼 receiver에게 보냅니다. redeem은 정확히 shares만큼 태워 자산을 보냅니다. 둘 다 Withdraw 이벤트를 내야 하며 owner가 msg.sender인 경우와 msg.sender가 owner 지분에 EIP-20 승인을 받은 경우를 모두 지원해야 합니다. msg.sender가 owner의 자금을 쓸 수 있는지 allowance로 확인해야 합니다(SHOULD). withdraw는 자산을 지분으로 바꾼 뒤 그 지분에 대해 확인합니다. 일부 구현은 출금 전에 금고에 미리 요청하는 절차가 필요하며 그 절차는 별도로 수행합니다.
Deposit과 Withdraw 이벤트
Deposit 이벤트는 sender가 assets를 shares로 바꿔 그 지분을 owner에게 전달했음을 뜻합니다. mint와 deposit으로 토큰이 금고에 들어올 때 반드시 발생합니다. sender와 owner는 indexed이고 assets와 shares는 indexed가 아닙니다. Withdraw 이벤트는 sender가 owner 소유의 shares를 assets로 바꿔 그 자산을 receiver에게 전달했음을 뜻합니다. redeem과 withdraw로 지분이 빠져나갈 때 반드시 발생합니다. sender, receiver, owner가 indexed이고 assets와 shares는 indexed가 아닙니다.
설계 의도와 호환성
인터페이스는 연동하는 쪽에 맞춰 기능은 갖추되 최소한으로 설계했습니다. 맡긴 토큰의 회계와 배분 방식은 일부러 정하지 않았습니다. 금고는 온체인에서는 블랙박스로 다루고 쓰기 전에 오프체인에서 살펴보는 것을 전제로 합니다. EIP-20을 강제한 이유는 승인과 잔고 계산 같은 구현 세부가 지분 회계에 그대로 이어지고 기존 EIP-20 용도 전부와 바로 호환되기 때문입니다. mint는 대칭성과 완결성을 위해 넣었습니다. 지금은 특정 지분 수를 노리는 경우가 드물지만 지분 자체에 고유한 의미가 생기는 전략을 상상할 수 있기 때문입니다. convertTo 함수는 수수료를 고려하지 않는 대략적 추정치로 프런트엔드용입니다. 정확한 값은 변경 함수마다 짝지은 preview 함수가, 한도는 max 함수가 맡습니다. preview가 한도를 반영하지 않는 것은 조합하기 쉽게 하려는 선택입니다. 이 표준은 EIP-20과 완전히 호환되며 표준을 쓰지 않는 기존 금고는 래퍼 어댑터로 감쌀 수 있습니다. 상태는 Final입니다.
쓸 때 주의할 점
- 인터페이스만 맞춘 악성 구현: 누구나 쓸 수 있는 환경에서는 인터페이스는 맞추되 명세는 지키지 않는 악성 구현에 당할 수 있습니다. 연동하기 전에 사용자 예치금을 잃을 수 있는 경로가 있는지 구현을 검토하기를 권합니다.
- EOA 직접 호출 시 슬리피지 보호: 일반 외부 계정(EOA)이 금고를 직접 쓰게 하려면, 정확한 결과량이 나오지 않을 때 거래를 되돌릴 다른 수단이 없으므로 deposit, mint, withdraw, redeem에 슬리피지 손실이나 예상 밖의 한도를 다룰 수 있는 추가 함수를 두는 것을 고려해야 합니다.
- 조회 함수의 성격 차이와 가격 오라클: totalAssets, convertToShares, convertToAssets는 표시용 추정치이며 정확한 기초 자산량을 보장하지 않습니다. preview 함수는 가능한 한 정확한 값을 내기 때문에 온체인 조건을 바꿔 조작할 수 있어 가격 오라클로 쓰기에 항상 안전하지는 않습니다. convert 함수는 부정확해도 되므로 시간 가중 평균 가격 같은 견고한 오라클로 구현할 수 있습니다.
- preview 함수를 서로 바꿔 쓰지 않기: 지분을 상환해 받을 자산(previewRedeem)과 같은 수의 지분을 발행할 때 내야 할 자산(previewMint)은 크게 다를 수 있습니다. 반올림 오차처럼 작을 수도 있고 입출금 수수료가 있으면 매우 클 수도 있습니다. 용도에 맞는 preview 함수를 쓰고 서로 바꿔 써도 된다고 가정하면 안 됩니다.
- 반올림 방향과 decimals: 계산은 사용자보다 금고에 유리한 쪽이 가장 안전합니다. 사용자가 낸 자산에 대해 발행할 지분이나 돌려준 지분에 대해 보낼 자산을 계산할 때는 내림합니다. 원하는 자산을 받으려면 내야 할 지분이나 원하는 지분을 받으려면 내야 할 자산을 계산할 때는 올림합니다. 방향이 모호한 convertTo 함수는 일관성을 위해 항상 내림으로 고정했습니다. 올림이 필요한 연동자는 결과에 1 wei를 더하는 식으로 직접 흉내 낼 수 있습니다. decimals는 가능하면 기초 토큰과 같게 맞추기를 강하게 권합니다.