はじめに

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

今回は、ERC721形式のNFTをオリジナルと限定盤を区別できる仕組みを提案しているERC3440についてまとめていきます!

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

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

概要

ERC3440は、ERC721を拡張し、アート作品を表すNFTに対して「署名」の機能を追加する提案です。 これにより、アーティストが作品のオリジナルと限定版プリントを明確に区別し、それぞれに署名データを紐づけることが可能になります。 アーティストが自らの鍵でトークンIDごとに固有の署名を記録できるようになり、作品の真正性や来歴を強化する仕組みです。

動機

現在、NFTとデジタルアートとの関係性は、NFTのメタデータ(tokenURI)に頼っており、その情報は外部サーバーに保存されています。 これではブロックチェーン上でアーティストによる「手書きのサイン」のような証明が困難です。

一方、アートの世界では「オリジナル作品」と「エディション(複製作品)」を区別することが一般的です。 しかし、ERC721はもともと不動産権利証やゲームアイテムといった用途を想定して設計されており、アート作品のような用途には最適化されていません。

そのため、ERC3440では次のような課題を解決しようとしています。

アーティストが自らの鍵を使ってNFTに署名を付与することで、真正性をブロックチェーン上に記録します。 NFTの限定版の数(エディション数)を明示的に指定し、購入者に作品の希少性を保証できます。 これにより、メタデータに依存せず、オンチェーンで署名付きのNFTを扱う標準を整備することで、アーティストと購入者の信頼性を向上できます。 また、第三者のアプリケーションにおいても、標準的な読み取り関数を使って、署名や限定版情報を明確に表示できます。

これにより、デジタルアート分野でのNFTの信頼性と表現力が高まり、アーティストとファンのつながりがより強固になります。

仕様

ERC3440は、ERC721に限定版アート作品の署名とエディション指定を追加する提案です。 ERC3440に準拠したコントラクトは、以下の機能を提供します。

  • オリジナルのNFTの指定
  • 限定版(プリント)の最大発行数の設定
  • 各トークンIDに対するアーティストの署名
  • アーティストが署名した情報の表示と検証

EIP712形式で署名を行い、オンチェーンで検証可能にすることで真正性と限定性をブロックチェーン上で証明できるようにしています。

インターフェース

ERC3440.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

/**
 * @dev ERC721 token with editions extension.
 */
abstract contract ERC3440 is ERC721URIStorage {

    // eip-712
    struct EIP712Domain {
        string  name;
        string  version;
        uint256 chainId;
        address verifyingContract;
    }
    
    // Contents of message to be signed
    struct Signature {
        address verificationAddress; // ensure the artists signs only address(this) for each piece
        string artist;
        address wallet;
        string contents;
    }

    // type hashes
    bytes32 constant EIP712DOMAIN_TYPEHASH = keccak256(
        "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
    );

    bytes32 constant SIGNATURE_TYPEHASH = keccak256(
        "Signature(address verifyAddress,string artist,address wallet, string contents)"
    );

    bytes32 public DOMAIN_SEPARATOR;
    
    // Optional mapping for signatures
    mapping (uint256 => bytes) private _signatures;
    
    // A view to display the artist's address
    address public artist;

    // A view to display the total number of prints created
    uint public editionSupply = 0;
    
    // A view to display which ID is the original copy
    uint public originalId = 0;
    
    // A signed token event
    event Signed(address indexed from, uint256 indexed tokenId);

    /**
     * @dev Sets `artist` as the original artist.
     * @param `address _artist` the wallet of the signing artist (TODO consider multiple
     * signers and contract signers (non-EOA)
     */
    function _designateArtist(address _artist) internal virtual {
        require(artist == address(0), "ERC721Extensions: the artist has already been set");

        // If there is no special designation for the artist, set it.
        artist = _artist;
    }
    
    /**
     * @dev Sets `tokenId as the original print` as the tokenURI of `tokenId`.
     * @param `uint256 tokenId` the nft id of the original print
     */
    function _designateOriginal(uint256 _tokenId) internal virtual {
        require(msg.sender == artist, "ERC721Extensions: only the artist may designate originals");
        require(_exists(_tokenId), "ERC721Extensions: Original query for nonexistent token");
        require(originalId == 0, "ERC721Extensions: Original print has already been designated as a different Id");

        // If there is no special designation for the original, set it.
        originalId = _tokenId;
    }
    

    /**
     * @dev Sets total number printed editions of the original as the tokenURI of `tokenId`.
     * @param `uint256 _maxEditionSupply` max supply
     */
    function _setLimitedEditions(uint256 _maxEditionSupply) internal virtual {
        require(msg.sender == artist, "ERC721Extensions: only the artist may designate max supply");
        require(editionSupply == 0, "ERC721Extensions: Max number of prints has already been created");

        // If there is no max supply of prints, set it. Leaving supply at 0 indicates there are no prints of the original
        editionSupply = _maxEditionSupply;
    }

    /**
     * @dev Creates `tokenIds` representing the printed editions.
     * @param `string memory _tokenURI` the metadata attached to each nft
     */
    function _createEditions(string memory _tokenURI) internal virtual {
        require(msg.sender == artist, "ERC721Extensions: only the artist may create prints");
        require(editionSupply > 0, "ERC721Extensions: the edition supply is not set to more than 0");
        for(uint i=0; i < editionSupply; i++) {
            _mint(msg.sender, i);
            _setTokenURI(i, _tokenURI);
        }
    }

    /**
     * @dev internal hashing utility 
     * @param `Signature memory _message` the signature message struct to be signed
     * the address of this contract is enforced in the hashing
     */
    function _hash(Signature memory _message) internal view returns (bytes32) {
        return keccak256(abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR,
            keccak256(abi.encode(
                SIGNATURE_TYPEHASH,
                address(this),
                _message.artist,
                _message.wallet,
                _message.contents
            ))
        ));
    }

    /**
     * @dev Signs a `tokenId` representing a print.
     * @param `uint256 _tokenId` id of the NFT being signed
     * @param `Signature memory _message` the signed message
     * @param `bytes memory _signature` signature bytes created off-chain
     *
     * Requirements:
     *
     * - `tokenId` must exist.
     *
     * Emits a {Signed} event.
     */
    function _signEdition(uint256 _tokenId, Signature memory _message, bytes memory _signature) internal virtual {
        require(msg.sender == artist, "ERC721Extensions: only the artist may sign their work");
        require(_signatures[_tokenId].length == 0, "ERC721Extensions: this token is already signed");
        bytes32 digest = hash(_message);
        address recovered = ECDSA.recover(digest, _signature);
        require(recovered == artist, "ERC721Extensions: artist signature mismatch");
        _signatures[_tokenId] = _signature;
        emit Signed(artist, _tokenId);
    }

    
    /**
     * @dev displays a signature from the artist.
     * @param `uint256 _tokenId` NFT id to verify isSigned
     * @returns `bytes` gets the signature stored on the token
     */
    function getSignature(uint256 _tokenId) external view virtual returns (bytes memory) {
        require(_signatures[_tokenId].length != 0, "ERC721Extensions: no signature exists for this Id");
        return _signatures[_tokenId];
    }
    
    /**
     * @dev returns `true` if the message is signed by the artist.
     * @param `Signature memory _message` the message signed by an artist and published elsewhere
     * @param `bytes memory _signature` the signature on the message
     * @param `uint _tokenId` id of the token to be verified as being signed
     * @returns `bool` true if signed by artist
     * The artist may broadcast signature out of band that will verify on the nft
     */
    function isSigned(Signature memory _message, bytes memory _signature, uint _tokenId) external view virtual returns (bool) {
        bytes32 messageHash = hash(_message);
        address _artist = ECDSA.recover(messageHash, _signature);
        return (_artist == artist && _equals(_signatures[_tokenId], _signature));
    }

    /**
    * @dev Utility function that checks if two `bytes memory` variables are equal. This is done using hashing,
    * which is much more gas efficient then comparing each byte individually.
    * Equality means that:
    *  - 'self.length == other.length'
    *  - For 'n' in '[0, self.length)', 'self[n] == other[n]'
    */
    function _equals(bytes memory _self, bytes memory _other) internal pure returns (bool equal) {
        if (_self.length != _other.length) {
            return false;
        }
        uint addr;
        uint addr2;
        uint len = _self.length;
        assembly {
            addr := add(_self, /*BYTES_HEADER_SIZE*/32)
            addr2 := add(_other, /*BYTES_HEADER_SIZE*/32)
        }
        assembly {
            equal := eq(keccak256(addr, len), keccak256(addr2, len))
        }
    }
}

構造体

EIP712Domain

js
struct EIP712Domain {
    string  name;
    string  version;
    uint256 chainId;
    address verifyingContract;
}

EIP712署名のドメイン情報を定義する構造体。 署名対象のドメインを特定するための情報をまとめた構造体で、EIP712の仕様に基づき、署名の範囲や意味を限定します。

EIP712については以下の記事を参考にしてください。

パラメータ

  • name
    • ドメインの名前。
  • version
    • ドメインのバージョン。
  • chainId
    • チェーンID(ブロックチェーンの識別子)。
  • verifyingContract
    • 署名検証対象のコントラクトアドレス。

Signature

js
struct Signature {
    address verificationAddress;
    string artist;
    address wallet;
    string contents;
}

アーティストが署名する情報を格納する構造体。 作品に署名する際の情報セットで、アーティスト名、署名用ウォレット、署名対象の内容などを含みます。

パラメータ

  • verificationAddress
    • 署名の対象となるコントラクトアドレス。
  • artist
    • アーティストの名前。
  • wallet
    • アーティストのウォレットアドレス。
  • contents
    • 署名対象のメッセージ内容。

定数・変数・配列

EIP712DOMAIN_TYPEHASH

js
bytes32 constant EIP712DOMAIN_TYPEHASH

EIP712Domain構造体の型ハッシュ。 EIP712署名時に必要な構造体の定義内容をハッシュ化した定数です。

SIGNATURE_TYPEHASH

js
bytes32 constant SIGNATURE_TYPEHASH

Signature構造体の型ハッシュ。 署名対象の構造体内容をハッシュ化したもので、EIP712署名用に使用されます。

DOMAIN_SEPARATOR

js
bytes32 public DOMAIN_SEPARATOR;

EIP712署名のためのドメイン識別子。 署名時に使うドメイン全体のハッシュで、署名検証時に用いられます。

_signatures

js
mapping (uint256 => bytes) private _signatures;

各NFTに紐づいた署名情報を保持するマッピング配列。 トークンIDごとにアーティストの署名(bytes型)を記録します。 署名済みかどうかの確認や検証に使用されます。

パラメータ

  • uint256
    • トークンID。
  • bytes
    • 署名データ。

artist

js
address public artist;

アーティストのウォレットアドレス。 NFTや署名を行う主体となるアーティストを表し、管理者的な立場となります。

editionSupply

js
uint public editionSupply = 0;

発行する限定版の総数。 プリントとして発行するNFTの最大数を示し、この値以上の発行はできません。

originalId

js
uint public originalId = 0;

オリジナルNFTのトークンID。 作品の元となるオリジナルのIDを一度だけ指定することができます。

イベント

Signed

js
event Signed(address indexed from, uint256 indexed tokenId);

NFTが署名されたことを示すイベント。 アーティストが特定のトークンIDに対して署名をした時に発行されます。

パラメータ

  • from
    • 署名を行ったアーティストのアドレス。
  • tokenId
    • 署名対象のトークンID。

関数

_designateArtist

js
function _designateArtist(address _artist) internal virtual

アーティストを指定する関数。 アーティストがまだ指定されていない場合に限り、コントラクト内でアーティストのウォレットアドレスを設定します。

引数

  • _artist
    • 署名を行うアーティストのウォレットアドレスです。

_designateOriginal

js
function _designateOriginal(uint256 _tokenId) internal virtual

オリジナルのNFTを指定する関数。 アーティストが自分のNFTの中から、オリジナルとしてトークンIDを一度だけ指定できます。

引数

  • _tokenId
    • オリジナル作品として指定するNFTのIDです。

_setLimitedEditions

js
function _setLimitedEditions(uint256 _maxEditionSupply) internal virtual

限定版(プリント)の総数を設定する関数。 発行する複製(プリント)の最大数を指定します。 一度設定すると再設定はできません。

引数

  • _maxEditionSupply
    • 限定版の最大供給数です。

_createEditions

js
function _createEditions(string memory _tokenURI) internal virtual

限定版NFTを実際に発行する関数。 あらかじめ設定された供給数に従って、NFTをアーティストのアドレスに発行し、各トークンに同じメタデータを設定します。

引数

  • _tokenURI
    • 各限定版NFTに適用する共通のメタデータURIです。

_hash

js
function _hash(Signature memory _message) internal view returns (bytes32)

署名対象のハッシュ値を計算する関数。 EIP712形式に従って、署名対象のSignature構造体からハッシュ値を生成します。

引数

  • _message
    • ハッシュ化する署名情報。

戻り値

  • bytes32
    • 計算されたハッシュ値。

_signEdition

js
function _signEdition(uint256 _tokenId, Signature memory _message, bytes memory _signature) internal virtual

NFTに対して署名を登録する関数。 アーティストが指定したSignatureデータと署名バイト列を検証し、正しければそのNFTに署名を保存します。

引数

  • _tokenId
    • 署名対象のNFTのID。
  • _message
    • 署名に使われた内容。
  • _signature
    • 実際の署名データ(バイト列)。

getSignature

js
function getSignature(uint256 _tokenId) external view virtual returns (bytes memory)

指定したNFTに登録されている署名を取得する関数。 既に署名されたNFTの署名バイト列を外部から取得します。

引数

  • _tokenId
    • 署名情報を取得するNFTのID。

戻り値

  • bytes
    • 署名データ。

isSigned

js
function isSigned(Signature memory _message, bytes memory _signature, uint _tokenId) external view virtual returns (bool)

署名が有効かどうかを検証する関数。 外部から与えられた署名データが、NFTに登録されたものであり、かつ正しくアーティストによって署名されたかどうかを検証します。

引数

  • _message
    • 検証する署名内容。
  • _signature
    • 検証する署名バイト列。
  • _tokenId
    • 検証対象のNFTのID。

戻り値

  • bool
    • アーティストによる署名である場合は true、それ以外は false。

_equals

js
function _equals(bytes memory _self, bytes memory _other) internal pure returns (bool equal)

2つのbytesデータが一致するかを判定する関数。 ハッシュを用いて2つのバイト配列の完全一致を確認します。 ガス効率が高いコードになっています。

引数

  • _self
    • 比較対象の署名データ。
  • _other
    • もう一方の署名データ。

戻り値

  • bool
    • 両者が完全一致していれば true、そうでなければ false。

hash

js
bytes32 digest = hash(_message);

※実装上 hash 関数は定義されていませんが、正しくは _hash を指していると考えられます(コード中に _hash(_message) が定義済み)。 したがって、ここでは _hash 関数として扱います。

補足

NFTの重要な役割の1つは、デジタルアートにおける「唯一性」を視覚的に示すことです。 作品の来歴(プロヴァナンス)は、アートにおいて非常に重要な要素であり、ERC3440はNFTにこの来歴をより強く結びつけるための手段を提供します。

アーティストがトークンに明示的な署名を施すことで、単なる画像リンクではなく「アーティストの意思を伴った作品」であることをブロックチェーン上で証明できるようになります。 これにより、アーティストとその作品との結びつきが強化され、コレクターやファンにとっても信頼性の高い作品であることを確認できるようになります。

将来的にアーティストが自らの秘密鍵を保持し続ける限り、同じ署名が過去に発行されたNFTに存在していたことを再証明することも可能になります。 これは、時間が経過しても真正性が失われないことを意味します。

参考実装

ArtToken.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./ERC3440.sol";

/**
 * @dev ERC721 token with editions extension.
 */
contract ArtToken is ERC3440 {

    /**
     * @dev Sets `address artist` as the original artist to the account deploying the NFT.
     */
     constructor (
        string memory _name, 
        string memory _symbol,
        uint _numberOfEditions,
        string memory tokenURI,
        uint _originalId
    ) ERC721(_name, _symbol) {
        _designateArtist(msg.sender);
        _setLimitedEditions(_numberOfEditions);
        _createEditions(tokenURI);
        _designateOriginal(_originalId);

        DOMAIN_SEPARATOR = keccak256(abi.encode(
            EIP712DOMAIN_TYPEHASH,
            keccak256(bytes("Artist's Editions")),
            keccak256(bytes("1")),
            1,
            address(this)
        ));
    }
    
    /**
     * @dev Signs a `tokenId` representing a print.
     */
    function sign(uint256 _tokenId, Signature memory _message, bytes memory _signature) public {
        _signEdition(_tokenId, _message, _signature);
    }
}

セキュリティ

ERC3440は、以下のような重要なアクションをアーティストが行えるようにしています。

  • オリジナル作品(originalId)の指定
  • 限定版(エディション)の最大供給数(editionSupply)の設定
  • 各エディションNFTの発行
  • tokenURI によるアート作品リンクの指定
  • 署名による唯一性の証明

ただし、これらの機能が何度も変更できてしまうと、以下のような問題が発生する可能性があります。

  • 一度販売されたオリジナルの信頼性が揺らぐ
  • アーティストが後から限定数を増やし、価値を下げる行為
  • 後から異なる署名を付けて作品の一貫性を壊すこと

これを防ぐため、ERC3440では以下のような安全策が取られています。

  • オリジナル作品の指定は一度しかできません。
  • 限定版の供給数も一度しか設定できません。
  • 各エディションのNFTも一度しか署名できません。

これらは、コントラクトのconstructor(デプロイ時に一度だけ実行される特殊関数)内で処理されることが推奨されており、初期化段階で固めてしまうことでその後の変更を防止してNFTの唯一性や信頼性を保ちます。

引用

Nathan Ginnever (@nginnever), "ERC-3440: ERC-721 Editions Standard [DRAFT]," Ethereum Improvement Proposals, no. 3440, April 2021. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-3440.

最後に

今回は「ERC721形式のNFTをオリジナルと限定盤を区別できる仕組みを提案しているERC3440」についてまとめてきました! いかがだったでしょうか?

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

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