ERC-6059 부모 관리 중첩형 NFT (Parent-Governed Nestable Non-Fungible Tokens)
Final · NFT·멀티토큰 · 2022-11-15 작성
NFT가 다른 NFT를 소유할 수 있게 하는 표준입니다. 자식 토큰을 누구나 제안할 수 있지만 받아들일지는 부모 토큰의 소유자가 결정합니다.
왜 필요한가
기존 NFT 표준(ERC-721)에서는 토큰의 소유자가 일반 계정(EOA, Externally Owned Account)이나 스마트 컨트랙트여야 합니다. 그래서 여러 컬렉션의 NFT를 하나로 묶어 한 번에 팔거나, 내 기준대로 NFT를 그룹으로 정리하거나, 멤버십 NFT에 보너스 NFT를 직접 붙여 주는 일을 표준화된 방법으로 할 수 없었습니다. 또 아무 토큰이나 강제로 붙일 수 있다면 스팸이나 악성 토큰이 쏟아질 위험이 있습니다. 이 표준은 NFT가 NFT를 소유하는 부모·자식 관계를 정의하고 자식 토큰은 먼저 대기 목록에 들어간 뒤 부모 토큰의 최상위 소유자가 수락해야만 정식 자식이 되도록 합니다. 덕분에 묶음 판매, 수집 정리, 멤버십 보상, 투표 위임 같은 쓰임이 안전하게 가능해집니다.
구현해야 하는 것
event AllChildrenRejected(uint256 indexed tokenId);: 어떤 토큰이 대기 배열의 자식 토큰을 전부 거절해 비웠을 때 발생합니다.function ownerOf(uint256 tokenId) external view returns (address owner);: 토큰의 루트 소유자를 조회합니다. 토큰이 다른 NFT에 소유되어 있으면 NFT가 아닌 주소가 나올 때까지 부모를 재귀적으로 따라 올라가야 합니다.function burn(uint256 tokenId, uint256 maxRecursiveBurns) external returns (uint256);: 토큰을 소각하며 자식 토큰도 재귀적으로 함께 소각합니다. 소각할 자식이 maxRecursiveBurns보다 많으면 되돌려야 하고 재귀 소각된 자식 수를 반환합니다.function addChild(uint256 parentId, uint256 childId) external;: 부모 토큰의 대기 자식 배열에 자식 토큰을 추가합니다. 직접 호출해서는 안 되고 nestTransfer나 transferChild 과정에서 IERC6059 구현체가 호출해야 합니다.function rejectAllChildren(uint256 parentId, uint256 maxRejections) external;: 대기 배열의 자식 토큰을 전부 거절해 제거합니다. maxRejections보다 많은 자식이 있으면 실패해 호출 직전에 끼어든 토큰을 실수로 거절하는 일을 막습니다.function childrenOf(uint256 parentId) external view returns (Child[] memory);: 부모 토큰의 활성 자식 토큰 배열을 Child 구조체(tokenId, contractAddress) 배열로 반환합니다.function pendingChildrenOf(uint256 parentId) external view returns (Child[] memory);: 부모 토큰의 대기 자식 토큰 배열을 반환합니다.function childOf(uint256 parentId, uint256 index) external view returns (Child memory);: 활성 자식 배열에서 지정한 인덱스의 자식 토큰 하나를 반환합니다.function pendingChildOf(uint256 parentId, uint256 index) external view returns (Child memory);: 대기 자식 배열에서 지정한 인덱스의 자식 토큰 하나를 반환합니다.
심층 설명
소유 구조: 루트 소유자와 직접 소유자
ownerOf는 ERC-721과 같은 함수지만, 토큰이 다른 NFT에 소유되어 있으면 NFT가 아닌 주소가 나올 때까지 부모를 재귀적으로 따라 올라가 루트 소유자를 반환해야 합니다. directOwnerOf는 가장 가까운 소유자를 소유자 주소, 부모 토큰 ID, NFT 여부 플래그 3개 값으로 반환합니다. 직접 소유자가 NFT가 아니면 토큰 ID는 0이어야 합니다. 내부적으로는 DirectOwner 구조체(tokenId, ownerAddress)로 저장하며 소유자가 다른 토큰이면 ownerAddress는 부모 토큰 컬렉션 컨트랙트의 주소여야 합니다. 이 때문에 토큰 ID는 절대 0이면 안 됩니다. 0은 소유자나 목적지가 NFT가 아니라는 표시로 쓰이기 때문입니다.
제안-수락(propose-commit) 패턴
자식 토큰 추가는 반드시 제안-수락 패턴을 따라야 합니다. 자식 토큰은 먼저 부모의 대기(Pending) 배열에 들어가고 부모 토큰의 루트 소유자가 활성(Active) 배열로 옮겨야 정식 자식이 됩니다. 대기 배열은 스팸과 괴롭힘(griefing)을 막기 위해 128슬롯으로 제한하는 것이 권장됩니다(SHOULD). 악성·스팸 토큰 발행은 싸고 쉬우므로, 대기 배열을 제한해 정상 토큰이 스팸에 묻히지 않게 합니다. 대기 배열은 페이지네이션 없이 제한되는데, 보관용 버퍼가 아니라 정기적으로 수락·거절하며 관리해야 하는 후보 목록이기 때문입니다. 활성 배열은 제한이 없습니다. 루트 소유자만 수락할 수 있으므로 누구도 원치 않는 자식을 강제로 붙일 수 없습니다.
부모 관리(Parent Governed) 패턴과 권한 규칙
중첩된 토큰의 진짜 주인은 부모 NFT와 그 루트 소유자입니다. 루트 소유자나 승인된 자는 자식에 대해 acceptChild, rejectAllChildren, transferChild를 실행할 수 있어야 합니다(MUST). 반면 transferFrom, safeTransferFrom, nestTransferFrom, burn은 토큰이 NFT에 소유되지 않았을 때만 루트 소유자나 승인된 자가 실행할 수 있습니다. 토큰이 NFT에 소유되어 있으면 부모 NFT만 이 작업을 실행할 수 있고 전송은 부모의 transferChild를 거쳐야 합니다. transferChild는 목적지가 NFT인지에 따라 자식 컨트랙트의 nestTransferFrom 또는 safeTransferFrom을 호출하는 것이 권장됩니다. 이 제한은 transferChild만이 부모에서 자식을 제거하는 처리를 하므로 부모 컨트랙트의 상태 불일치를 막기 위한 것입니다. 중첩된 토큰을 소각하려면 먼저 EOA로 꺼낸 뒤 소각해야 합니다.
transferChild로 가능한 5가지 상태 전이
transferChild 하나로 자식 토큰의 5가지 상태 전이를 처리합니다. 1) 거절(reject): 대기 배열의 자식을 제거합니다. 2) 유기(abandon): 자식을 배열에서 제거하되 소유자를 갱신하지 않습니다(to가 0x0 주소인 경우). 3) 중첩 해제(unnest): 자식을 부모 밖으로 꺼냅니다. 4) EOA나 ERC721Receiver로 전송. 5) 새 부모 토큰으로 전송: 이 경우 자식은 새 부모의 대기 배열에 들어가며 새 부모의 루트 소유자가 수락해야 활성 배열로 옮겨집니다. isPending 매개변수로 대기 배열의 자식인지 활성 배열의 자식인지 지정합니다.
인덱스 기반 접근과 경쟁 조건 방지
자식을 수락·전송할 때 토큰 ID 대신 인덱스(childIndex)를 쓰는 이유는 가스 절약입니다. 토큰 ID로 찾으면 배열을 순회해야 해서 비용이 배열 크기에 비례하지만 인덱스는 비용이 고정됩니다. 다만 인덱스가 바뀌는 경쟁 조건을 막기 위해 기대하는 자식의 컬렉션 주소(childAddress)와 토큰 ID(childId)를 함께 전달해 인덱스 위치의 토큰이 기대한 토큰인지 검증합니다. rejectAllChildren의 maxRejections도 같은 목적으로, 호출 준비 중에 새 토큰이 대기 배열에 끼어들면 거래가 되돌려지도록 합니다. 내부에서 매핑으로 인덱스를 관리하는 구현도 시도했지만 자식 수락 비용이 20% 이상, 발행 비용이 15% 이상 늘어 표준에는 넣지 않았습니다.
재귀 소각과 순환 중첩 금지
burn(tokenId, maxRecursiveBurns)은 토큰을 소각할 때 자식 토큰도 재귀적으로 소각합니다. 소각할 자식이 maxRecursiveBurns보다 많으면 실행을 되돌려야 하고(MUST) 값을 0으로 주면 지정한 토큰만 소각을 시도하며 자식이 하나라도 있으면 되돌려야 합니다. 또 토큰을 자기 자식이나 그 하위 자식 안으로 중첩하는 것은 금지됩니다. 허용하면 소유 루프가 생겨 루프 안의 어떤 토큰도 관리할 수 없게 되기 때문입니다. 별도의 safe 중첩 전송 함수가 없는 이유는 nestTransfer가 목적지의 IERC6059 호환성을 반드시 확인해야 하므로 항상 안전하기 때문입니다.
ERC-721 호환성과 인터페이스 식별자
이 표준은 ERC-721을 확장하며 기존 ERC-721 도구와 인프라를 그대로 활용할 수 있도록 ERC-721과 호환되게 설계되었습니다. 하위 호환은 ERC-721에 대해서만 제공해 인터페이스를 단순하게 유지합니다. 중첩은 한 컨트랙트 안에 갇히지 않고 완전히 별개인 NFT 컬렉션 사이에서도 실행할 수 있습니다. ERC-165 인터페이스 식별자는 0x42b0e56f입니다. 이 제안은 Final 상태로 확정된 표준입니다.
쓸 때 주의할 점
- 숨겨진 로직 가능성: ERC-721과 같은 주의가 필요합니다. 소각, 자식 추가, 자식 수락을 비롯한 어떤 함수에도 숨겨진 로직이 들어 있을 수 있습니다.
- 판매 직전 자식 토큰 제거: 현재 소유자가 토큰을 관리할 수 있으므로, 부모 토큰을 판매 등록한 뒤 판매 직전에 자식 토큰을 빼내 구매자가 기대한 자식을 받지 못할 수 있습니다. 이는 표준 설계에 내재한 위험으로, 마켓플레이스는 판매 시점에 기대한 자식 토큰이 있는지 확인할 방법을 제공하거나 다른 방식으로 이런 악의적 행동을 막아야 합니다.
- 미감사 컨트랙트 주의: 감사받지 않은 컨트랙트를 다룰 때는 주의가 필요합니다.