ERC-4910 로열티 내장 NFT (Royalty Bearing NFTs)
Final · NFT·멀티토큰 · 2022-03-14 작성
NFT 표준(ERC-721)을 확장해 로열티의 정의·분배·지급을 모두 온체인에서 처리합니다. 원작과 파생작을 잇는 계층형 로열티 구조를 만들어 중앙 기관 없이 창작자에게 수익이 가게 합니다.
왜 필요한가
로열티 관리는 복잡한 계약과 불투명한 운영, 속임수와 사기가 많았던 오래된 문제입니다. 특히 원화에서 만든 판화, 다른 곡을 샘플링한 노래처럼 원작에서 파생작이 나오는 계층형 로열티에서는 문제가 더 심합니다. 원작자는 파생작이 팔리고 되팔릴 때마다 몫을 받아야 하지만 이를 보장할 장치가 없으면 중간 사업자가 지급을 조작하거나 아예 건너뛸 수 있습니다. 기존 NFT 거래에서도 구매자와 판매자가 컨트랙트 밖에서 돈을 주고받으면 로열티를 피해 갈 수 있습니다. 이 표준은 NFT 컨트랙트 자체가 판매와 결제에 직접 개입하게 만들어 법적으로 받을 권리가 있는 사람에게 로열티가 돌아가도록 설계합니다.
구현해야 하는 것
function getRoyaltyAccount (uint256 tokenId) public view virtual returns (address, RoyaltyAccount memory, RASubAccount[] memory);: tokenId에 연결된 로열티 계정과 그 하위 계정 목록을 조회합니다.function updateRoyaltyAccount (uint256 _tokenId, `RoyaltyAccount memory _raAccount) public virtual returns (bool): 로열티 계정과 하위 계정을 갱신합니다. 자산 식별자, 조상, 잔액 등은 바꿀 수 없고 자기 몫의 분배 비율 축소와 하위 계정 추가만 허용됩니다.function deleteRoyaltyAccount (uint256 _tokenId) public virtual returns (bool): 로열티 계정을 삭제합니다. 해당 NFT와 혈통상 연결된 NFT가 모두 소각되고 잔액이 전부 0일 때만 가능합니다.function mint(address to, NFTToken[] memory nfttoken, address tokenType) public virtual: 로열티 메타데이터와 함께 NFT를 하나 이상 발행하고 로열티 계정을 만들어 NFT와 소유자, 조상의 계정에 연결합니다.function listNFT (uint256[] calldata tokenIds, uint256 price, address tokenType) public virtual returns (bool): 직접 판매를 위해 NFT 한 개 이상을 가격과 결제 토큰 종류를 지정해 판매 목록에 올립니다.function removeNFTListing (uint256 listingId) public virtual returns (bool): 등록된 결제가 없을 때 판매 목록에서 해당 리스팅을 내립니다.function executePayment (address receiver, address seller, uint 256[] tokenIds, uint256 payment, string tokenType, int256 trxnType) public virtual nonReentrant returns (bool): ERC-20 토큰으로 NFT 구매 대금을 컨트랙트에 지불합니다. 직접 판매(0)면 바로 전송까지 처리하고 거래소 중개(1)면 결제를 등록해 둡니다.function reversePayment(uint256 paymentId, string memory tokenType) public virtual returns (bool): 판매가 완결되기 전에 등록된 결제를 구매자가 되돌려 받습니다.function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory _data) external virtual override: _data에 담긴 결제 정보를 검증하고 로열티를 분배한 뒤에 NFT 소유권을 넘깁니다. 이 표준에서 유일하게 허용되는 전송 함수입니다.function distributePayment (uint256 tokenId, uint265 payment) internal virtual returns (bool): 결제 금액을 로열티 계정 사슬을 따라 조상까지 거슬러 올라가며 각 하위 계정에 비율대로 배분하는 내부 함수입니다.function royaltyPayOut (uint256 tokenId, address RAsubaccount, address payable payoutAccount, uint256 amount) public virtual nonReentrant returns (bool): 하위 계정 소유자가 자기 로열티 잔액을 지정한 주소로 인출합니다.function _royaltyPayOut (uint256 tokenId, address RAsubaccount, address payable payoutAccount, uint256 amount) public virtual returns (bool): 전송 과정에서 쓰이는 내부 인출 함수로, 지급이 성공한 경우에만 잔액을 차감합니다.
심층 설명
로열티 계정과 하위 계정 구조
각 NFT는 tokenId를 통해 로열티 계정(Royalty Account, RA) 하나와 연결되고 RA에는 하위 계정이 하나 이상 있어야 합니다(R1). RA 식별자 raAccountId는 유일해야 하고(R2), tokenId와 연결되어야 합니다(R3). 하위 계정은 개인 계정이거나 다른 RA일 수 있으며 개인 계정만 인출 가능한 잔액을 가질 수 있습니다. 하위 계정이 RA인 경우 그 몫은 다시 그 RA의 하위 계정들로 재배분되어야 하므로 최종 잔액은 0이어야 합니다. 부모와 자식 NFT 사이에는 직접 연결이 있어야 하고(R4), 지원 토큰 종류 목록(R5)과 토큰별 마지막 검증 잔액(R6), 판매 목록(R7, R8), 등록된 결제 목록(R9, R10)도 유지해야 합니다.
민팅 규칙과 컨트랙트 소유 구조
NFT를 민팅할 때 RA와 하위 계정이 함께 생성되어 NFT, 소유자, 그리고 조상이 있으면 조상의 RA에 연결되어야 합니다(R18). 특이하게도 새 NFT의 owner는 NFT 컨트랙트 자신이어야 하고(R19), 실제 소유자는 isApproved로 지정되어 owner처럼 행동합니다(R20). 컨트랙트가 결제와 로열티의 에스크로 역할을 하기 위한 설계입니다. mint 입력에는 parent, canBeParent, maxChildren, royaltySplitForItsChildren, uri가 담기며(R21), 발행 수가 0이 아닐 것, msg.sender가 MINTER_ROLE 또는 CREATOR_Role일 것, 자식을 가질 수 없는 NFT는 royaltySplitForItsChildren이 0일 것, 부모의 세대 수준이 허용 최대 세대 수 미만일 것 등의 규칙이 적용됩니다(R23).
두 가지 판매 방식과 결제 처리
판매는 직접 판매(trxnType = 0)와 거래소 중개 판매(trxnType = 1)로 나뉩니다. 직접 판매는 listNFT와 removeNFTListing으로 컨트랙트를 통해 등록·해제해야 합니다(R24). 구매자는 항상 판매자가 아닌 NFT 컨트랙트에 지불합니다. ERC-20이면 approve 후 executePayment를 호출하고(R31), ETH 같은 프로토콜 토큰이면 msg.data에 seller, tokenId 배열, receiver, trxnType을 인코딩해 payable fallback 함수로 보냅니다(R39~R41). receive()는 msg.data를 받을 수 없어 허용되지 않습니다. 직접 판매는 결제 검증 후 내부 _safeTransferFrom으로 즉시 전송하고 리스팅을 제거하며(R37, R38), 중개 판매는 결제를 registeredPayment에 등록해 두었다가 별도의 safeTransferFrom에서 검증합니다(R36). 완결 전에는 reversePayment로 구매자가 결제를 되돌릴 수 있습니다(R44, R45).
전송 함수 제한과 _data 검증
로열티 우회를 막기 위해 전송 함수는 하나만 남깁니다. transferFrom과 data 없는 safeTransferFrom은 비활성화해야 합니다(R46). 유일한 safeTransferFrom은 ERC-721 인터페이스를 그대로 쓰되, 여러 토큰 전송 정보를 _data에 인코딩합니다. _data에는 판매자·구매자·수령인 주소, tokenId 배열, 결제 토큰 종류, 결제 금액, 등록된 결제 식별자, block.chainid가 최소한 담겨야 하고(R48), seller == from, 수령인이 판매자가 아닐 것, 각 토큰이 등록된 결제의 토큰과 일치할 것 등의 규칙을 통과해야 합니다(R49). 로열티가 모두 배분된 뒤에야 전송이 일어나고 전송은 _approve(to, tokenId[i])로 승인 주소를 바꾸는 방식이며 기술적 owner는 계속 컨트랙트입니다(R53). 승인 갱신 후 등록된 결제를 제거합니다(R54).
계층 분해 방식의 로열티 분배
distributePayment는 깊이 N인 로열티 트리의 재귀 문제를 층별 N개의 문제로 나눠 가장 아래층에서 뿌리 조상까지 거슬러 올라가며 처리합니다. 결제 금액은 전송에 포함된 모든 NFT에 균등하게 나뉩니다. 각 하위 계정에 대해 isIndividual이 true면 로열티 비율을 적용한 금액을 그 계정의 royaltybalance에 더하고 assetId, accountId, tokenType, royaltybalance를 담은 이벤트를 발생시킵니다. false면 금액을 임시 변수에 담아 두고 ancestor를 따라 다음 RA로 넘어가 같은 규칙을 반복하며 조상이 없고 모든 하위 계정이 개인인 RA에 도달하면 끝납니다(R51). 비율 계산은 100%를 10000 같은 정수로 표현해 곱한 뒤 나누므로 오버런·언더런을 주의해야 합니다.
로열티 인출과 전송 중 정산
인출 함수는 공개 버전과 내부 버전 두 가지입니다. 공개 royaltyPayOut은 msg.sender == RAsubaccount, 토큰이 존재하고 소각되지 않았을 것, 해당 하위 계정의 isIndividual == true, amount가 royaltybalance 이하일 것을 요구하며(R56), 자금이 나가므로 nonReentrant 보호가 붙습니다. 내부 _royaltyPayOut은 지급을 보내고 성공한 경우에만 잔액을 차감합니다(R58, R59). 전송 과정에서는 먼저 from 주소(승인된 실소유자)의 하위 계정 잔액 전부를 from에게 지급하고 royaltybalance가 0이 된 뒤에야 하위 계정 소유자를 to로 갱신합니다(R52).
계정 갱신·삭제의 제약
updateRoyaltyAccount에서는 자산 식별자, 조상, 결제 토큰 종류, 하위 계정 잔액, 부모에게서 상속된 분배 비율을 바꿀 수 없습니다. 모든 분배 비율의 합은 항상 1이어야 하고 msg.sender는 수정 대상 RA의 하위 계정 소유자여야 하며 자기 비율은 줄이는 것만 가능합니다. 줄인 만큼 기존 계정의 비율을 올리거나 잔액 0인 새 하위 계정을 추가해야 합니다(R15). deleteRoyaltyAccount는 가스 비용이 매우 큰 작업으로, 해당 tokenId가 소각되었고 혈통상 연결된 조상·후손 토큰이 모두 소각되었으며 하위 계정 잔액이 전부 0일 때만 허용됩니다(R17). 규칙을 지키지 않으면 트리 일부가 무너져 자금이 잠길 수 있습니다.
쓸 때 주의할 점
- 재진입과 무단 인출: 모든 결제 함수에 재진입(reentrancy) 보호를 적용해 재진입 공격을 막습니다. 인출은 최소한 msg.sender가 해당 하위 계정 소유자일 것을 요구해 무단 지급을 차단합니다. executePayment가 실패해 대금이 컨트랙트에 묶이면 reversePayment로 되돌릴 수 있습니다.
- 로열티 우회: 오프체인에서 개인키를 돈 받고 넘기는 방식은 어떤 설계로도 막을 수 없습니다. 여러 주소가 제어하는 스마트 컨트랙트 지갑이 NFT를 소유한 채 지갑 안에서 자산을 넘기는 우회는, 명시적으로 허용된 경우가 아니면 컨트랙트의 NFT 소유를 금지하는 방식으로 완화합니다.
- 로열티 분배 방해(가스 고갈): 공격자가 비율이 아주 낮은 하위 계정을 대량으로 추가하고 세대 한도까지 파생작을 계속 민팅하면, 재귀적 분배 함수에서 가스 고갈이나 실행 오류를 일으킬 수 있습니다. NFT당 하위 계정 수와 분배 비율에 한도를 두고 한 리스팅에 넣을 수 있는 NFT 수와 maxNumberGeneration을 제한해 완화합니다.
- 피싱 공격: 이 설계에서는 컨트랙트 자신이 모든 NFT의 owner이므로 approve와 setApprovalForAll을 노리는 일반적인 NFT 피싱이 통하지 않습니다. 다만 피해자를 속여 아주 낮은 가격에 리스팅하게 한 뒤 즉시 결제하는 공격이 가능하므로, 구매 확정 전 대기 시간을 두거나 2단계 인증을 요구하는 완화책이 제시됩니다.
- 파라미터 설계 책임: NFT 패밀리의 창작자는 비즈니스 모델을 신중히 검토해 최대 세대 수, 하위 계정 수, 파생작 수, 리스팅당 NFT 수, 분배 비율의 상한과 하한을 정해야 합니다. 보안 분석 도구 사용과 별도의 보안 감사도 권장됩니다.