체인의정석

SBT와 관련된 표준 ERC 정리) 최소한의 표준인 ERC-5192 & 만료기한과 권한을 추가시켜서 관리하는 ERC-6147 설명 본문

블록체인/NFT & BRIDGE

SBT와 관련된 표준 ERC 정리) 최소한의 표준인 ERC-5192 & 만료기한과 권한을 추가시켜서 관리하는 ERC-6147 설명

체인의정석 2023. 9. 14. 20:13
728x90
반응형

1. 들어가며

먼저 SoulBoundToken이란 전송 불가능한 토큰으로서 최근 메타버스 분야를 비롯하여 많은 곳에서 인증의 수단이나 활용의 수단으로 쓰이고 있다. 필자는 서강대 메타버스전문대학원 과정의 학생으로서 SBT에 대한 공부를 좀 살펴보려는 취지에서 따로 조사를 진행하기 위하여 글로 정리하는 것이며 대략적으로 SBT는 전송이 불가능하다는 특징 외에는 아는 것이 없었는데 SBT와 관련된 공식 표준을 통해서 어떤 내용이 있는지 살펴보도록 하겠다. 필자도 처음 살펴보는 내용이므로 해당 내용은 투자정보 또는 100% 자세한 내용임은 보장할 수 없지만 더 전문적이고 자세한 연구글이 나오기 전까지 처음 SBT의 구현체를 접근하려는 사람들에게 도움을 주기 위하여 해당 글을 작성한다.

사실 SBT의 표준에 대한 글을 검색해봤는데 별로 자료가 많지 않은것을 확인했기에 공식 EIP docs를 보고 어떤 표준이 있는지 어떻게 구현하고 있는지 하나하나 살펴보도록 하겠다.

https://eips.ethereum.org/erc

 

ERC | Ethereum Improvement Proposals

Ethereum Improvement Proposals (EIPs) describe standards for the Ethereum platform, including core protocol specifications, client APIs, and contract standards.

eips.ethereum.org

여기서 SBT와 관련된 EIP들을 뽑아보자면 다음과 같다.

2. ERC-5192 : "최소한의 SBT 표준"

정말 간단한 표준이지만 공식 표준으로 등록이 되어 있다.

 

ERC-5192: Minimal Soulbound NFTs

Minimal interface for soulbinding EIP-721 NFTs

eips.ethereum.org

여기서는 딱 한가지 기능이 추가가 되었는데 locked라는 특정 토큰 아이디를 잠그는 기능이다.
그리고 해당 함수가 실행될 때 Locked와 Unlocked 이벤트를 남기는 것이다.

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.0;

interface IERC5192 {
  /// @notice Emitted when the locking status is changed to locked.
  /// @dev If a token is minted and the status is locked, this event should be emitted.
  /// @param tokenId The identifier for a token.
  event Locked(uint256 tokenId);

  /// @notice Emitted when the locking status is changed to unlocked.
  /// @dev If a token is minted and the status is unlocked, this event should be emitted.
  /// @param tokenId The identifier for a token.
  event Unlocked(uint256 tokenId);

  /// @notice Returns the locking status of an Soulbound Token
  /// @dev SBTs assigned to zero address are considered invalid, and queries
  /// about them do throw.
  /// @param tokenId The identifier for an SBT.
  function locked(uint256 tokenId) external view returns (bool);
}

 

해당 표준에 대해서 ERC5192 중 저자 한명이 자신의 깃허브에 공개해둔 코드를 찾을 수 있었다.

 

https://eips.ethereum.org/EIPS/eip-5192

EIP 문서에서 TimDaub를 누르면 해당 저자의 깃허브로 들어가는데 여기서 찾을 수 있었다.

https://github.com/attestate/ERC5192/blob/main/src/ERC5192.sol

*수정 (9/26)

구현체를 보고 표준을 다시 한번 살펴보니 생성시에 잠금 여부를 한번 정하고 SBT면 잠김 상태로 모든 컨트렉트가 그 이후에는 작동을 하고 잠금 상태가 아니라면 일반적인 NFT처럼 작동을 하는 모델이였다.

그리고 EIP712의 확장 규격인 만큼 해당 NFT를 다룰 때에는 EIP712에 있는 supportsInterface(bytes4 interfaceID) 조회함수에서 나오는 결과 값인 interfaceID=0xb45a3c0e 를 통해서 지원 여부를 체크할 수 있게 만들어 두었다.

3.  ERC-6147: "만료일 관리,  소유권, 전송권의 권한 분리를 지원하는 SBT 표준"

1번 보다 조금 더 자세한 내용이 정의 되어 있는 ERC이다.

https://eips.ethereum.org/EIPS/eip-6147

 

ERC-6147: Guard of NFT/SBT, an Extension of ERC-721

A new management role with an expiration date of NFT/SBT is defined, achieving the separation of transfer right and holding right.

eips.ethereum.org

설명을 보자면

"ERC-6147: ERC-721의 확장, NFT/SBT의 가드 NFT/SBT의 만료일을 가진 새로운 관리 역할이 정의되어 전송 권한과 보유 권한이 분리됩니다." 이렇게 되어있다. 만료일을 설정하는 관리 역할 및 전송 권한, 보유 권한의 분리가 포인트 인 것 같다.

그럼 이제 동기 부분을 한번 봐보자

"대체 불가능한 토큰은 사용 가치와 금전적 가치를 모두 지닌 자산입니다.

현재 많은 NFT 도난 사례가 존재하며, 콜드월렛으로 NFT를 전송하는 것과 같은 현재의 NFT 도난 방지 체계는 NFT를 사용하기 불편하게 만들고 있습니다.

현재 NFT 대출에서는 NFT 소유자가 NFT를 NFT 대출 계약에 따라 양도해야 하며, 대출을 받은 동안에는 NFT 소유자는 더 이상 NFT를 사용할 권리가 없습니다. 예를 들어, 현실 세계에서 어떤 사람이 자신의 집을 담보로 대출을 받더라도 여전히 그 집을 사용할 권리가 있습니다.

SBT의 경우, 현재 주류의 견해는 SBT는 양도할 수 없기 때문에 이더 주소에 묶여 있다는 것입니다. 그러나 사용자 주소의 개인 키가 유출되거나 분실될 경우 SBT를 복구하는 것은 복잡한 작업이 될 것이며 이에 상응하는 표준이 존재하지 않습니다. SBT는 기본적으로 NFT 보유권과 전송권의 분리를 실현합니다. SBT가 있는 지갑을 도난당하거나 사용할 수 없는 경우에도 SBT를 복구할 수 있어야 합니다.

또한 SBT는 사용 중에도 관리가 필요합니다. 예를 들어, 대학에서 졸업생에게 졸업장 기반 SBT를 발급하고 나중에 졸업생이 학업 부정행위를 저질렀거나 대학의 평판을 훼손한 사실을 알게 된 경우, 대학은 졸업장 기반 SBT를 회수할 수 있는 기능이 있어야 합니다."

일단 해당 동기를 보면 SBT에서 분실이 있을 경우에는 복구가 사실상 어려우며 대출 등에 있어서도 현실에서 집을 담보로 대출을 받아도 집을 사용할 수 있는것 처럼 이런 다양한 사례를 SBT가 다루려고 한다면 전송권과 보유권이 분리되어야 한다는 점을 시사한다. 그리고 회수 기능 또한 마찬가지로 SBT를 졸업 증명서로 보내주더라도 이에 대한 박탈에 대한 부분도 필요하기 때문에 해당 부분을 표준으로 제시해 주고 있다.

인터페이스 살펴보기

그럼 인터페이스 부터 살펴 보도록 하겠다. 먼저 가드에 대한 내용이 주를 이루고 있는데 이에 대한 내용은 다음과 같고

"ERC-721을 준수하는 계약은 이 EIP를 구현할 수 있습니다.

가드는 만료되기 전까지만 유효해야 합니다.

토큰에 가드가 없거나 가드가 만료된 경우, 가드정보는 (주소(0), 0)을 반환해야 합니다.

토큰에 가드가 없거나 가드가 만료된 경우, 토큰의 소유자, 승인된 운영자, 승인된 주소는 가드를 설정할 수 있는 권한이 있어야 하고 만료되어야 합니다.

토큰에 유효한 가드가 있는 경우, 토큰의 소유자, 승인된 운영자 및 승인된 주소는 가드를 변경할 수 없어야 하며 가드가 만료되면 토큰을 전송할 수 없어야 합니다.

토큰에 유효한 가드가 있는 경우, 가드정보는 가드의 주소와 만료일을 반환해야 합니다.

토큰에 유효한 가드가 있는 경우, 가드는 가드 및 만료 제거, 가드 및 만료 변경, 토큰 전송을 할 수 있어야 합니다.

토큰에 유효한 가드가 있을 때 토큰이 소각되면 가드는 반드시 삭제되어야 합니다.

SBT를 발행하거나 발행하는 경우, 관리를 용이하게 하기 위해 가드를 지정된 주소로 일률적으로 설정할 수 있습니다."

이를 코드로 본다면 다음과 같다.

 interface IERC6147 {

    /// Logged when the guard of an NFT is changed or expires is changed
    /// @notice Emitted when the `guard` is changed or the `expires` is changed
    ///         The zero address for `newGuard` indicates that there currently is no guard address
    event UpdateGuardLog(uint256 indexed tokenId, address indexed newGuard, address oldGuard, uint64 expires);
    
    /// @notice Owner, authorised operators and approved address of the NFT can set guard and expires of the NFT and
    ///         valid guard can modifiy guard and expires of the NFT
    ///         If the NFT has a valid guard role, the owner, authorised operators and approved address of the NFT
    ///         cannot modify guard and expires
    /// @dev The `newGuard` can not be zero address
    ///      The `expires` need to be valid
    ///      Throws if `tokenId` is not valid NFT
    /// @param tokenId The NFT to get the guard address for
    /// @param newGuard The new guard address of the NFT
    /// @param expires UNIX timestamp, the guard could manage the token before expires
    function changeGuard(uint256 tokenId, address newGuard, uint64 expires) external;

    /// @notice Remove the guard and expires of the NFT
    ///         Only guard can remove its own guard role and expires
    /// @dev The guard address is set to 0 address
    ///      The expires is set to 0
    ///      Throws if `tokenId` is not valid NFT
    /// @param tokenId The NFT to remove the guard and expires for
    function removeGuard(uint256 tokenId) external;
    
    /// @notice Transfer the NFT and remove its guard and expires
    /// @dev The NFT is transferred to `to` and the guard address is set to 0 address
    ///      Throws if `tokenId` is not valid NFT
    /// @param from The address of the previous owner of the NFT
    /// @param to The address of NFT recipient 
    /// @param tokenId The NFT to get transferred for
    function transferAndRemove(address from, address to, uint256 tokenId) external;

    /// @notice Get the guard address and expires of the NFT
    /// @dev The zero address indicates that there is no guard
    /// @param tokenId The NFT to get the guard address and expires for
    /// @return The guard address and expires for the NFT
   function guardInfo(uint256 tokenId) external view returns (address, uint64);   
}

하나하나 가볍게 살펴보고 넘어가보자

    /// Logged when the guard of an NFT is changed or expires is changed
    /// @notice Emitted when the `guard` is changed or the `expires` is changed
    ///         The zero address for `newGuard` indicates that there currently is no guard address
    event UpdateGuardLog(uint256 indexed tokenId, address indexed newGuard, address oldGuard, uint64 expires);

이건 가드 부분에 대한 상수가 업그레이드 되는 부분이다.

expires가 여기에 추가가 되었는데 해당 부분을 통해서 기간을 넣어주고 있으며 이전관리자와 새 관리자를 각각 newGuard와 oldGuard로 두고 있다.

    /// @notice Owner, authorised operators and approved address of the NFT can set guard and expires of the NFT and
    ///         valid guard can modifiy guard and expires of the NFT
    ///         If the NFT has a valid guard role, the owner, authorised operators and approved address of the NFT
    ///         cannot modify guard and expires
    /// @dev The `newGuard` can not be zero address
    ///      The `expires` need to be valid
    ///      Throws if `tokenId` is not valid NFT
    /// @param tokenId The NFT to get the guard address for
    /// @param newGuard The new guard address of the NFT
    /// @param expires UNIX timestamp, the guard could manage the token before expires
    function changeGuard(uint256 tokenId, address newGuard, uint64 expires) external;

다음 가드를 바꾸는 부분이다. 가드는 여기서 만료기간이나 가드를 할지 말지에 대한 여부를 정할 수 있다.

오너거나 approve를 받은 주소 또는 타당한 가드를 받은 주소 등이라면 가드로서 정의가 가능하다고 되어 있다.

그리고 expires는 UNIX 타임스탬프를 반영한다.

    /// @notice Remove the guard and expires of the NFT
    ///         Only guard can remove its own guard role and expires
    /// @dev The guard address is set to 0 address
    ///      The expires is set to 0
    ///      Throws if `tokenId` is not valid NFT
    /// @param tokenId The NFT to remove the guard and expires for
    function removeGuard(uint256 tokenId) external;

removeGuard는 특정 nft id를 지정했을 때 해당 아이디에 지정된 가드를 제거하고 expries 기간을 0으로 만든다.

토큰 아이디가 유효한지 여부도 검사해야 한다.

    /// @notice Transfer the NFT and remove its guard and expires
    /// @dev The NFT is transferred to `to` and the guard address is set to 0 address
    ///      Throws if `tokenId` is not valid NFT
    /// @param from The address of the previous owner of the NFT
    /// @param to The address of NFT recipient 
    /// @param tokenId The NFT to get transferred for
    function transferAndRemove(address from, address to, uint256 tokenId) external;

위의 전송하고 삭제하는 함수는 nft를 전송하면서 가드랑 유효기간을 같이 제거하는 기능을 한다.

NFT가 to 로 전송이되고 guard 주소는 0으로 지정이 되게 된다.

이건 아무래도 전송 시에 접근 권한도 같이 제거해주는 부분이기 때문에 nft의 소유권을 넘기면서 관련된 가드들의 권한도 제거할 상황이 많을거 같아 넣은 기능 같다.

    /// @notice Get the guard address and expires of the NFT
    /// @dev The zero address indicates that there is no guard
    /// @param tokenId The NFT to get the guard address and expires for
    /// @return The guard address and expires for the NFT
   function guardInfo(uint256 tokenId) external view returns (address, uint64);

당연히 guard애 대한 상태변화를 시켜주는 함수가 있다면 guard의 현재 상태에 대한 정보를 조회하는 함수도 있을 것이다.

해당 함수는 nft의 아이디를 넣어주게 되면 리턴 값으로 가드주소와 가드가 유효한 시간을 리턴해주게 된다.

구현체 살펴보기

해당 표준의 경우 구현체도 있어서 살펴보도록 하겠다.

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.8;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "./IERC6147.sol";

abstract contract ERC6147 is ERC721, IERC6147 {

    /// @dev A structure representing a token of guard address and expires
    /// @param guard address of guard role
    /// @param expirs UNIX timestamp, the guard could manage the token before expires
    struct GuardInfo{
        address guard;
        uint64 expires;
    }

먼저 구조체로 GuardInfo가 있는데 여기서 guard의 주소와 유효기간을 보여주게 된다.

    mapping(uint256 => GuardInfo) internal _guardInfo;

구조체가 있다면 당연히 이에 상응하는 mapping자료형이 있어야 할 것이다. 따라서 mapping이 구현되어 있고

    function changeGuard(uint256 tokenId, address newGuard, uint64 expires) public virtual{
        require(expires > block.timestamp, "ERC6147: invalid expires");
        _updateGuard(tokenId, newGuard, expires, false);
    }

가드를 바꾸는 부분은 블록의 타임스탬프를 체크해서 만료 여부를 체크하여 가드를 바꾸려는 주소의 기간 만료 여부를 확인하고 가드를 업데이트 하는 함수를 호출한다.

    function _updateGuard(uint256 tokenId, address newGuard, uint64 expires, bool allowNull) internal {
        (address guard,) = guardInfo(tokenId);
        if (!allowNull) {
            require(newGuard != address(0), "ERC6147: new guard can not be null");
        }
        if (guard != address(0)) { 
            require(guard == _msgSender(), "ERC6147: only guard can change it self"); 
        } else { 
            require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC6147: caller is not owner nor approved");
        } 

        if (guard != address(0) || newGuard != address(0)) {
            _guardInfo[tokenId] = GuardInfo(newGuard,expires);
            emit UpdateGuardLog(tokenId, newGuard, guard, expires);
        }
    }

해당 함수를 업데이트 하는 부분을 보면 토큰아이디, 새로운 가드, 만료기한, 가드가 없어도 되는지 여부등을 파라미터로 받아서 업데이트를 해주는데 가드가 msgSender인지, null이 허용되는지 approved나 owner인지 여부를 체크한 후에 각각 예외처리를 해주게 된다.

그리고 주소0인지 여부를 체크한 후에 위에서 살펴봤던 mapping자료형에서 tokenID를 키 값으로 두고 가드 주소와 expires를 각각 받아와서 구조체로 만들어 준후에 업데이트를 하고 이벤트를 남긴다.

    function _checkGuard(uint256 tokenId) internal view returns (address) {
        (address guard, ) = guardInfo(tokenId);
        address sender = _msgSender();
        if (guard != address(0)) {
            require(guard == sender, "ERC6147: sender is not guard of the token");
            return guard;
        }else{
            return address(0);
        }
    }

그 다음은 가드를 체크하는 부분이다. 토큰 아이디를 넣어서 가져온 구조체 정보를 통하여서 가드 인지 체크하는 내부호출 함수이다.

    function transferFrom(address from, address to, uint256 tokenId) public virtual override {
        address guard;
        address new_from = from;
        if (from != address(0)) {
            guard = _checkGuard(tokenId);
            new_from = ownerOf(tokenId);
        }
        if (guard == address(0)) {
            require(
                _isApprovedOrOwner(_msgSender(), tokenId),
                "ERC721: transfer caller is not owner nor approved"
            );
        }
        _transfer(new_from, to, tokenId);
    }

그리고 일반적인 transferFrom을 여기서 오버라이딩 해서 다시 사용하고 있는데 위에서 정의한 가드 여부를 체크하고 from주소의 owner도 유효한지 체크한 후에 approved를 받았거나 owner인지 체크하고 나서 tranfer를 해주게 된다.

    /// @dev Before safe transferring the NFT, need to check the gurard address
    function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory _data) public virtual override {
        address guard;
        address new_from = from;
        if (from != address(0)) {
            guard = _checkGuard(tokenId);
            new_from = ownerOf(tokenId);
        }
        if (guard == address(0)) {
            require(
                _isApprovedOrOwner(_msgSender(), tokenId),
                "ERC721: transfer caller is not owner nor approved"
            );
        }
        _safeTransfer(from, to, tokenId, _data);
    }

safeTransferFrom의 경우에는 transfer과 비슷하지만 safeTransferFrom 함수를 지원하는 내용이다. 만약 nft를 직접 컨트렉트에 예치하는 브릿지 같은 경우 위와 같은 safeTransferFrom을 사용한다.

    /// @dev When burning, delete `token_guard_map[tokenId]`
    /// This is an internal function that does not check if the sender is authorized to operate on the token.
    function _burn(uint256 tokenId) internal virtual override {
        (address guard, )=guardInfo(tokenId);
        super._burn(tokenId);
        delete _guardInfo[tokenId];
        emit UpdateGuardLog(tokenId, address(0), guard, 0);
    }

    /// @dev See {IERC165-supportsInterface}.
    function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
        return interfaceId == type(IERC6147).interfaceId || super.supportsInterface(interfaceId);
    }

다음으로 소각 함수를 보면 소각 후에 guardInfo 구조체를 아예 삭제시켜 버린다.

그리고 supportsInterface를 통해서 해당 표준을 만족하는지 여부를 리턴해주는 조회 함수를 만들어 주고 있다.

 

728x90
반응형
Comments