ERC-6956 자산 결속 NFT (Asset-bound Non-Fungible Tokens)
Review · NFT·멀티토큰 · 2023-04-29 작성
실물이나 디지털 자산에 고유 식별자인 앵커(anchor)를 붙여 NFT와 1:1로 묶고 오라클이 자산을 실제로 쥐고 있음을 증명하면 기존 소유자의 서명 없이도 토큰을 옮길 수 있게 합니다.
왜 필요한가
ERC-721로 실물 상품 같은 오프체인 자산을 표현할 때는 보통 메타데이터에 자산 식별자를 적어 두는데, 메타데이터는 대부분 체인 밖에 있고 바뀔 수 있어서 토큰과 자산의 연결이 약합니다. 해시를 체인에 저장해 고정하면 이번에는 특성이나 플레이 시간처럼 바뀌어야 하는 메타데이터를 갱신할 수 없고 tokenURI가 가리키는 외부 저장소가 사라지는 사고도 있었습니다. 방향이 더 큰 문제입니다. 실물의 주인이 바뀌어도 기존 NFT 소유자가 협조하지 않거나 계정 접근권을 잃으면 체인 위 소유권을 옮길 방법이 없습니다. 자산을 실제로 갖고 있다는 사실이 체인 위 소유로 이어지지 않아, 주인이 수천 번 바뀔 수 있는 자산의 생애주기를 토큰이 따라가지 못합니다.
구현해야 하는 것
event AnchorApproval(address indexed owner, address approved, bytes32 indexed anchor, uint256 tokenId);: 어테스테이션으로 앵커 토큰의 승인 주소가 바뀌거나 재확인될 때 발생합니다.event AnchorTransfer(address indexed from, address indexed to, bytes32 indexed anchor, uint256 tokenId);: 앵커에 묶인 NFT의 소유권이 어떤 방식으로든 바뀔 때 ERC-721 Transfer와 함께 발생해 앵커 관점의 이력을 남깁니다.event AttestationUse(address indexed to, bytes32 indexed anchor, bytes32 indexed attestationHash, uint256 totalUsedAttestationsForAnchor);: 어테스테이션이 사용되어 같은 해시의 어테스테이션을 다시 받지 않음을 알립니다.event OracleUpdate(address indexed oracle, bool indexed trusted);: 오라클 주소의 신뢰 상태가 바뀔 때 발생합니다. 마지막 이벤트가 신뢰(true)인 오라클의 어테스테이션만 유효합니다.function anchorByToken(uint256 tokenId) external view returns (bytes32 anchor);: 토큰 ID에 1:1로 묶인 앵커를 돌려줍니다. 앵커 토큰이 아니면 0x0입니다.function tokenByAnchor(bytes32 anchor) external view returns (uint256 tokenId);: 앵커에 1:1로 묶인 토큰 ID를 돌려줍니다. 토큰이 아직 없으면 0입니다.function attestationsUsedByAnchor(bytes32 anchor) view external returns (uint256 attestationUses);: 특정 앵커의 상태 변경에 이미 사용된 어테스테이션 개수를 돌려줍니다.function decodeAttestationIfValid(bytes memory attestation, bytes memory data) external view returns (address to, bytes32 anchor, bytes32 attestationHash);: 어테스테이션이 유효하면 to 주소, 앵커, 어테스테이션 해시를 해독해 돌려줍니다. 이미 사용됐거나 서명자가 신뢰 오라클이 아니거나 유효기간 밖이면 실패합니다.function burnAuthorization() external view returns(Authorization burnAuth);: 소각 권한이 자산·소유자·발행자 중 누구에게 있는지 알려 줍니다.function approveAuthorization() external view returns(Authorization approveAuth);: 승인 권한이 자산·소유자·발행자 중 누구에게 있는지 알려 줍니다.function transferAnchor(bytes memory attestation) external;: 추가 데이터 없이 어테스테이션만으로 앵커에 묶인 NFT를 attestation.to로 이전합니다.function transferAnchor(bytes memory attestation, bytes memory data) external;: 어테스테이션의 앵커에 묶인 NFT 소유권을 to 주소로 바꿉니다. 누구나 제출할 수 있는 무허가 함수이며 토큰이 없으면 새로 민트합니다.function approveAnchor(bytes memory attestation) external;: 추가 데이터 없이 어테스테이션으로 앵커 토큰의 승인 주소를 지정합니다.function approveAnchor(bytes memory attestation, bytes memory data) external;: 앵커에 묶인 토큰을 attestation.to에 승인합니다. ERC-721.approve와 같은 동작이며 자산에 승인 권한이 없으면 실패합니다.function burnAnchor(bytes memory attestation) external;: 추가 데이터 없이 어테스테이션으로 앵커 토큰을 소각합니다.function burnAnchor(bytes memory attestation, bytes memory data) external;: 앵커에 묶인 토큰을 소각합니다. 자산에 소각 권한이 없으면 실패합니다.function attestationLimit(bytes32 anchor) external view returns (uint256 limit);: (AttestationLimited 확장) 앵커별 어테스테이션 사용 한도를 돌려줍니다. 앵커별 설정이 없으면 전역 한도를 따릅니다.function attestationUsagesLeft(bytes32 anchor) external view returns (uint256 nrTransfersLeft);: (AttestationLimited 확장) 앵커에 남은 어테스테이션 사용 횟수를 돌려줍니다.function floating(bytes32 anchor) external view returns (bool);: (Floatable 확장) 앵커 토큰이 어테스테이션 없이 전송 가능한 상태인지 알려 줍니다.function float(bytes32 anchor, FloatState newState) external;: (Floatable 확장) 앵커별로 플로팅 상태를 켜거나 끄거나 전역 기본값으로 되돌립니다. 권한 없는 호출은 실패합니다.function anchorValid(bytes32 anchor, bytes32[] memory proof) external view returns (bool isValid);: (ValidAnchors 확장) 머클 증명으로 앵커가 이 컨트랙트에서 유효한 앵커인지 검증합니다.
심층 설명
앵커와 토큰의 1:1 결속
앵커는 오프체인 자산을 유일하게 식별하는 값이고, 컨트랙트는 anchorByToken(tokenId)과 tokenByAnchor(anchor)의 양방향 매핑을 반드시 갖춰야 하며 앵커당 토큰은 최대 1개입니다. tokenId=0과 anchor=0x0은 무효로 정했는데, 솔리디티 매핑에서 존재하지 않는 키가 0을 돌려주는 성질을 이용해 존재 검사용 상태 변수를 생략하고 가스를 아끼기 위해서입니다. 배치 크기가 1을 넘는 전송은 금지되고 모든 전송은 AnchorTransfer(from, to, anchor, tokenId) 이벤트를 함께 내야 합니다. tokenURI(tokenId)는 baseURI/anchor 형태의 앵커 기반 URI가 권장되는데, 앵커가 처음 사용되기 전에는 앵커와 tokenId의 대응을 알 수 없기 때문입니다. 대량 생산품이라도 자산마다 소유 이력이 다르므로 멀티토큰이 아닌 ERC-721 확장을 택했습니다.
어테스테이션의 형식과 검증
어테스테이션은 to 주소, 앵커, 서명 시각 attestationTime, 유효 시작 validStartTime, 유효 종료 validEndTime을 abi 인코딩하고 오라클이 attestationHash = keccak256([to, anchor, attestationTime, validStartTime, validEndTime])에 대해 만든 65바이트 ETH 서명을 붙인 것입니다. decodeAttestationIfValid(attestation, data)는 이를 해독해 to, anchor, attestationHash를 돌려주되, 신뢰 오라클의 서명이 아니거나, 유효기간 밖이거나, 같은 attestationHash가 이미 사용됐으면 반드시 실패해야 합니다. 이 함수는 상태를 바꾸지 않으므로 어테스테이션을 소모하지 않고 유효성만 확인하는 데도 쓸 수 있습니다. 상태를 바꾸는 작업은 같은 트랜잭션 안에서 어테스테이션을 사용 처리(사용한 attestationHash 저장 권장)하고 attestationsUsedByAnchor를 1 올리며 AttestationUse 이벤트를 내야 합니다.
무허가 전송·승인·소각
transferAnchor, approveAnchor, burnAnchor는 모두 무허가(permissionless) 함수입니다. 현재 소유자(from)도 수령인(to)도 서명할 필요가 없고 누구나 트랜잭션을 제출할 수 있으며, 권한은 오직 오라클이 서명한 어테스테이션에서 나옵니다. transferAnchor는 ERC-721.safeTransferFrom과 같은 동작과 이벤트를 따르되 tokenByAnchor(anchor)가 0이면 NFT를 새로 민트하고 attestation.to가 이미 소유자이면 실패합니다. 승인과 소각 권한은 Authorization 열거형으로 OWNER(토큰 소유자), ISSUER(발행 컨트랙트), ASSET(어테스테이션)의 조합 중 하나로 정하고 burnAuthorization(), approveAuthorization()으로 조회합니다. 개인 키를 모르는 임의 계정이나 ERC-721을 지원하지 않는 컨트랙트로 토큰이 넘어간 경우에도 자산 증명만으로 승인과 소각이 가능하도록 하기 위한 설계입니다.
세 가지 선택 확장
AttestationLimited 확장은 앵커별 어테스테이션 사용 횟수에 상한을 둡니다. attestationLimit(anchor)와 attestationUsagesLeft(anchor)를 구현하고 한도 갱신 방향을 IMMUTABLE, INCREASE_ONLY, DECREASE_ONLY, FLEXIBLE 중 하나의 정책으로 고정해 attestationLimitPolicy()로 공개합니다. Floatable 확장은 토큰을 일시적으로 띄울 수 있게 합니다. floating(anchor)가 true면 해당 토큰은 어테스테이션 없이 통상의 ERC-721 방식(isApprovedOrOwner 승인)으로 전송할 수 있고 띄우기 시작과 중지 권한을 floatStartAuthorization과 floatStopAuthorization으로 따로 둡니다. ValidAnchors 확장은 유효한 앵커 집합을 머클 트리 루트 해시로 온체인에 기록하고 anchorValid(anchor, proof)로 검증합니다. 루트 갱신은 관리자(maintainer)만 할 수 있어 오라클이 손상돼도 새 앵커를 끼워 넣지 못합니다.
기본은 잠김, ERC-721과의 호환
앵커는 기본적으로 릴리스되지 않은 상태여야 하며 릴리스되지 않은 앵커의 토큰은 일반 ERC-721 전송이 모두 실패해야 합니다. 즉 기본 상태에서는 어테스테이션으로만 토큰이 움직입니다. Floatable 확장을 더하면 잘 알려진 ERC-721 동작에 어테스테이션 권한 메커니즘이 추가된 형태가 되어 ERC-721과 완전히 호환됩니다. 자주 쓰이는 _beforeTokenTransfer() 훅은 어테스테이션 기반 전송을 포함한 모든 전송에서 호출되어야 하므로, ERC-5192나 ERC-6982 같은 잠금 확장을 이 훅에서 함께 검사하면 일반 전송과 어테스테이션 전송을 모두 막을 수 있습니다. 마켓플레이스를 위해 ERC-6454, ERC-5484처럼 전송 가능 여부를 알리는 인터페이스로 보강하는 것도 권장됩니다.
실물 자산에 붙는 추가 요건
자산이 실물일 때 오라클은 to 주소를 지정하는 입력 장치와 물리적 앵커 사이의 물리적 근접(통상 수 밀리미터에서 수 미터)을 증명해야 하고 그 방법은 앵커의 복제·재현 시도와 스푸핑(제시 공격)에 강해야 합니다. 또 to 주소를 지정하는 쪽이 실물에 접근한 적 없이 거짓 어테스테이션을 얻으려는 악의적 당사자라는 가정 아래 구현해야 합니다. 실물 자산 자체는 고유 식별자 역할의 앵커(일련번호는 평문보다 해시 권장)와 물리적 존재를 증명할 보안 장치나 마킹을 갖춰야 하며 복제 불가능한 보안 특성을 가진 앵커 기술이 권장됩니다. 바코드나 일반 NFC 칩처럼 물리적으로든 디지털로든 쉽게 복제되는 기술은 권장되지 않습니다.
아직 검토 단계인 제안
이 제안은 Review 상태로, 검토 과정에서 인터페이스나 요구 사항이 바뀔 수 있으므로 구현 전에 최신 원문을 확인하는 것이 좋습니다. 표준 인터페이스의 ERC-165 식별자는 0xa9cf7635이고 AttestationLimited는 0x75a2e933, Floatable은 0xf82773f7, ValidAnchors는 0x051c9bd8입니다. 원문에는 표준 인터페이스만 구현한 최소 구현과 세 확장을 모두 지원하는 전체 구현, 어테스테이션을 생성하는 타입스크립트 예제, 두 구성에 대한 테스트가 함께 제공됩니다.
쓸 때 주의할 점
- 자산을 훔치면 NFT도 가져갑니다: 이 표준은 자산 통제를 그대로 토큰에 반영하므로, 자산을 훔친 사람이 통제를 증명하면 NFT도 가져갑니다. 오라클은 통제 사실만 증언할 뿐 정당한 소유자인지 판단하지 않습니다. 다만 범인이 앵커를 쓰는 순간 범행에 연결된 온체인 주소가 드러나 수사의 단서가 될 수 있고 잠금 메커니즘과 결합해 어테스테이션 기반 동작을 일시적 또는 영구적으로 막을 수도 있습니다.
- 가스 대납자 자금 고갈 공격: 어테스테이션 기반 동작은 무허가라서 자산 발행자가 가스비를 대신 내는 구조가 흔합니다. 악의적 사용자가 계정 주소를 바꿔 가며 오라클 증명을 반복해서 받아 대납자에게 넘기면 대납자의 자금이 고갈되어 시스템이 멈출 수 있습니다. AttestationLimited 확장으로 앵커당 사용 횟수를 제한해 방어합니다.
- 일련번호는 해시해서 쓰기: 순차 식별자를 평문으로 쓰면 최소·최대 번호만으로 시장에 풀린 자산 수량을 추정할 수 있어, 제품군별 판매량 같은 기밀 정보가 노출됩니다. 일련번호를 해시하거나 암호화해 실제 수량을 가리는 것이 가장 간단한 방법입니다.
- 오라클을 전적으로 신뢰하지 않기: 오라클은 유효 앵커의 머클 트리를 알고 있으므로 손상되면 악의로 트리를 바꿀 수 있습니다. 온체인 앵커 검증을 두고 트리 갱신은 오라클이 아닌 관리자만 할 수 있게 해, 오라클이 뚫려도 새 앵커를 추가하지 못하게 해야 합니다. 한 시스템이 뚫려도 오라클과 관리자 계정이 함께 뚫리지 않도록 오프체인에서 분리해 관리합니다.
- 머클 트리 재구성에 대비한 솔트 잎: 모든 앵커의 NFT가 민트되면 앵커가 전부 체인에 공개되어 머클 트리를 재구성할 수 있습니다. 오라클이 절대 어테스테이션을 발급하지 않는 솔트 잎(salt-leave) 하나를 트리에 넣어 두는 것이 권장됩니다.