[ERC5489] NFTにハイパーリンクをつける機能を理解しよう！

# はじめに

初めまして。
**CryptoGames**というブロックチェーンゲーム企業でエンジニアをしている **cardene（かるでね）** です！
スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。

https://cryptogames.co.jp/

代表的なゲームは**クリプトスペルズ**というブロックチェーンゲームです。

https://cryptospells.jp/

今回は、NFTにハイパーリンクを埋め込み、ユーザーが任意のNFTをクリックして、所有者が設定した任意のURLに移動できるようにする**ERC5489**についてまとめていきます！

:::note info
以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。
:::

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

# 概要
ERC721規格のNFTの新たな拡張を提案します。
**nft-hyperlink-extention（hNFT）**　とは、NFTにハイパーリンクを組み込むもので、**hNFTs**と呼ばれます。
**hNFTs**の所有者は、URLスロットの変更権限を特定のアドレス（外部所有アカウント[EOA]、またはコントラクトアドレス）に承認することができ、いつでもその承認を取り消すことができます。
権限を持つアドレスは、そのスロットのURLを管理することができます。

::: note info
スロットとは、メタデータ、特にハイパーリンクを保存する場所を示します。
各スロットはアドレスをキー、URIを値として保持している。
例）`{"0xabc123...": "metadata"}`
「**スロットをアドレスを承認**」とは、特定のアドレスが特定のスロットを管理できるように許可することを意味する。
:::

# 動機
NFTが注目を集めるにつれて、Web3の主要な媒体になる可能性があります。
現在、エンドユーザーはNFTにリッチテキスト、ビデオ、画像を添付することができません。
そのため、多くの業界がこのようなリッチコンテンツ添付する機能を熱望しています。
高度にカスタマイズされた情報の添付、編集、表示は、標準化されていると有用であるため、「**NFT上の高度にカスタマイズされた添付ファイル**」の形としてハイパーリンクを使用し、NFTにどのように添付、編集、表示するかも指定します。

# 仕様

## インターフェース

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

interface IERC5489 {
    event SlotAuthorizationCreated(uint256 indexed tokenId, address indexed slotManagerAddr);

    event SlotAuthorizationRevoked(uint256 indexed tokenId, address indexed slotManagerAddr);

    event SlotUriUpdated(uint256 indexed tokenId, address indexed slotManagerAddr, string uri);

    function authorizeSlotTo(uint256 tokenId, address slotManagerAddr) external;

    function revokeAuthorization(uint256 tokenId, address slotManagerAddr) external;

    function revokeAllAuthorizations(uint256 tokenId) external;

    function setSlotUri(
        uint256 tokenId,
        string calldata newUri
    ) external;

    function getSlotUri(uint256 tokenId, address slotManagerAddr)
        external
        view
        returns (string memory);
}
```

### Event

#### SlotAuthorizationCreated

特定のアドレスが、特定のNFTのURIの変更権限を得られた時に発行される。

#### SlotAuthorizationRevoked

特定のアドレスが、特定のNFTのURIの変更権限を取り消された時に発行される。
進行中のインセンティブや権利を停止することができます。

#### SlotUriUpdated

特定のアドレスが、特定のNFTのURI変更した時に発行される。

### 関数

#### authorizeSlotTo

`slotManagerAddr`に指定されたアドレスに、`tokenId`に一致するNFTのハイバーリンク変更権限を付与する。
`public`もしくは`external`として実装して良い。

#### revokeAuthorization

`slotManagerAddr`に指定されたアドレスの、`tokenId`に一致するNFTのハイバーリンク変更権限を取り消す。
`public`もしくは`external`として実装して良い。

#### revokeAllAuthorizations

`tokenId`に一致するNFTのハイバーリンク変更権限をすべてのアドレスから取り消す。
`public`もしくは`external`として実装して良い。

#### setSlotUri

`slotManagerAddr`に指定されたアドレスが`tokenId`に一致するNFTのURIを設定する。
`authorizeSlotTo`によって権限を付与されたアドレスのみ実行できる。
`public`もしくは`external`として実装して良い。

#### getSlotUri

`tokenId`に一致するNFTのURIを返す。
URIは「**EIP5489 Metadata JSON schema**」に準拠したJSONファイルである必要があります。
`pure`もしくは`view`として実装して良い。

## 認証
`authorizeSlotTo`関数、`revokeAuthorization`関数、`revokeAllAuthorizations`関数は、メッセージ送信者がトークンの所有者である場合にのみ実行されます。

## Metadata JSON schema

```json
{
    "title": "AD Metadata",
    "type": "object",
    "properties": {
        "icon": {
            "type": "string",
            "description": "A URI pointing to a resource with mime type image/* representing the slot's occupier. Consider making any images at a width between 48 and 1080 pixels and aspect ration between 1.91:1 and 4:5 inclusive. Suggest to show this as an thumbnail of the target resource"
        },
        "description": {
            "type": "string",
            "description": "A paragraph which briefly introduce what is the target resource"
        },
        "target": {
            "type": "string",
            "description": "A URI pointing to target resource, sugguest to follow 30X status code to support more redirections, the mime type and content rely on user's setting"
        } 
    }
}
```

# 補足

## NFTにハイパーリンクを拡張
URIはさまざまなユースケースを処理するための十分な柔軟性を確保します。

## スロットをアドレスに承認
我々は、スロットのキーを表現するためにアドレスを使用し、すべてのユースケースを処理するための十分な柔軟性を確保します。


# 後方互換性
完全にEIP721と互換性を持っている。

# 参照実装

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

import "./IERC5489.sol";

contract ERC5489 is IERC5489, ERC721Enumerable, Ownable {
    using EnumerableSet for EnumerableSet.AddressSet;

    mapping(uint256 => EnumerableSet.AddressSet) tokenId2AuthroizedAddresses;
    mapping(uint256 => mapping(address=> string)) tokenId2Address2Value;
    mapping(uint256 => string) tokenId2ImageUri;

    string private _imageURI;
    string private _name;

    constructor() ERC721("Hyperlink NFT Collection", "HNFT") {}

    modifier onlyTokenOwner(uint256 tokenId) {
        require(_msgSender() == ownerOf(tokenId), "should be the token owner");
        _;
    }

    modifier onlySlotManager(uint256 tokenId) {
        require(_msgSender() == ownerOf(tokenId) || tokenId2AuthroizedAddresses[tokenId].contains(_msgSender()), "address should be authorized");
        _;
    }

    function setSlotUri(uint256 tokenId, string calldata value) override external onlySlotManager(tokenId) {
        tokenId2Address2Value[tokenId][_msgSender()] = value;

        emit SlotUriUpdated(tokenId, _msgSender(), value);
    }

    function getSlotUri(uint256 tokenId, address slotManagerAddr) override external view returns (string memory) {
        return tokenId2Address2Value[tokenId][slotManagerAddr];
    }

    function authorizeSlotTo(uint256 tokenId, address slotManagerAddr) override external onlyTokenOwner(tokenId) {
        require(!tokenId2AuthroizedAddresses[tokenId].contains(slotManagerAddr), "address already authorized");
        
        _authorizeSlotTo(tokenId, slotManagerAddr);
    }

    function _authorizeSlotTo(uint256 tokenId, address slotManagerAddr) private {
        tokenId2AuthroizedAddresses[tokenId].add(slotManagerAddr);
        emit SlotAuthorizationCreated(tokenId, slotManagerAddr);
    }

    function revokeAuthorization(uint256 tokenId, address slotManagerAddr) override external onlyTokenOwner(tokenId) {
        tokenId2AuthroizedAddresses[tokenId].remove(slotManagerAddr);
        delete tokenId2Address2Value[tokenId][slotManagerAddr];

        emit SlotAuthorizationRevoked(tokenId, slotManagerAddr);
    }

    function revokeAllAuthorizations(uint256 tokenId) override external onlyTokenOwner(tokenId) {
        for (uint256 i = tokenId2AuthroizedAddresses[tokenId].length() - 1;i > 0; i--) {
            address addr = tokenId2AuthroizedAddresses[tokenId].at(i);
            tokenId2AuthroizedAddresses[tokenId].remove(addr);
            delete tokenId2Address2Value[tokenId][addr];

            emit SlotAuthorizationRevoked(tokenId, addr);
        }

        if (tokenId2AuthroizedAddresses[tokenId].length() > 0) {
            address addr = tokenId2AuthroizedAddresses[tokenId].at(0);
            tokenId2AuthroizedAddresses[tokenId].remove(addr);
            delete tokenId2Address2Value[tokenId][addr];

            emit SlotAuthorizationRevoked(tokenId, addr);
        }
    }

    function isSlotManager(uint256 tokenId, address addr) public view returns (bool) {
        return tokenId2AuthroizedAddresses[tokenId].contains(addr);
    }

    // !!expensive, should call only when no gas is needed;
    function getSlotManagers(uint256 tokenId) external view returns (address[] memory) {
        return tokenId2AuthroizedAddresses[tokenId].values();
    }

    function _mintToken(uint256 tokenId, string calldata imageUri) private {
        _safeMint(msg.sender, tokenId);
        tokenId2ImageUri[tokenId] = imageUri;
    }

    function mint(string calldata imageUri) external {
        uint256 tokenId = totalSupply() + 1;
        _mintToken(tokenId, imageUri);
    }

    function mintAndAuthorizeTo(string calldata imageUri, address slotManagerAddr) external {
        uint256 tokenId = totalSupply() + 1;
        _mintToken(tokenId, imageUri);
        _authorizeSlotTo(tokenId, slotManagerAddr);
    }

    function tokenURI(uint256 _tokenId) public view override returns (string memory) {
        require(
            _exists(_tokenId),
            "URI query for nonexistent token"
        );

        return
            string(
                abi.encodePacked(
                    "data:application/json;base64,",
                    Base64.encode(
                        bytes(
                            abi.encodePacked(
                                '{"name":"',
                                abi.encodePacked(
                                    _name,
                                    " # ",
                                    Strings.toString(_tokenId)
                                ),
                                '",',
                                '"description":"Hyperlink NFT collection created with Parami Foundation"',
                                '}'
                            )
                        )
                    )
                )
            );
    }
}
```


# 最後に

今回は「**NFTにハイパーリンクを埋め込み、ユーザーが任意のNFTをクリックして、所有者が設定した任意のURLに移動できるようにするERC5489**」についてまとめてきました。
いかがだったでしょうか？
実装については今後追記していきます。

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください！

[Twitter @cardene777](https://twitter.com/cardene777)

# 採用強化中！

CryptoGamesでは一緒に働く仲間を大募集中です。

https://www.wantedly.com/companies/cryptogames

この記事で書いた自分の経験からもわかるように、裁量権を持って働くことができて一気に成長できる環境です。
「ブロックチェーンやWeb3、NFTに興味がある」、「スマートコントラクトの開発に携わりたい」など、少しでも興味を持っている方はまずはお話ししましょう！