ERC-6909 최소 멀티토큰 인터페이스 (Minimal Multi-Token Interface)
Final · NFT·멀티토큰 · 2023-04-19 작성
하나의 컨트랙트에서 번호(id)별로 여러 토큰을 관리하는 최소 인터페이스입니다. ERC-1155에서 콜백과 일괄 전송을 빼고, 토큰 id별 한도 승인과 전체 운영자 승인을 함께 씁니다.
왜 필요한가
ERC-1155 멀티토큰 표준은 받는 쪽이 컨트랙트 계정이면 전송마다 콜백(callback) 함수를 호출합니다. 받는 컨트랙트는 정해진 값을 돌려줘야 합니다. ERC-721처럼 콜백 없는 전송 방식을 고를 수 없어 필요 없는 외부 호출이 생기고 받는 컨트랙트도 콜백 함수를 여럿 갖춰야 해서 코드가 커집니다. 일괄 전송도 명세에 들어 있어 롤업처럼 호출 데이터 크기를 줄여야 하는 환경에 맞게 인코딩을 바꾸기 어렵습니다. 권한도 운영자(operator) 한 가지뿐이라, 한 번 승인하면 그 계정이 컨트랙트 안의 모든 토큰 id를 무제한으로 옮길 수 있습니다. ERC-6909는 이런 요소를 걷어내고 여러 토큰을 한 컨트랙트에서 관리하는 데 꼭 필요한 최소 인터페이스만 남깁니다.
구현해야 하는 것
event Transfer(address caller, address indexed sender, address indexed receiver, uint256 indexed id, uint256 amount);: caller가 sender의 토큰 id를 receiver에게 amount만큼 옮겼을 때 기록합니다. 발행이면 sender가, 소각이면 receiver가 0 주소입니다.event OperatorSet(address indexed owner, address indexed spender, bool approved);: owner가 spender의 운영자 상태를 approved 값으로 설정했을 때 기록합니다.event Approval(address indexed owner, address indexed spender, uint256 indexed id, uint256 amount);: owner가 spender에게 토큰 id를 amount만큼 대신 옮길 권한을 설정했을 때 기록합니다.mapping(address owner => mapping(uint256 id => uint256 amount)) public balanceOf;: owner가 가진 토큰 id의 수량을 돌려주는 balanceOf(owner, id) 조회 함수입니다.mapping(address owner => mapping(address spender => mapping(uint256 id => uint256 amount))) public allowance;: spender가 owner를 대신해 옮길 수 있는 토큰 id의 수량을 돌려주는 allowance(owner, spender, id) 조회 함수입니다.mapping(address owner => mapping(address spender => bool)) public isOperator;: spender가 owner의 운영자인지 돌려주는 isOperator(owner, spender) 조회 함수입니다.function transfer(address receiver, uint256 id, uint256 amount) public returns (bool): 호출자의 토큰 id를 receiver에게 amount만큼 보냅니다. 잔액이 부족하면 되돌리고 성공하면 true를 돌려줍니다.function transferFrom(address sender, address receiver, uint256 id, uint256 amount) public returns (bool): sender의 토큰 id를 receiver에게 amount만큼 보냅니다. 호출자가 sender 본인이나 운영자가 아니면 allowance를 검사하고 줄입니다.function approve(address spender, uint256 id, uint256 amount) public returns (bool): spender가 호출자를 대신해 옮길 수 있는 토큰 id의 수량을 amount로 설정합니다.function setOperator(address spender, bool approved) public returns (bool): spender를 호출자의 모든 토큰 id에 대한 운영자로 지정하거나(true) 해제합니다(false).function supportsInterface(bytes4 interfaceId) public pure returns (bool supported): ERC-165 조회입니다. 0x0f632fb3 또는 0x01ffc9a7을 넣으면 true를 돌려줍니다.
심층 설명
정의와 인터페이스 ID
이 표준을 따르는 컨트랙트는 아래 인터페이스와 함께 ERC-165를 반드시 구현해야 하며 인터페이스 ID는 0x0f632fb3입니다. 용어는 원문의 정의를 따릅니다. infinite는 uint256의 최댓값(2 ** 256 - 1)입니다. caller는 현재 호출자(msg.sender)입니다. spender는 다른 계정을 대신해 토큰을 옮기는 계정이고 operator는 다른 계정의 모든 토큰 id에 무제한 전송 권한을 가진 계정입니다. mint는 토큰 생성으로, 별도 mint 메서드로 하거나 0 주소에서 오는 전송으로 처리할 수 있습니다(MAY). burn은 토큰 제거로, burn 메서드나 0 주소로 보내는 전송으로 처리할 수 있습니다(MAY).
조회 함수 세 가지
balanceOf(owner, id)는 owner가 가진 토큰 id의 총 수량(amount)을 돌려줍니다. allowance(owner, spender, id)는 spender가 owner를 대신해 옮길 수 있는 토큰 id의 총 수량입니다. isOperator(owner, spender)는 spender가 owner의 운영자로 승인되어 있으면 true를 돌려줍니다. 세 함수 모두 view이며 ERC-20의 balanceOf·allowance와 달리 토큰 id를 인자로 받습니다. 참조 구현에서는 세 값을 public mapping으로 두어 같은 이름의 조회 함수가 자동으로 만들어집니다.
transfer와 transferFrom의 필수 규칙
transfer(receiver, id, amount)는 호출자의 토큰 id를 receiver에게 보냅니다. 호출자의 잔액이 부족하면 반드시 되돌리고(MUST revert) Transfer 이벤트를 남기며 true를 돌려줘야 합니다. transferFrom(sender, receiver, id, amount)는 호출자가 sender의 토큰을 receiver에게 보냅니다. 호출자가 sender 본인도 아니고 sender의 운영자도 아니면서 그 id의 allowance가 부족하면 되돌려야 하고 sender의 잔액이 부족해도 되돌려야 합니다. 호출자가 운영자가 아니고 allowance가 무한(infinite)이 아니면 sender 잔액이 줄어든 만큼 호출자의 allowance를 줄여야 합니다(MUST). allowance가 무한이거나 호출자가 운영자 또는 sender 본인이면 allowance를 줄이지 않아야 합니다(SHOULD NOT). 두 함수 모두 Transfer 이벤트를 남기고 true를 돌려줘야 합니다.
approve와 setOperator
approve(spender, id, amount)는 호출자를 대신해 spender가 옮길 수 있는 토큰 id의 수량을 정합니다. 기존 값에 더하거나 빼는 것이 아니라 allowance를 amount로 설정해야 하며(MUST set) Approval 이벤트를 남기고 true를 돌려줘야 합니다. 증가·감소 메서드는 인터페이스를 줄이려고 일부러 뺐습니다. setOperator(spender, approved)는 spender에게 호출자의 모든 토큰 id에 대한 무제한 전송 권한을 주거나 거둡니다. 운영자 상태를 approved 값으로 설정하고 OperatorSet 이벤트를 남기며 true를 돌려줘야 합니다. 두 방식이 함께 있어서 특정 id에 정해진 양, 특정 id에 무한, 모든 id에 무한이라는 세 단계의 권한을 고를 수 있습니다.
이벤트 세 가지
Transfer 이벤트는 caller(인덱스 아님), sender, receiver, id(각각 indexed), amount(인덱스 아님)를 담습니다. 토큰 id의 수량이 한 계정에서 다른 계정으로 옮겨질 때 반드시 기록해야 하고 발행(mint) 때는 sender를 0 주소로, 소각(burn) 때는 receiver를 0 주소로 적어야 합니다. OperatorSet 이벤트는 owner, spender(각각 indexed)와 approved를 담으며 운영자 상태를 설정할 때 반드시 기록합니다. 이전과 같은 값으로 설정해도 기록할 수 있습니다(MAY). Approval 이벤트는 owner, spender, id(각각 indexed)와 amount를 담고 owner가 allowance를 설정할 때 반드시 기록합니다.
선택 확장: 메타데이터, 콘텐츠 URI, 총공급량
세 가지 확장은 모두 토큰 id별로 동작합니다. 메타데이터 확장은 name(id), symbol(id), decimals(id)를 두어 id마다 이름, 티커, 소수 자릿수(uint8)를 돌려줍니다. 콘텐츠 URI 확장은 컨트랙트 전체의 contractURI()와 토큰별 tokenURI(id)를 둡니다. tokenURI는 존재하지 않는 id에 대해 되돌릴 수 있으며(MAY revert) 돌려받은 URI 문자열 안의 {id}는 클라이언트가 바꿔 넣어야 합니다(MUST). 컨트랙트 메타데이터 JSON은 name이 필수이고 description, image_url, banner_image_url, external_link, editors, animation_url을 선택으로 둡니다. 토큰 메타데이터 JSON은 name, description, image가 필수이고 animation_url이 선택입니다. 토큰 공급 확장은 totalSupply(id)로 id별 총공급량(supply)을 돌려줍니다.
뺀 것과 그 이유, 하위 호환성
원문은 ERC-1155에서 뺀 요소의 이유를 설명합니다. 첫째, 필수 콜백은 콜백이 필요 없거나 자체 방식을 쓰려는 구현자에게 부담이므로 뺐습니다. 이렇게 하면 컨트랙트 크기, 가스, 복잡도를 줄일 수 있습니다. 둘째, 일괄 처리는 유용하지만 표준이 아니라 구현별로 정하는 편이 낫습니다. 롤업처럼 calldata를 전역 저장소에 기록하는 환경에서는 calldata 배치를 다르게 짜야 할 수 있기 때문입니다. 셋째, safeTransfer·safeTransferFrom이라는 이름은 실제로는 코드가 있는 수신 계정에 외부 호출을 해서 실행 흐름을 임의의 컨트랙트에 넘기므로 오해를 부릅니다. 필수 콜백과 safe라는 단어를 함께 없애 기본 제어 흐름을 더 안전하게 만들었습니다. 넷째, 일부 메서드가 빠졌기 때문에 ERC-1155와 하위 호환되지 않지만 ERC-20·ERC-721·ERC-1155용 래퍼는 만들 수 있습니다.
참조 구현의 처리 순서
참조 구현(Solidity 0.8.19)의 transferFrom은 sender != msg.sender이고 운영자도 아닐 때만 allowance를 읽습니다. allowance가 amount보다 작으면 InsufficientPermission(spender, id) 오류로 되돌리고 type(uint256).max가 아닐 때만 amount만큼 줄입니다. 그다음 잔액을 검사해 부족하면 InsufficientBalance(owner, id)로 되돌립니다. supportsInterface는 0x0f632fb3과 ERC-165 자체의 0x01ffc9a7에 true를 돌려줍니다. 발행과 소각은 내부 함수 _mint, _burn으로 두었고 Transfer 이벤트의 sender 또는 receiver에 address(0)을 넣습니다. 두 함수 앞에는 중요한 안전 검사를 먼저 해야 한다는 경고 주석이 있습니다.
쓸 때 주의할 점
- 위임 권한은 철회 전까지 유효합니다: allowance를 받은 계정은 승인이 철회되기 전까지 언제든 어떤 이유로든 한도 전액을 옮길 수 있습니다. 운영자 권한을 받은 계정은 철회 전까지 소유자의 모든 토큰 id를 어떤 수량이든 옮길 수 있습니다. 이는 모든 위임 권한 모델에 공통된 주의점입니다.
- 운영자와 한도 검사 순서: 한 계정이 운영자 권한은 있지만 특정 전송에 필요한 allowance가 부족할 때, allowance를 먼저 검사하면 되돌아가고 운영자를 먼저 검사하면 통과합니다. 명세는 운영자라도 allowance를 따로 추적하려는 구현을 위해 이 순서를 일부러 정하지 않았습니다. 원문 예시의 ERC6909AllowancePrecedence처럼 allowance를 무조건 빼면, 운영자 권한이 있어도 산술 언더플로로 실행이 멈춥니다.