ERC-7858 만료 기한이 있는 NFT·SBT (Expirable NFTs and SBTs)
Final · NFT·멀티토큰 · 2024-01-04 작성
NFT와 소울바운드 토큰(SBT)에 유효기간을 붙이는 ERC-721 확장 표준입니다. 정해진 블록 번호나 시각이 지나면 토큰이 만료 상태가 되어 회원권·티켓·구독권처럼 기간이 있는 용도를 표현할 수 있습니다.
왜 필요한가
회원권, 구독권, 행사 입장권, 쿠폰, 임대 계약처럼 일정 기간만 유효해야 하는 것을 NFT나 SBT로 만들려면 토큰에 만료 시점이 필요합니다. 이 표준이 나오기 전에는 각 프로젝트가 자체 매핑(mapping)으로 만료 기능을 구현했습니다. 그 결과 다른 스마트 컨트랙트와의 연동이 일관되지 않았고 표준을 벗어난 ERC-721 구현이 NFT 마켓플레이스에서 잘못 동작할 수 있는 문제가 있었습니다. 지갑이나 앱 입장에서는 토큰이 아직 유효한지 확인하는 공통된 방법이 없었습니다. 이 표준은 기존 인프라와 호환되는 만료 인터페이스를 표준으로 내장해 이 문제를 풉니다. 토큰마다 따로 기한을 두는 방식과 에포크(epoch, 기간 단위)로 묶어 관리하는 방식을 모두 지원해 용도에 맞게 고를 수 있습니다.
구현해야 하는 것
function expiryType() external view returns (EXPIRY_TYPE);: 이 컨트랙트가 쓰는 만료 기준이 블록 번호(BLOCKS_BASED)인지 시각(TIME_BASED)인지 반환합니다.function isTokenExpired(uint256 tokenId) external view returns (bool);: 해당 토큰이 만료됐으면 true, 아니면 false를 반환합니다. 존재하지 않는 tokenId면 되돌립니다(revert).function startTime(uint256 tokenId) external view returns (uint256);: 토큰의 유효 시작 시점을 반환합니다. expiryType에 따라 블록 번호 또는 타임스탬프로 해석합니다.function endTime(uint256 tokenId) external view returns (uint256);: 토큰의 유효 종료 시점을 반환합니다. expiryType에 따라 블록 번호 또는 타임스탬프로 해석합니다.function currentEpoch() external view returns (uint256);: 컨트랙트의 현재 에포크를 반환합니다. 유효·만료 상태를 판단하는 데 쓰입니다.function epochLength() external view returns (uint256);: 에포크 하나의 길이를 반환합니다. 단위는 expiryType에 따라 블록 수 또는 초입니다.function isEpochExpired(uint256 epoch) external view returns (bool);: 지정한 에포크가 만료됐으면 true를 반환합니다. 판정 로직은 구현체가 정의하고 문서화해야 합니다.function unexpiredBalanceOf(address account) external view returns (uint256);: 계정이 보유한 만료되지 않은(쓸 수 있는) 토큰의 총 개수를 반환합니다.function unexpiredBalanceOfAtEpoch(uint256 epoch, address account) external view returns (uint256);: 특정 에포크에서 계정이 보유한 만료되지 않은 토큰 수를 반환합니다. 그 에포크가 만료됐으면 0을 반환해야 합니다.function validityDuration() external view returns (uint256);: 토큰 하나의 유효 기간을 에포크 수 단위로 반환합니다.function getRemainingDurationBeforeTokenExpired(uint256 tokenId) public view returns (uint256);: 선택 함수입니다. 해당 토큰이 만료되기까지 남은 시간이나 블록 수를 반환합니다.function getEpochBalance(uint256 epoch) public view returns (uint256);: 선택 함수입니다. 지정한 에포크에 저장된 토큰 수량을 만료 여부와 무관하게 반환합니다.function getEpochInfo(uint256 epoch) public view returns (uint256,uint256);: 선택 함수입니다. 지정한 에포크의 시작과 끝을 함께 반환합니다.function getNearestExpiryOf(address account) public view returns (uint256);: 선택 함수입니다. 만료에 가장 가까운 tokenId 목록과 예상 만료 블록 번호 또는 타임스탬프를 반환합니다.function getRemainingDurationBeforeEpochChange() public view returns (uint256);: 선택 함수입니다. 다음 에포크로 바뀌기까지 남은 시간이나 블록 수를 반환합니다.
심층 설명
ERC-721을 상속하는 확장 표준
호환 구현은 IERC7858 인터페이스를 구현해야 하고 ERC-721 인터페이스를 상속해야 합니다. 인터페이스에 정의된 모든 함수가 있어야 하며 함수 동작도 명세의 요구사항을 충족해야 합니다. ERC-165 supportsInterface용 인터페이스 ID는 IERC7858이 0x3ebdfa31, 에포크 확장 IERC7858Epoch가 0xec7ffd66입니다. 이 표준은 ERC-721, ERC-5484를 비롯한 다른 SBT와 완전히 호환됩니다.
만료 기준: 블록 번호 또는 타임스탬프
expiryType은 컨트랙트가 쓰는 만료 기준을 반환해야 합니다. EXPIRY_TYPE 열거형은 BLOCKS_BASED(block.number 기준)와 TIME_BASED(block.timestamp 기준) 두 값을 가지며 startTime과 endTime이 어느 단위로 해석되는지도 이 값에 따라 정해집니다. 설계 근거에 따르면 블록 기반 만료는 네트워크 활동에 의존하고 정밀한 일관성이 필요한 응용에 맞고 시간 기반 만료는 블록 간격이 일정하지 않은 네트워크에 적합합니다. startTime과 endTime의 반환형은 uint256이며 원문 주석은 ERC-5007처럼 uint64로 반환하면 block.number 기반에는 적합하지 않을 수 있다고 적습니다.
startTime·endTime 규칙과 무기한 토큰
startTime은 endTime보다 작거나 같아야 하며 둘 다 0인 경우를 제외하면 startTime이 endTime보다 엄격히 작은 것이 권장됩니다. startTime과 endTime이 모두 0이면 그 tokenId는 만료가 없다는 뜻입니다. startTime이 0이 아니고 endTime이 0인 토큰도 만료가 없는 것으로 취급합니다. 존재하지 않는 tokenId를 조회하면 이 함수들은 isTokenExpired와 같은 방식으로 되돌려야 합니다. TokenExpiryUpdated 이벤트는 토큰이 발행될 때와 startTime이나 endTime이 갱신될 때 반드시 발생해야 합니다.
isTokenExpired의 반환값과 에러 처리
isTokenExpired는 주어진 tokenId의 상태를 조회하며 토큰이 만료됐으면 true를 반환해야 하고 tokenId가 존재하지 않으면 되돌려야 합니다. 커스텀 에러를 쓰는 구현은 ERC-6093을 따라 ERC721NonexistentToken으로 되돌리는 것이 권장됩니다. 솔리디티 v0.8.4 미만을 쓰거나 문자열 에러를 선호하는 구현은 NonexistToken으로 되돌리는 것이 권장됩니다. tokenId가 존재하고 만료되지 않았으면 false를 반환해야 합니다.
만료돼도 전송과 잔액 집계는 그대로
ERC-721에서 상속한 balanceOf는 만료된 토큰을 포함해 모든 토큰을 반환해야 합니다. 만료된 토큰을 체인 위에서 추적하기 어렵다는 한계 때문에, 토큰은 존재하되 쓸 수 없는 상태로 남습니다. NFT의 transferFrom과 safeTransferFrom은 만료된 토큰의 전송도 허용해야 합니다. 이미 배포된 애플리케이션과의 호환성과 거래 가능성을 지키기 위해서입니다. 다만 유효성을 검사하는 컨트랙트에서는 만료된 토큰을 무효이고 사용 불가한 것으로 취급해야 합니다. 원문은 이 설계를 '먼저 해를 끼치지 않는다(First, do no harm)'는 원칙으로 설명합니다. 만료 기능을 추가 계층으로 얹어 기존 사용처를 방해하지 않고 만료를 근본적 변경이 아닌 보강으로 다루는 것입니다.
에포크 확장: 기간 단위로 묶어서 만료
에포크는 특정 토큰들이 유효한 기간이나 블록 범위를 뜻하며 ERC-7818의 개념을 빌렸습니다. 토큰은 하나의 에포크 아래 묶이고 같은 validityDuration을 공유합니다. 에포크 기반 만료가 필요한 구현은 기본 IERC7858과 함께 IERC7858Epoch를 구현해야 하고 두 인터페이스의 요구사항을 모두 충족해야 합니다. currentEpoch는 현재 에포크를, epochLength는 에포크 사이의 길이를 블록 수나 초 단위로, validityDuration은 토큰의 유효 기간을 에포크 수로 반환해야 합니다. isEpochExpired는 해당 에포크가 만료됐으면 true를 반환해야 합니다. unexpiredBalanceOf는 계정이 보유한 만료되지 않은 토큰의 총 개수를 반환해야 하고 unexpiredBalanceOfAtEpoch는 특정 에포크에서 보유한 만료되지 않은 토큰 수를 반환하되 그 에포크가 만료됐으면 이전에 토큰이 있었더라도 0을 반환해야 합니다.
필수는 아닌 편의 함수들
표준은 요구사항에 따라 선택적으로 구현할 수 있는 OPTIONAL 함수도 제시합니다. 기본 쪽에는 getRemainingDurationBeforeTokenExpired가 있어 해당 tokenId가 만료되기까지 남은 시간이나 블록 수를 반환합니다. 에포크 확장 쪽에는 네 가지가 있습니다. getEpochBalance는 에포크가 만료됐더라도 그 에포크에 저장된 토큰 수량을 반환하고 getEpochInfo는 지정한 에포크의 시작과 끝을 함께 반환합니다. getNearestExpiryOf는 만료에 가장 가까운 tokenId 목록과 예상 만료 블록 번호 또는 타임스탬프를 반환하며 getRemainingDurationBeforeEpochChange는 에포크가 바뀌기까지 남은 시간이나 블록 수를 반환합니다.
쓸 때 주의할 점
- 소각 후 재발행에 따른 무단 갱신: 토큰을 소각한 뒤 같은 tokenId로 다시 발행할 때, 허가되지 않은 갱신이 일어나지 않도록 구현해야 합니다.
- 만료 시점의 무단 변경: startTime과 endTime은 권한이 있는 주체만 갱신할 수 있도록 구현해야 합니다.