ERC-1155 멀티 토큰 표준 (Multi Token Standard)
Final · NFT·멀티토큰 · 2018-06-17 작성
컨트랙트 하나로 대체 가능 토큰과 NFT 등 여러 종류의 토큰을 함께 관리하고 한 거래로 여러 토큰을 보낼 수 있게 하는 표준입니다.
왜 필요한가
ERC-20이나 ERC-721 같은 기존 토큰 표준은 토큰 종류나 컬렉션마다 컨트랙트를 하나씩 배포해야 합니다. 같은 코드가 블록체인에 계속 중복으로 올라가고 토큰마다 주소와 권한이 따로 나뉘어 함께 다루기 어렵습니다. 블록체인 게임처럼 아이템 종류가 수천 개에 이르면 이 방식으로는 감당하기 힘듭니다. 여러 토큰을 한꺼번에 옮기려면 거래를 종류마다 따로 보내야 해서 수수료가 늘고 거래소나 교환 컨트랙트를 쓰려면 토큰 컨트랙트 하나하나에 승인을 해 줘야 합니다. ERC-1155는 컨트랙트 하나 안에서 토큰 ID마다 다른 종류의 토큰을 정의하고 여러 토큰을 한 번에 보내는 길을 열어 이 문제를 풉니다. 게임에 한정된 표준은 아니며 이런 유연함이 필요한 다른 앱도 쓸 수 있습니다.
구현해야 하는 것
event TransferSingle(address indexed _operator, address indexed _from, address indexed _to, uint256 _id, uint256 _value);: 토큰 한 종류가 옮겨질 때 냅니다. 민트는 _from이 0 주소, 소각은 _to가 0 주소입니다.event TransferBatch(address indexed _operator, address indexed _from, address indexed _to, uint256[] _ids, uint256[] _values);: 여러 종류의 토큰이 한 번에 옮겨질 때 냅니다. _ids와 _values는 순서와 길이가 맞아야 합니다.event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);: 운영자에게 소유자의 모든 토큰 관리 권한을 주거나 거둘 때 냅니다.event URI(string _value, uint256 indexed _id);: 토큰 ID의 메타데이터 URI가 바뀔 때 냅니다. URI는 메타데이터 JSON 파일을 가리켜야 합니다.function safeTransferFrom(address _from, address _to, uint256 _id, uint256 _value, bytes calldata _data) external;: 토큰 한 종류를 _value만큼 보냅니다. 받는 쪽이 컨트랙트이면 onERC1155Received를 호출해 수락을 확인합니다.function safeBatchTransferFrom(address _from, address _to, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data) external;: 여러 종류의 토큰을 한 번에 보냅니다. 받는 쪽이 컨트랙트이면 수신 훅을 호출해 수락을 확인합니다.function balanceOf(address _owner, uint256 _id) external view returns (uint256);: 한 계정이 가진 특정 토큰 ID의 잔액을 돌려줍니다.function balanceOfBatch(address[] calldata _owners, uint256[] calldata _ids) external view returns (uint256[] memory);: 여러 (계정, 토큰 ID) 쌍의 잔액을 한 번에 돌려줍니다.function setApprovalForAll(address _operator, bool _approved) external;: 운영자가 호출자의 모든 토큰을 관리하도록 허용하거나 취소합니다. 성공하면 ApprovalForAll 이벤트를 냅니다.function isApprovedForAll(address _owner, address _operator) external view returns (bool);: 운영자가 소유자의 토큰을 관리할 권한이 있는지 돌려줍니다.function onERC1155Received(address _operator, address _from, uint256 _id, uint256 _value, bytes calldata _data) external returns(bytes4);: 받는 컨트랙트가 토큰 한 종류를 받을 때 호출됩니다. 수락하면 0xf23a6e61을 돌려주고 거절하면 revert합니다.function onERC1155BatchReceived(address _operator, address _from, uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data) external returns(bytes4);: 받는 컨트랙트가 여러 종류의 토큰을 받을 때 호출됩니다. 수락하면 0xbc197c81을 돌려주고 거절하면 revert합니다.function uri(uint256 _id) external view returns (string memory);: 토큰 ID의 메타데이터 URI를 돌려줍니다. 선택 확장 ERC1155Metadata_URI에 속합니다.
심층 설명
인터페이스 전체 구현과 ERC-165 식별자
ERC-1155를 구현하는 컨트랙트는 ERC1155 인터페이스의 함수를 모두 구현해야 합니다. 또 ERC-165의 supportsInterface를 구현하고 interfaceID로 0xd9b67a26이 들어오면 true를 돌려줘야 합니다. 모든 함수의 _id 인자는 거래에서 다루는 토큰 또는 토큰 종류를 가리킵니다. ERC-20처럼 종류마다 컨트랙트를 나누거나 ERC-721처럼 컬렉션 하나에 설정 하나를 두는 대신, 토큰 ID마다 메타데이터와 공급량 등 속성이 다른 토큰 종류를 둘 수 있습니다. 토큰을 받는 컨트랙트가 쓰는 ERC1155TokenReceiver 인터페이스의 식별자는 0x4e2312e0, 선택 확장인 ERC1155Metadata_URI의 식별자는 0x0e89341c입니다.
safeTransferFrom과 safeBatchTransferFrom 규칙
두 함수 모두 호출자가 _from 계정의 토큰을 관리할 권한이 있어야 하고 _to가 0 주소이면 되돌려야 합니다. 보유자의 잔액이 보내는 _value보다 적어도 되돌리고 그 밖의 오류에도 되돌립니다. 단일 전송은 TransferSingle 이벤트를 내야 하며 일괄 전송은 _ids와 _values의 길이가 다르면 되돌리고 TransferSingle 또는 TransferBatch 이벤트로 모든 잔액 변화를 남겨야 합니다. 일괄 전송에서 잔액 변경과 이벤트는 배열 순서(_ids[0]/_values[0]가 _ids[1]/_values[1]보다 먼저)를 따라야 합니다. 조건을 모두 만족한 뒤에는 _to가 컨트랙트인지(코드 크기 > 0) 확인하고 컨트랙트이면 onERC1155Received 또는 onERC1155BatchReceived를 호출해 결과에 따라 처리해야 합니다. 보내는 쪽이 넘긴 _data는 내용을 바꾸지 않고 그대로 훅에 전달해야 합니다. 표준은 안전 전송만 지원하므로 받는 컨트랙트는 전송 끝에 훅이 항상 불린다고 믿을 수 있습니다.
이벤트 규칙과 민트·소각
토큰이 옮겨질 때마다 TransferSingle 또는 TransferBatch가 나와야 하며 0 수량 전송과 민트·소각도 포함됩니다. _operator는 전송 권한이 있는 주소(msg.sender가 바람직), _from은 잔액이 줄어드는 보유자, _to는 잔액이 늘어나는 수신자입니다. 민트(생성)는 _from을 0x0으로, 소각(파괴)은 _to를 0x0으로 둔 특수한 전송으로 취급합니다. 소각 때 실제로 0x0에 보낼 필요는 없고 이벤트의 _to만 0x0이면 됩니다. 0x0에서 나온 총량에서 0x0으로 들어간 총량을 빼면 클라이언트와 거래소가 토큰 ID별 유통량을 구할 수 있습니다. 잔액 없이 토큰 ID의 존재만 알리려면 0x0에서 0x0으로, 생성자를 _operator로, _value를 0으로 한 TransferSingle을 내는 것이 바람직합니다. 컨트랙트 생성 시점에 초기 잔액을 넣는 경우도 이벤트를 내야 하며 컨트랙트 상태는 언제나 이벤트만으로 추적할 수 있어야 합니다. 모든 이벤트는 수신 훅을 부르기 전에 나와야 하고 받는 컨트랙트가 토큰을 다시 보내는 재진입이 있어도 순서가 맞도록 외부 호출 전에 상태 잔액과 이벤트 잔액이 일치해야 합니다.
받는 컨트랙트가 지켜야 할 ERC1155TokenReceiver 규칙
토큰을 받으려는 컨트랙트는 onERC1155Received와 onERC1155BatchReceived를 모두 구현하고 supportsInterface(0x4e2312e0)에 true를 돌려줘야 합니다. 이 supportsInterface는 0x01ffc9a7에도 true를 돌려주고 가스를 10,000 넘게 쓰면 안 됩니다. 훅은 잔액이 갱신되고 이벤트가 나온 뒤, 전송의 마지막에 호출됩니다. 전송을 받아들이면 onERC1155Received는 0xf23a6e61, onERC1155BatchReceived는 0xbc197c81을 돌려주고 거절하려면 revert합니다. 다른 값을 돌려주거나 revert하면 호출한 쪽이 거래를 되돌려야 합니다. 받는 쪽이 일반 계정(EOA)이면 훅을 부르지 않으며 민트나 전송 과정 밖에서도 부르지 않습니다. 한 거래에서 훅이 여러 번 불릴 수 있는데, 각 호출은 서로 겹치지 않는 잔액 변화를 나타내고 전체 호출을 모으면 제출 순서대로 모든 잔액 변화가 설명돼야 합니다. 훅 안에서 토큰을 다른 주소로 넘기는 경우, 이는 수락한 뒤 새 전송을 시작한 것으로 보고 성공하면 매직 값을 돌려줘야 합니다. 컨트랙트가 자기 자신에게 보내는 경우에는 훅을 건너뛸 수 있습니다.
비표준 전송 함수와 ERC-721 하이브리드
safeTransferFrom이 아닌 구현 고유의 전송 함수(예: myTransferFrom)를 써도 잔액 갱신과 이벤트 규칙은 똑같이 지켜야 하고 받는 쪽이 컨트랙트이면 훅을 부르고 반환값을 존중해야 합니다. 다만 표준 함수는 받는 컨트랙트가 ERC1155TokenReceiver를 구현하지 않으면 반드시 되돌려야 하는 반면, 비표준 함수는 되돌리는 것이 바람직하지만 진행할 수도 있습니다. 진행하려면 supportsInterface(0x4e2312e0)를 호출해 true가 오면 유효한 수신자인데 수락에 실패한 것이므로 되돌려야 하고 호출이 실패하거나 다른 값이 오면 성공으로 볼 수 있습니다. 이 경우 토큰을 받을 준비가 안 된 주소로 보내 토큰을 되찾지 못할 수 있습니다. ERC-721 등과 섞은 하이브리드 구현은 훅을 부르기 전에 받는 컨트랙트에 최소 10,000 가스를 주고 supportsInterface(0x4e2312e0)를 먼저 호출해야 합니다. true이면 일반 ERC-1155처럼 진행하고 아니면 다른 표준의 전송 규칙을 따를 수 있습니다. 어느 쪽이든 ERC-1155 전송 이벤트는 반드시 내야 합니다. 원문은 하이브리드보다 단일 표준의 순수 구현을 권합니다.
메타데이터 URI와 {id} 치환
선택 확장 ERC1155Metadata_URI를 넣으면 supportsInterface(0x0e89341c)에 true를 돌려주고 uri(_id) 함수를 제공합니다. URI는 RFC 3986 형식이며 ERC-1155 메타데이터 JSON 스키마를 따르는 JSON 파일을 가리켜야 합니다. URI 안에 {id} 문자열이 있으면 클라이언트는 이를 실제 토큰 ID의 16진수로 바꿔야 합니다. 이때 0x 접두사 없이 소문자 [0-9a-f]만 쓰고 64자가 되도록 앞을 0으로 채웁니다. 예를 들어 토큰 ID 314592(0x4CCE0)는 https://token-cdn-domain/{id}.json에서 {id}가 000000000000000000000000000000000000000000000000000000000004cce0으로 바뀝니다. JSON 값 안의 {id}도 같은 규칙으로 바꿉니다. URI가 바뀌면 이벤트로 표현할 수 있는 한 URI 이벤트를 내야 하고 민트 때 내는 것은 선택입니다. uri 함수는 마지막 이벤트와 같은 값을 돌려줘야 하며 토큰이 없어도 문자열을 돌려줄 수 있으므로 토큰 존재 확인용으로 쓰면 안 됩니다. JSON에는 name, decimals, description, image, properties 필드가 있고 localization 객체(uri, default, locales)를 두면 {locale}을 바꿔 언어별 데이터를 가져올 수 있습니다.
승인 방식과 설계 근거
setApprovalForAll은 운영자(operator)가 승인자의 토큰 전체를 대신 관리하게 하며 성공 시 ApprovalForAll 이벤트를 내야 합니다. isApprovedForAll로 승인 상태를 조회합니다. 토큰 ID 일부만 승인하려면 ERC-1761 범위 승인 인터페이스 같은 별도 인터페이스를 쓰도록 제안합니다. 소유자는 승인과 무관하게 항상 자기 토큰을 다룰 수 있다고 봐야 하므로 자신을 운영자로 승인할 필요가 없습니다. 설계 근거로는, symbol 함수는 일반 가상 자산을 식별하는 데 널리 쓸모 있지 않고 충돌이 잦아 뺐고 name 함수는 메타데이터 JSON을 이름의 기준으로 삼아 온체인 중복과 언어별 저장 비용을 줄이려고 뺐습니다. 단일 전송 함수를 따로 둔 이유는 단일 토큰 전송이 흔하고 훨씬 저렴하기 때문입니다. 새 주소로 다시 배포하는 업그레이드 때는 새 컨트랙트에서 이전 상태를 재현하는 이벤트를 내야 하며 최종 잔액만 반영한 최소한의 이벤트로 충분합니다. 이를 피하려면 EIP-2535 같은 프록시 패턴을 고려하라고 권합니다.
NFT와 대체 가능 토큰을 섞는 방법, 열거
표준은 NFT를 어떻게 표현할지 강제하지 않고 예시만 듭니다. 첫째는 ID 비트 분할로, uint256 _id의 상위 128비트를 기본 토큰 ID, 하위 128비트를 개별 NFT의 인덱스로 쓰는 방식입니다. 기본 ID와 0을 합친 값으로 종류 전체를, 인덱스를 더한 값으로 개별 토큰을 가리키며 이때 인덱스는 1부터 시작하는 것이 바람직합니다. 128은 임의의 값이라 구현마다 다르게 나눌 수 있고 관찰자가 ID만으로 종류를 알려면 그 구현의 분할 방식을 알아야 합니다. 둘째는 각 NFT의 최대 수량을 1로 두는 자연스러운 방식입니다. 열거(어떤 ID와 수량이 있는지 찾기)는 저장 공간을 아끼기 위해 이벤트 로그로 해야 하며 거래소와 탐색기는 배포 블록부터 TransferSingle, TransferBatch, URI 이벤트를 읽어 토큰 ID, 공급량, URI를 담은 로컬 DB를 두는 것을 권합니다. 일괄 전송 때 ID와 수량을 오름차순으로 정렬해 보내면 일부 구현에서 가스를 크게 아낄 수 있습니다. 이 표준의 상태는 Final입니다.