# [ERC4671] NTTと呼ばれる譲渡できないNFTの仕組みを理解しよう！

> 非譲渡トークン（NTT：Non-Tradable Token）は、大学の卒業証書、オンライン研修の修了証明書、政府発行の各種書類（国民ID、運転免許証、ビザ、婚姻証明書など）、ラベルなど、個人的な所有物（有形・無形）を表すトークンです。

- URL: https://cardene.dev/articles/7acca8927401cbe8491c
- 公開日: 2025-04-27
- 更新日: 2025-04-27
- トピック: Blockchain
- タグ: ethereum, solidity, smartcontract, ブロックチェーン

## はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

https://amzn.asia/d/gxvJ0Pw

今回は、NTTと呼ばれる譲渡できないNFTの仕組みを提案している**ERC4671**についてまとめていきます！

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

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

他にも様々なEIPについてまとめています。

https://cardene.notion.site/ERC-EIP-2a03fa3ea33d43baa9ed82288f98d4a9?pvs=4

## 概要

非譲渡トークン（NTT：Non-Tradable Token）は、大学の卒業証書、オンライン研修の修了証明書、政府発行の各種書類（国民ID、運転免許証、ビザ、婚姻証明書など）、ラベルなど、個人的な所有物（有形・無形）を表すトークンです。

このトークンは名前の通り、譲渡や取引ができないように設計されており、「**ソウルバウンド**」であることが特徴です。
金銭的な価値を持たず、個人に対して発行されることで、その人が何かを所有している・成し遂げたことの証明手段として機能します。

## 動機

過去には、スマートコントラクトを用いて大学の卒業証書や運転免許証を発行したり、食品ラベルやイベント出席証明などに利用された事例があります。
これらのユースケースはいずれも、共通して「**譲渡不可なトークン**」が使われています。

ブロックチェーンは長らく投機手段として使われてきましたが、非譲渡トークンは実用性をもたらすためのブロックチェーンの新たな活用方法として位置づけられています。

非譲渡トークンに共通のインターフェースを提供することで、より多くのユースケースに対応可能になり、個人の所有物や達成事項の検証手段としてブロックチェーン技術を標準的なインフラとして普及させることが期待されています。

## 仕様

**ERC4671**は、個人の成果や資格を表すために用いられる「**非譲渡トークン（NTT）**」を標準化するインターフェースです。
NTTは譲渡不可であり、ソウルバウンドトークンとしての性質を持ちます。

**ERC4671**では、各トークンコントラクトが1つの証明書の種類（例：大学の卒業証書、政府発行のID）を表し、個人のアドレスに対して発行されます。
また、発行者がそのトークンを無効化（`revoke`）できる仕組みや、第三者がその保有状況や有効性を確認できる機能も備えています。

### インターフェース

```js:IERC4671.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./IERC165.sol";

interface IERC4671 is IERC165 {
    /// Event emitted when a token `tokenId` is minted for `owner`
    event Minted(address owner, uint256 tokenId);

    /// Event emitted when token `tokenId` of `owner` is revoked
    event Revoked(address owner, uint256 tokenId);

    /// @notice Count all tokens assigned to an owner
    /// @param owner Address for whom to query the balance
    /// @return Number of tokens owned by `owner`
    function balanceOf(address owner) external view returns (uint256);

    /// @notice Get owner of a token
    /// @param tokenId Identifier of the token
    /// @return Address of the owner of `tokenId`
    function ownerOf(uint256 tokenId) external view returns (address);

    /// @notice Check if a token hasn't been revoked
    /// @param tokenId Identifier of the token
    /// @return True if the token is valid, false otherwise
    function isValid(uint256 tokenId) external view returns (bool);

    /// @notice Check if an address owns a valid token in the contract
    /// @param owner Address for whom to check the ownership
    /// @return True if `owner` has a valid token, false otherwise
    function hasValid(address owner) external view returns (bool);
}
```

#### `Minted`

```js
event Minted(address owner, uint256 tokenId);
```

トークンが発行された時に発行されるイベント。

**パラメータ**  
- `owner`  
    - トークンを受け取ったアドレス。
- `tokenId`  
    - 発行されたトークンの識別子。

#### `Revoked`

```js
event Revoked(address owner, uint256 tokenId);
```

トークンが無効化された時に発行されるイベント。

**パラメータ**  
- `owner`  
    - 対象となるトークンの保有者。
- `tokenId`  
    - 無効化されたトークンの識別子。

#### `balanceOf`

```js
function balanceOf(address owner) external view returns (uint256);
```

指定したアドレスが所有するトークン数を取得する関数。
有効・無効に関わらず、特定のアドレスが発行された全てのトークン数を返します。

**引数**  
- `owner`  
    - 保有トークン数を確認したいアドレス。

**戻り値**  
- `uint256`  
    - 所有するトークンの数。

#### `ownerOf`

```js
function ownerOf(uint256 tokenId) external view returns (address);
```

指定したトークンの所有者を取得する関数。
`tokenId`に対して現在の所有者アドレスを返します。
無効化されたトークンでも所有者情報は保持されます。

**引数**  
- `tokenId`  
    - 対象トークンの識別子。

**戻り値**  
- `address`  
    - トークンの所有者。

#### `isValid`

```js
function isValid(uint256 tokenId) external view returns (bool);
```

トークンが有効かどうかを確認する関数。
トークンが無効化されていない場合に`true`を返します。

**引数**  
- `tokenId`  
    - チェックするトークンの識別子。

**戻り値**  
- `bool`  
    - 有効であれば`true`、無効であれば`false`。

#### `hasValid`

```js
function hasValid(address owner) external view returns (bool);
```

指定アドレスが有効なトークンを1つでも持っているかを確認する関数。
アドレスが発行済みかつ有効なトークンを少なくとも1つ保有していれば`true`を返します。

**引数**  
- `owner`  
    - 対象のアドレス。

**戻り値**  
- `bool`  
    - 有効なトークンを持っていれば`true`。

#### ERC4671Metadata

```js:IERC4671Metadata.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./IERC4671.sol";

interface IERC4671Metadata is IERC4671 {
    /// @return Descriptive name of the tokens in this contract
    function name() external view returns (string memory);

    /// @return An abbreviated name of the tokens in this contract
    function symbol() external view returns (string memory);

    /// @notice URI to query to get the token's metadata
    /// @param tokenId Identifier of the token
    /// @return URI for the token
    function tokenURI(uint256 tokenId) external view returns (string memory);
}
```

#### `name`

```js
function name() external view returns (string memory);
```

トークンの名称を返す関数。

**戻り値**  
- `string`  
  - トークンの名称。

#### `symbol`

```js
function symbol() external view returns (string memory);
```

**概要**  
トークンの略称を返す関数。
`ETH`, `IDFRA` など短い形式の名前です。

**戻り値**  
- `string`  
    - トークンの略称。

#### `tokenURI`

```js
function tokenURI(uint256 tokenId) external view returns (string memory);
```

トークンに紐づくメタデータのURIを取得する関数。
JSON形式のメタデータにアクセスできるURIを返します。

**引数**  
- `tokenId`  
    - 対象トークンの識別子。

**戻り値**  
- `string`  
    - メタデータへのURI。

### ERC4671Enumerable

```js:IERC4671Enumerable.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./IERC4671.sol";

interface IERC4671Enumerable is IERC4671 {
    /// @return emittedCount Number of tokens emitted
    function emittedCount() external view returns (uint256);

    /// @return holdersCount Number of token holders  
    function holdersCount() external view returns (uint256);

    /// @notice Get the tokenId of a token using its position in the owner's list
    /// @param owner Address for whom to get the token
    /// @param index Index of the token
    /// @return tokenId of the token
    function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256);

    /// @notice Get a tokenId by it's index, where 0 <= index < total()
    /// @param index Index of the token
    /// @return tokenId of the token
    function tokenByIndex(uint256 index) external view returns (uint256);
}
```

#### `emittedCount`

```js
function emittedCount() external view returns (uint256);
```

発行されたトークンの総数を返す関数。
無効化されたトークンも含めた累計発行数です。

**戻り値**  
- `uint256`  
    - 発行トークン数。

#### `holdersCount`

```js
function holdersCount() external view returns (uint256);
```

トークン保有者の数を返す関数。
一度でもトークンを受け取ったことがあるアドレス数を返します。

**戻り値**  
- `uint256`  
    - 保有者数。

#### `tokenOfOwnerByIndex`

```js
function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256);
```

所有者が保有するトークンのうち、インデックス指定で取得する関数。
指定インデックスに該当する`tokenId`を返します。

**引数**  
- `owner`  
    - 対象アドレス。
- `index`  
    - 保有トークンリスト上のインデックス。

**戻り値**  
- `uint256`  
    - 該当するトークンID。

#### `tokenByIndex`

```js
function tokenByIndex(uint256 index) external view returns (uint256);
```

グローバルなトークンインデックスから`tokenId`を取得する関数。
全体の発行順でトークンIDを取得します。

**引数**  
- `index`  
    - トークンのグローバルインデックス。

**戻り値**  
- `uint256`  
  - トークンID。

### ERC4671Delegate

```js:IERC4671Delegate.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./IERC4671.sol";

interface IERC4671Delegate is IERC4671 {
    /// @notice Grant one-time minting right to `operator` for `owner`
    /// An allowed operator can call the function to transfer rights.
    /// @param operator Address allowed to mint a token
    /// @param owner Address for whom `operator` is allowed to mint a token
    function delegate(address operator, address owner) external;

    /// @notice Grant one-time minting right to a list of `operators` for a corresponding list of `owners`
    /// An allowed operator can call the function to transfer rights.
    /// @param operators Addresses allowed to mint
    /// @param owners Addresses for whom `operators` are allowed to mint a token
    function delegateBatch(address[] memory operators, address[] memory owners) external;

    /// @notice Mint a token. Caller must have the right to mint for the owner.
    /// @param owner Address for whom the token is minted
    function mint(address owner) external;

    /// @notice Mint tokens to multiple addresses. Caller must have the right to mint for all owners.
    /// @param owners Addresses for whom the tokens are minted
    function mintBatch(address[] memory owners) external;

    /// @notice Get the issuer of a token
    /// @param tokenId Identifier of the token
    /// @return Address who minted `tokenId`
    function issuerOf(uint256 tokenId) external view returns (address);
}
```

#### `delegate`

```js
function delegate(address operator, address owner) external;
```

指定のオペレーターに対して、特定の所有者に対するトークン発行の一回限りの権限を付与する関数。
`operator` は `owner` のために 1 回限りの `mint` を実行できるようになります。

**引数**  
- `operator`  
    - `mint` の権限を与えるアドレス。
- `owner`  
    - トークンの発行対象となるアドレス。

#### `delegateBatch`

```js
function delegateBatch(address[] memory operators, address[] memory owners) external;
```

複数のオペレーターに対して、それぞれの所有者へのトークン発行権限を一括で付与する関数。
各 `operator` が対応する `owner` に対して 1 回限り `mint` できるようになります。

**引数**  
- `operators`  
    - `mint` 権限を与えるアドレスの配列。
- `owners`  
    - 各オペレーターに対応するトークンの発行対象者。

#### `mint`

```js
function mint(address owner) external;
```

指定されたアドレスに対してトークンを発行する関数。
この関数を実行できるのは、`owner` に対する `mint` 権限を持っているオペレーターのみです。

**引数**  
- `owner`  
    - トークンを発行する対象のアドレス。

#### `mintBatch`

```js
function mintBatch(address[] memory owners) external;
```

複数のアドレスに一括でトークンを発行する関数。
呼び出し元が各アドレスに対して `mint` 権限を持っている必要があります。

**引数**  
- `owners`  
    - トークンを発行する対象アドレスの配列。

#### `issuerOf`

```js
function issuerOf(uint256 tokenId) external view returns (address);
```

トークンを発行した発行者（オペレーター）のアドレスを取得する関数。
`tokenId` に対応する発行者アドレスを返します。

**引数**  
- `tokenId`  
    - 発行者を取得したいトークンの識別子。

**戻り値**  
- `address`  
    - トークンの発行者のアドレス。

### ERC4671Consensus

```js:IERC4671Consensus.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./IERC4671.sol";

interface IERC4671Consensus is IERC4671 {
    /// @notice Get voters addresses for this consensus contract
    /// @return Addresses of the voters
    function voters() external view returns (address[] memory);

    /// @notice Cast a vote to mint a token for a specific address
    /// @param owner Address for whom to mint the token
    function approveMint(address owner) external;

    /// @notice Cast a vote to revoke a specific token
    /// @param tokenId Identifier of the token to revoke
    function approveRevoke(uint256 tokenId) external;
}
```

#### `voters`

```js
function voters() external view returns (address[] memory);
```

このコントラクトにおける投票権を持つアドレス一覧を取得する関数。
コンセンサスに基づいてトークンを発行・無効化する時に、どのアドレスが意思決定に参加できるかを返します。

**戻り値**  
- `address[]`  
    - 投票者アドレスのリスト。

#### `approveMint`

```js
function approveMint(address owner) external;
```

指定されたアドレスへのトークン発行に対して賛成票を投じる関数。
投票者はこの関数を用いて `owner` に対するトークン発行を承認できます。

**引数**  
- `owner`  
    - トークン発行の対象となるアドレス。

#### `approveRevoke`

```js
function approveRevoke(uint256 tokenId) external;
```

特定のトークンに対する無効化に賛成票を投じる関数。
投票者はこの関数を用いてトークンの無効化を承認します。

**引数**  
- `tokenId`  
    - 無効化を提案しているトークンの識別子。

### ERC4671Pull

```js:IERC4671Pull.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./IERC4671.sol";

interface IERC4671Pull is IERC4671 {
    /// @notice Pull a token from the owner wallet to the caller's wallet
    /// @param tokenId Identifier of the token to transfer
    /// @param owner Address that owns tokenId
    /// @param signature Signed data (tokenId, owner, recipient) by the owner of the token
    function pull(uint256 tokenId, address owner, bytes memory signature) external;
}
```

#### `pull`

```js
function pull(uint256 tokenId, address owner, bytes memory signature) external;
```

所有者が別のウォレットに自身のトークンを移動する関数。
呼び出し元は、`owner` が署名した `(tokenId, owner, recipient)` の署名を渡すことで、トークンを `owner` から自身のウォレットへ引き取ることができます。

**引数**  
- `tokenId`  
    - 移動対象のトークンID。
- `owner`  
    - 現在トークンを保有しているアドレス。
- `signature`  
    - `(tokenId, owner, recipient)` に対する `owner` の署名。

###  NTT Store

非譲渡トークン（NTT）は、他者によって確認されることを前提としています。
そのため、ユーザー自身が自身の保有トークンを選択的に公開できる仕組みが必要です。
**ERC4671Store**はこのニーズに応えるためのストア機能を定義するインターフェースであり、ユーザーが特定の**ERC4671Enumerable**トークンコントラクトを登録・公開・削除できる機能を提供します。

```js:IERC4671Store.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./IERC165.sol";

interface IERC4671Store is IERC165 {
    // Event emitted when a IERC4671Enumerable contract is added to the owner's records
    event Added(address owner, address token);

    // Event emitted when a IERC4671Enumerable contract is removed from the owner's records
    event Removed(address owner, address token);

    /// @notice Add a IERC4671Enumerable contract address to the caller's record
    /// @param token Address of the IERC4671Enumerable contract to add
    function add(address token) external;

    /// @notice Remove a IERC4671Enumerable contract from the caller's record
    /// @param token Address of the IERC4671Enumerable contract to remove
    function remove(address token) external;

    /// @notice Get all the IERC4671Enumerable contracts for a given owner
    /// @param owner Address for which to retrieve the IERC4671Enumerable contracts
    function get(address owner) external view returns (address[] memory);
}
```

#### `Added`

```js
event Added(address owner, address token);
```

ユーザーがトークンコントラクトを登録した時に発行されるイベント。

**パラメータ**  
- `owner`  
    - コントラクトを追加したユーザーのアドレス。
- `token`  
    - 登録された**ERC4671Enumerable**トークンコントラクトのアドレス。

#### `Removed`

```js
event Removed(address owner, address token);
```

ユーザーが登録済みのトークンコントラクトを削除した時に発行されるイベント。

**パラメータ**  
- `owner`  
    - コントラクトを削除したユーザーのアドレス。
- `token`  
    - 削除された**ERC4671Enumerable**トークンコントラクトのアドレス。

#### `add`

```js
function add(address token) external;
```

ユーザーが自分のストアに**ERC4671Enumerable**トークンコントラクトを登録する関数。
この関数は呼び出し元のアドレスに対してトークンコントラクトを追加し、その情報を第三者が取得可能にします。
追加可能なコントラクトは**ERC4671Enumerable**に準拠している必要があります。

**引数**  
- `token`  
    - 追加するトークンコントラクトのアドレス。

#### `remove`

```js
function remove(address token) external;
```

ユーザーが自分のストアから登録済みのトークンコントラクトを削除する関数。
呼び出し元が過去に登録したトークンコントラクトを一覧から削除します。
この削除は公開情報から非表示にすることを意味し、トークン自体の無効化ではありません。

**引数**  
- `token`  
    - 削除したいトークンコントラクトのアドレス。

#### `get`

```js
function get(address owner) external view returns (address[] memory);
```

指定されたユーザーが公開している**ERC4671Enumerable**トークンコントラクトの一覧を取得する関数。
この関数を使うことで第三者は任意のアドレスが公開しているトークンコントラクトの情報を確認できます。
ストアに追加されていないトークンは対象外です。

**引数**  
- `owner`  
    - 取得対象のユーザーアドレス。

**戻り値**  
- `address[]`  
    - 公開されているトークンコントラクトアドレスの配列。

## 補足

### オンチェーンかオフチェーンか

非譲渡トークン（NTT）のメタデータはオンチェーンではなくオフチェーンで管理される設計になっています。
その理由は以下の2点です。

- 非譲渡トークンは個人の所有物（パーソナルな証明）を表すため、場合によってはデータの暗号化が必要となるケースがあります。しかし、暗号化の手法は多様でユースケースごとに異なるため、標準仕様として具体的な暗号化の方法を定めるべきではありません。
- 汎用的なトークン仕様を保つために、理想としては `MetadataStore` のようなコントラクトにデータを保存するアプローチも考えられました。しかし、Solidityでは構造体の継承や型の汎用性をサポートしていないため、そうした柔軟な表現は現状実現できません。

このような理由から、メタデータは `tokenURI()` を通じて外部リソースから取得する形になっています。

## セキュリティ

### `tokenURI`関数

`tokenURI` 関数は、トークンに関連するメタデータのURIを返します。
個人情報に関わるデータ（例：国民IDなど）を扱う場合、内容の暗号化が必要になるケースがあります。
この暗号化処理は、各コントラクトの発行者が適切に実装する必要があります。

### URIの参照先

URIの参照先（オフチェーンのメタデータ）は常にアクセス可能である必要があります。
リンク切れや削除などによりメタデータが取得できなくなると、トークンの意味や価値が失われる可能性があります。

### トークンの再発行

トークンは**ERC4671**では譲渡が禁止されています。
そのため、ユーザーはNTTを受け取るウォレットの管理に十分注意する必要があります。
ウォレットを紛失した場合、そのトークンを回復する唯一の方法は発行元の権限者によって再発行してもらうことです。
これは現実世界における証明書の再発行と同じ考え方です。

## 参考実装

```js:EIPCreatorBadge.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./ERC4671.sol";

contract EIPCreatorBadge is ERC4671 {
    constructor() ERC4671("EIP Creator Badge", "EIP") {}

    function giveThatManABadge(address owner) external {
        require(_isCreator(), "You must be the contract creator");
        _mint(owner);
    }

    function _baseURI() internal pure override returns (string memory) {
        return "https://eips.ethereum.org/ntt/";
    }
}
```


## 引用

Omar Aflak (@omaraflak), Pol-Malo Le Bris, Marvin Martin (@MarvinMartin24), "ERC-4671: Non-Tradable Tokens Standard [DRAFT]," Ethereum Improvement Proposals, no. 4671, January 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-4671.

## 最後に

今回は「NTTと呼ばれる譲渡できないNFTの仕組みを提案している**ERC4671**」についてまとめてきました！
いかがだったでしょうか？

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

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

他の媒体でも情報発信しているのでぜひ他も見ていってください！

https://chaldene.net/

https://zenn.dev/heku

https://cardene.substack.com/

https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58

https://cardene.notion.site/ERC-EIP-2a03fa3ea33d43baa9ed82288f98d4a9?pvs=4
