ERC-721で実現するフルオンチェーンNFT~永続的デジタル資産を構築する~

 本記事では、Ethereumのトークン規格である「ERC-721」を使って、Ethereumのテストネットである「Sepolia」でオリジナルのフルオンチェーンNFTを発行する手順を解説します。

 こんにちは。大和総研デジタルソリューション研究開発部の大迫です。普段は、NFTを活用したビジネス検討やサービスの開発を行っています。2024年度までは、デジタル証券であるセキュリティ・トークンを取り扱うためのウォレット(注1)と呼ばれるシステムの開発を行っていました。
 リテール基幹システム第二部の戸隱です。普段は、大和証券が提供するファンドラップに関連するシステム開発に携わっています。

 大和総研ではEthereumなどのパブリックブロックチェーンを活用したWeb3の広がりを見据え、Web3分野の研究開発を行う専門のプロジェクト「Web3 Lab」を発足しました。社内のさまざまな部署から公募で選ばれたメンバーがWeb3 Labに参加しており、私たちもその一員として日々の業務と兼務しながら研究開発に取り組んでいます。

 Web3 Labではスマートコントラクトを使った開発も行っており、今回はEthereumのトークン規格である「ERC-721」を使って、フルオンチェーンNFTを発行する手順について解説します。本記事が、スマートコントラクトを使った開発を検討している皆さまの参考になれば幸いです。

1 ERC-721とは

 ERC-721のERCとはEthereum Request for Commentの略で、Ethereumの機能やプロセスの改善に関する提案が書かれたドキュメントであるEIP(Ethereum Improvement Proposal)において、スマートコントラクトなどのアプリケーションに関する提案がまとめられたカテゴリの名称です。
 ERC-721はNFT(Non-Fungible Token:ノンファンジブルトークン)を実装するためのスマートコントラクトに関する規格として、William Entriken氏やDieter Shirley氏によって提案されました(注2)。NFTのスマートコントラクトを開発する際に、実装すべき関数やその引数、戻り値などについて定義されています。721という数字はGitHubのissue番号が721だったことが由来となっています。

2 NFT(ノンファンジブルトークン)とは

 NFTはNon-Fungible Token(ノンファンジブルトークン)の略称です。Fungibleは「代替可能であること」を意味するのに対して、Non-Fungibleは「代替不可能であること」を意味します。例えば、現金の1万円札はどの1枚でも同じ価値を持ち、1万円札同士を交換したとしても価値は同じであるため、Fungibleであるといえます。一方、直筆のアート作品やサインなどは、他のものに直接交換できず、Non-Fungibleなものと言えます。
 次にTokenは、ブロックチェーンの文脈では「ブロックチェーン上で何らかの価値や権利を表すもの」を指す言葉として使用されます。つまりNFTは、「ブロックチェーン上で唯一無二の価値や権利を表すもの」を意味します。
 NFTの例としてよく取り上げられるのがデジタルアートです。デジタルアートは、現物のアートと同様に、市場に流通する数が限られていて、それが本物であると証明できることで資産価値が生まれます。しかし、従来のデジタルアートはデジタルなデータであるため同じものを容易にコピー(複製)できてしまうので本物であることの証明が難しく、資産性が損なわれるという問題がありました。
 そこで、NFTの特性が活かされます。デジタルアートにNFTを紐づけて発行することで、そのデジタルアートが「唯一無二のオリジナルある」という真正性が証明可能になります。これにより、NFTの所有者はそのデジタルアートの所有権を主張できるようになり、デジタルデータに対する所有権のような考え方が生まれます。
 このように、NFTを使うことで、デジタルデータが本物であることや、誰がその持ち主であるかを示せるようになり、現物の作品のように「価値のあるデジタル資産」として売買・流通できるようになります。NFTを扱うマーケットプレースを利用すれば、誰でもNFTの売買を容易に行えます。
 ただし、NFTを購入してもそれに紐づくデジタルアート自体の著作権などの法的権利は自動的に自分ものになるわけではありません。これらの権利の扱いは、NFTが取引されるプラットフォームのルールや、もともとの作者との契約によって異なります。NFTはあくまでデジタルデータを所有していることを証明しているにすぎないことには注意が必要です。とはいえ、NFTを用いると現物のアートと同じように唯一無二であることが証明可能になるため、NFTが高値で取引される事例もあります。
 このように、NFTはデジタルデータだけでなく、現実世界にあるモノにも「唯一性」や「所有者」の情報を与え、その価値や本物であることを証明できる技術です。例にあげたようなデジタルアートだけでなく、ライブチケットや、製品の真贋証明など、さまざまな分野でその活用が広がっています。

図1. NFTとデジタルコンテンツ

出所:大和総研作成

3 フルオンチェーンNFTとは

 フルオンチェーンのNFTとは、「画像などのデジタルコンテンツを含むすべてのデータがブロックチェーン上に記録されているNFT」を指します。
 一般的にNFTは画像データなどのデジタルコンテンツと紐づけられますが、そのコンテンツの保存方法はフルオンチェーンとオフチェーンの2種類に分けられます。
 まず、フルオンチェーンNFTは、すべての情報がブロックチェーン上に記録されるため、データの永続性や改ざん耐性、所有権の透明性において、高い信頼性が確保されます。しかし、ブロックチェーンに保存できるデータ量の制限やコストの問題から、サイズの大きいコンテンツをブロックチェーン上に保存することは現実的ではありません。特にフルオンチェーンNFTでは、デジタルコンテンツ自体をブロックチェーン上に直接書き込むため、データ量が大きくなり、NFT発行の際に必要となるトランザクション手数料が高額になる傾向があります。それゆえ、現在流通している多くのフルオンチェーンNFTは、ドット絵などデータサイズを小さく抑えたものになっています。
 一方で、オフチェーンNFTは、デジタルコンテンツを外部のストレージに保存する方式であり、ブロックチェーン上には外部ストレージへの参照情報(URI)のみが記録されます。この方式では、ブロックチェーン上に書き込むデータサイズが大きくなりにくく、トランザクション手数料も抑えやすいという特徴があります。
 オフチェーン方式において、デジタルコンテンツの保存先として広く利用されているのが、IPFS(InterPlanetary File System)です。IPFSはP2Pネットワーク上で動作する分散型のファイルシステムです。保存するデジタルコンテンツが特定のサーバに依存しない構成になっていることに加え、コンテンツアドレッシング方式(注3)を採用しているため、高い障害耐性、改ざん耐性を有しています。これらの特徴から、ブロックチェーンとの親和性が高く、デジタルコンテンツの安全な保存先として評価されています。
 ただし、オフチェーンNFTはあくまでブロックチェーンの外にあるサービスに依存するというリスクがともないます。仮にコンテンツの保存先がサービスを停止したり、システム障害が発生したりすると、コンテンツにアクセスできなくなる可能性があります。

図2. フルオンチェーンNFTとオフチェーンNFT
出所:大和総研作成

 このように、現在は主にコストの観点から、フルオンチェーンよりもオフチェーンのNFTのほうが一般的ですが、今後の技術の進展でデータ量の制限やコストの問題が改善されると、信頼性の観点からフルオンチェーンNFTの利用が主流になっていく可能性があります。

4 ERC-721を用いたNFTの事例

 ここでは、EthereumでERC-721規格を用いて発行されたトークンの例を紹介します。
数多くのERC-721に対応したNFTプロジェクトが存在しますが、今回は、その中からCryptoKitties(注4) とENS(Ethereum Name Service)(注5)の2つをご紹介します。

4.1 CryptoKitties

 まずは、CryptoKittiesです。CryptoKittiesはカナダのDapper Labs社によって2017年にEthereum上で開発され、NFTブームの火付け役となったNFTゲームです。当時は取引量が急増してEthereumネットワークが混雑するほどの人気を集めました。ユーザは、それぞれがユニークな特徴を持つデジタルの猫(キティ)を収集、繁殖、売買できます。CryptoKittiesに登場する猫は「Cattributes(キャトリビュート)」と呼ばれる独自の遺伝子情報を持っており、目、口、毛皮の模様、色、性格などが異なります。これにより、見た目が全く同じ猫は存在せず、希少性の高い猫は高値で取引されることもありました(2018年には、600 ETH(当時約1900万円)で売買された例もあります)。CryptoKittiesによりERC-721規格の普及が進みました。

4.2 ENS(Ethereum Name Service)

 次は、ENS(Ethereum Name Service)です。ENSは、Ethereumを基盤とした分散型で拡張可能なネーミングシステムです。ENSは、「0x」で始まる長く複雑なEthereumのアドレスを、「example.eth」のような人間が読みやすい簡単な名前に置き換えます。その仕組みは、IPアドレスを「google.com」のようなドメイン名に変換するDNS(Domain Name System)と似ています。
 ENSのドメイン名は、そのドメイン名自体がERC-721規格に沿ったNFTとして発行されます。これにより、NFTの所有者はそのドメイン名の所有者であることを証明できます。また、NFTを他の人に移転すればドメインの所有権を移転することもできます。

5 ERC-721の仕組み

 次に、ERC-721の具体的な仕組みについて紹介します。ERC-721で定義されているメソッドのうち、主なものは以下の通りです。

  • balanceOf: 指定したアドレスが保有しているNFTの数を取得するメソッド。
function balanceOf(address _owner) external view returns (uint256);
  • ownerOf: 指定したトークンIDのNFTの所有者を取得するメソッド。
function ownerOf(uint256 _tokenId) external view returns (address);
  • transferFrom: NFTを指定したアドレスに移転するメソッド。受取先がコントラクトでも対応可否のチェックを行わないため、非対応コントラクト宛の場合、NFTがロックされる恐れがある。
function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
  • safeTransferFrom: NFTを指定したアドレスに安全に移転するメソッド。受取先がコントラクトアドレスの場合は受け取り可能かを自動確認し、不可であれば処理を失敗させてロックを防ぐ(外部アカウントの場合は通常どおり移転)。追加の情報(移転目的など)を含めた場合と含めない場合がある。
function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;
function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;
  • approve: NFTを指定した第三者が操作することを許可するメソッド。
function approve(address _approved, uint256 _tokenId) external payable;
  • setApprovalForAll: 自分が所有する特定のコレクションのNFTすべてに対する操作権限を、指定したアドレスにまとめて付与、または取り消すメソッド。
function setApprovalForAll(address _operator, bool _approved) external;

 またERC-721では、メタデータの拡張機能が用意されており、各トークンに名称や画像のリンクなどを紐づけることができます。各機能を紹介します。

  • name:NFTコレクションの名前を取得するメソッド。
function name() external view returns (string _name);
  • symbol: NFTコレクションのシンボルを取得するメソッド
function symbol() external view returns (string _symbol);
  • tokenURI: 指定したトークンIDのメタデータが格納されているURI(Uniform Resource Identifier)を取得するメソッド。
function tokenURI(uint256 _tokenId) external view returns (string);

6 テストネットでのNFTの発行

 ここからは、EthereumのテストネットであるSepolia上でERC721スマートコントラクトをデプロイし、NFTを発行する手順を解説します。
 今回NFTとして発行するのは、TypeScriptのコード上で自動生成したSVG画像(ランダムな円が描かれたアート)です。このような画像データをスマートコントラクト内に格納し、「画像データも含めてすべてのデータがブロックチェーン上に記録される」フルオンチェーンNFTとして発行します。
 Ethereumでは、トランザクションを実行するには、トランザクション手数料をETHで支払う必要があります。手数料は、スマートコントラクトのデプロイとNFTの発行を実行するアカウントが支払う必要があります。そのため、予めアカウントにETHを保有させておく必要があることに注意してください。SepoliaのSepolia ETHは以下のFaucetというサービスを使って無料で入手できます。
Sepolia PoW Faucet
Google Cloud Web3 Faucet
Chainlink Ethereum Sepolia Faucet

 今回はMetamaskであらかじめ作成したアカウントを使ってデプロイと発行を行います。秘密鍵はMetamaskからエクスポートして使用します。

6.1 事前準備

 スマートコントラクトを作成する前に、実行環境の準備を行います。Node.js(v18以降)をインストール済みとします。
 まずは、Hardhat(v2.25.0)(注6)を用いて、プロジェクトを作成します。また、コントラクトのデプロイやNFTを発行するためのライブラリとして、Viem(v2.31.7)(注7)を利用します。
 今回は、HardhatでViemが統合されたプロジェクトを作成します。以下のコマンドを順に実行します。

$ mkdir my-onchain-nft
$ cd my-onchain-nft
$ npx hardhat init

 コマンドを実行すると質問が表示されるので、それぞれ以下のように回答します。

・Ok to proceed? (y) y
・What do you want to do? ? Create a TypeScript project (with Viem)
・Hardhat project root: 何もせずEnterキー
・Do you want to add a .gitignore? (Y/n) ? y
・Do you want to install this sample project's dependencies with npm (hardhat @nomicfoundation/hardhat-toolbox-viem)? (Y/n) ? y

 プロジェクトの初期化が完了しました。次に、Zeppelin Group Ltd.が提供するOpenZeppelin(v5.3.0)(注8)というライブラリのインストールを行います。以下のコマンドを実行し、インストールを行います。

$ npm install @openzeppelin/contracts

 また、今回はTypeScriptを使用してファイルを作成するため、TypeScriptファイルを直接実行するために、tsx(TypeScript Execute)(注9)を使用します。こちらも以下のコマンドを実行し、インストールします。

$ npm install --save-dev tsx

 最後に、後述の「スマートコントラクトのデプロイ」と「NFTの発行」の際に使用するファイルで読み込む環境変数の設定ファイルを作成します。ソースコードの全文は、以下の通りです。MetaMaskからエクスポートした秘密鍵と、今回接続するSepoliaの接続情報を設定します。

.env (大和総研作成)
PRIVATE_KEY= Metamaskからエクスポートしたアカウントの秘密鍵
RPC_URL= Sepoliaの接続情報

 環境変数を「.env」から読み込むために、dotenvをインストールします。以下のコマンドを実行し、インストールを行います。

$ npm install dotenv

 これで、事前の準備は完了です。今回作成したファイルと、プロジェクト全体のフォルダ内の主なファイルの構成は、以下のようになります。

  • Contracts配下のFullOnChainNFT.sol
  • Scripts配下のdeploy.tsとmint.ts
  • ルート配下の.env

図3 ディレクトリ構成

出所:大和総研作成

6.2 スマートコントラクトの作成

 まずは、ERC721のスマートコントラクトを作成します。今回は、OpenZeppelinの中で、ERC-721のメタデータに関連する機能が拡張された「ERC721URIStorage」を利用して、コントラクトを作成します。ポイントを解説します。

  • constructor(): NFTコレクション名とシンボルを設定します。Ownable の機能により、コントラクトの所有者権限をデプロイしたアドレスに設定します。
  • mint(): 指定したアドレスへNFTを発行するための関数。コントラクトの所有者のみが実行できるように、制限しています。引数として、NFTの発行先アドレス、NFTの名前、Base64エンコードされた画像データを受け取り、内部関数の _mintNFT() に処理を渡します。
  • _mintNFT(): まず _safeMint() を呼び出し、新しいトークンIDでNFTを発行します。次に、引数で受け取った「NFT名」「画像データ」をもとに以下のような形式のメタデータJSONを作成します。
メタデータJSON(大和総研作成)
{
"name": "NFT name",
"description": "NFT description",
"image": "data:image/svg+xml;base64,{Base64エンコード済みSVGデータ}"
}image フィールドには、後述の「6.5 NFTの発行」で説明するDataURI形式(Base64エンコード済み)に変換されたSVGデータが格納されます。

 ブロックチェーン上に画像などのメタデータを保管するにあたってはBase64形式でエンコードし、Data URI(注10)形式に変換する必要があります。そのため、_mintNFT()関数内で、上記で作成したメタデータJSONのエンコード処理、Data URIへの変換処理を行った上で、tokenURIへの設定を行っています。

FullOnCahinNFT.sol (大和総研作成)
pragma solidity ^0.8.22;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/utils/Base64.sol";

/**
* @title FullOnChainNFT
* @dev フルオンチェーンNFT - メタデータと画像データをブロックチェーン上に保存
*/
contract FullOnChainNFT is ERC721URIStorage, Ownable {
    using Strings for uint256;

    // トークンIDカウンター
    uint256 private _currentTokenId;

    /**
    * @dev コンストラクタ 
    * @param name_ コレクション名
    * @param symbol_ シンボル
    * @param owner 所有者アドレス
    */
    constructor(
        string memory name_,
        string memory symbol_,
        address owner
    ) ERC721(name_, symbol_) Ownable(owner) {}

    /**
    * @dev 新しいNFTをミント(オーナーのみ実行可能)
    * @param to ミント先アドレス
    * @param nftName_ NFTの名前
    * @param image_ 画像データ(Base64エンコード済み)
    */
    function mint(
        address to,
        string memory nftName_,
        string memory image_
    ) public onlyOwner {
        _mintNFT(to, nftName_, image_);
    }

    /**
    * @dev 内部ミント処理 - メタデータ生成とトークンURI設定
    */
    function _mintNFT(
        address to,
        string memory nftName_,
        string memory image_
    ) internal {
        // トークンIDを増加させて新規ID取得
        _currentTokenId += 1;
        uint256 newTokenId = _currentTokenId;

        // NFTをミント
        _safeMint(to, newTokenId);

        // メタデータJSON生成
        string memory json = string(
            abi.encodePacked(
                '{"name":"',
                nftName_,
                '",',
                '"description":"A fully on-chain NFT where metadata and image are stored on the blockchain.",',
                '"image":"',
                image_,
                '"}'
            )
        );

        // メタデータをBase64エンコードしてDataURI形式に変換
        string memory metadataURI = string(
            abi.encodePacked(
                "data:application/json;base64,",
                Base64.encode(bytes(json))
            )
        );

        // トークンURIを設定
        _setTokenURI(newTokenId, metadataURI);
    }

    /**
    * @dev トークンURIを取得
    */
    function tokenURI(
        uint256 tokenId
    ) public view override(ERC721URIStorage) returns (string memory) {
        return super.tokenURI(tokenId);
    }
}

6.3 スマートコントラクトのコンパイル

 NFTを発行するには、作成したコントラクトをコンパイルする必要があります。作成したコントラクトファイルを「contracts」フォルダに格納します。ファイルを格納後、以下のコマンドを実行して、コンパイルを行います。

$ npx hardhat compile

 コンパイルが成功すると「artifacts 」フォルダが生成され、コントラクトのABI(Application Binary Interface)(注11)と バイトコード を含むJSONファイルが格納されます。これらのファイルは、スマートコントラクトのデプロイやNFTの発行処理で使用します。

6.4 スマートコントラクトのデプロイ

 ここでは、事前にインストールしたViemライブラリを使用して、コントラクトのデプロイ、デプロイ結果の取得までを行います。コントラクトをデプロイするためのソースコードの全文を以下に掲載します。
 コンパイルして生成したJSONファイルからABIとバイトコードを読み込み、先ほどFullOnCahinNFT.solのコンストラクタで定義したコレクション名、シンボル、所有者アドレスをパラメータとして設定し、デプロイ処理を実行します。

deploy.ts (大和総研作成)
import { readFileSync } from "fs";
import { join } from "path";
import * as dotenv from 'dotenv';

// viemライブラリから必要な関数をインポート
import { createPublicClient, createWalletClient, http, type Abi, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains";

dotenv.config(); //.envの読み込み

//=====================================================
// 型定義
//=====================================================

interface ContractArtifact {
  abi: Abi;
  bytecode: Hex;
}

interface Config {
  privateKey: Hex;
  rpcUrl: string;
  contractName: string;
  contractSymbol: string;
}

//=====================================================
// 設定
//=====================================================

const config: Config = {
  privateKey: process.env.PRIVATE_KEY as Hex,
  rpcUrl: process.env.RPC_URL as string,
  contractName: "Full OnChain NFT Collection", // NFTコレクションの名前
  contractSymbol: "FONC", // NFTコレクションのシンボル
};

//=====================================================
// ユーティリティ関数
//=====================================================

/**
 * コントラクトのアーティファクトを読み込む
 * @param path アーティファクトファイルのパス
 * @returns コントラクトのABIとバイトコード
 */
function loadContractArtifact(path: string): ContractArtifact {
  try {
    const rawData = readFileSync(path, "utf8");
    const artifact = JSON.parse(rawData);

    if (!artifact.abi || !artifact.bytecode) {
      throw new Error("Invalid contract artifact: missing abi or bytecode");
    }

    return {
      abi: artifact.abi as Abi,
      bytecode: artifact.bytecode as Hex,
    };
  } catch (error) {
    throw new Error(`Failed to load contract artifact: ${error instanceof Error ? error.message : "Unknown error"}`);
  }
}

/**
 * クライアントのセットアップ
 * @param config デプロイメント設定
 * @returns publicClientとwalletClient
 */
function setupClients(config: Config) {
  const account = privateKeyToAccount(config.privateKey);

  const publicClient = createPublicClient({
    chain: sepolia,
    transport: http(config.rpcUrl),
  });

  const walletClient = createWalletClient({
    account,
    chain: sepolia,
    transport: http(config.rpcUrl),
  });

  return { publicClient, walletClient, account };
}

//=====================================================
// メイン処理
//=====================================================

/**
 * コントラクトをデプロイする
 * @param config デプロイメント設定
 */
async function deployContract(config: Config): Promise<void> {
  try {
    // クライアントのセットアップ
    const { publicClient, walletClient, account } = setupClients(config);

    // コントラクトアーティファクトの読み込み
    const artifactPath = join(
      __dirname,
      "../artifacts/contracts/FullOnChainNFT.sol/FullOnChainNFT.json"
    );
    const { abi, bytecode } = loadContractArtifact(artifactPath);

    // コントラクトのデプロイ
    const hash = await walletClient.deployContract({
      abi,
      bytecode,
      args: [config.contractName, config.contractSymbol, account.address],
    });

    // トランザクションの完了を待つ
    const receipt = await publicClient.waitForTransactionReceipt({ hash });

    // デプロイ結果の検証
    if (receipt.status === "success") {
      console.log("Contract deployed successfully!");
      console.log(`Transaction Hash: ${hash}`);
      console.log(`Contract Address: ${receipt.contractAddress}`);
    } else {
      throw new Error("Transaction failed");
    }

  } catch (error) {
    console.error("Deployment failed:");
    if (error instanceof Error) {
      console.error(`Error: ${error.message}`);
      if (error.stack) {
        console.error(`Stack: ${error.stack}`);
      }
    } else {
      console.error("Unknown error occurred");
    }
    process.exit(1);
  }
}

//=====================================================
// エントリーポイント
//=====================================================

// 即時実行関数でメイン処理を実行
(async () => {
  await deployContract(config);
})();

 scriptsフォルダを作成し、「deploy.ts」というファイル名で、ファイルを保存します。
次にデプロイ処理を行っていきます。以下のコマンドを実行します。

$ npx tsx scripts/deploy.ts

 処理が成功すると、以下のようなログが出力されます。トランザクションハッシュとコントラクトアドレスが出力されていることを確認し、コントラクトアドレスをメモしておきます。

Contract deployed successfully!
Transaction Hash: 0xdc945977ba5568d4985443acc3be06b0b6c2e90d9e434ecd268756f802ee653c
Contract Address: 0x9a9ce57bd07d7e78ebcf1811fa699be4d08633fa

6.5 NFTの発行

 いよいよNFTを発行します。NFTを発行するためのソースコードの全文を以下に掲載します。
 今回は、TypeScriptのソースコード上でSVG(Scalable Vector Graphics)形式で画像を作成しています。generateTestSVG()は、ランダムな円を作成する関数です。ブロックチェーン上に画像データを保存するには、DataURI形式に変換する必要があります。svgToDataURI()は、SVGからDataURIへの変換処理を行う関数です。先ほどFullOnCahinNFT.solのmint関数で定義した発行先アドレス、NFTの名前、画像データを引数として設定し、発行処理を実行します。

mint.ts (大和総研作成)
import {
  createPublicClient,
  createWalletClient,
  http,
  parseEventLogs,
  toHex,
  type Abi,
  type Hex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains";
import { readFileSync } from "fs";
import { join } from "path";
import * as dotenv from 'dotenv';

dotenv.config(); //.envの読み込み

//=====================================================
// 型定義
//=====================================================

interface ContractArtifact {
  abi: Abi;
}

interface Config {
  privateKey: Hex;
  rpcUrl: string;
  contractAddress: Hex;
  nftName: string;
}

//=====================================================
// 設定
//=====================================================

const config: Config = {
  privateKey: process.env.PRIVATE_KEY as Hex,
  rpcUrl: process.env.RPC_URL as string,
  contractAddress: "メモしたデプロイ済みのコントラクトアドレス",
  nftName: "Full OnChain NFT 1",
};

//=====================================================
// ユーティリティ関数
//=====================================================

/**
 * コントラクトのアーティファクトを読み込む
 * @param path アーティファクトファイルのパス
 * @returns コントラクトのABI
 */
function loadContractArtifact(path: string): ContractArtifact {
  try {
    const rawData = readFileSync(path, "utf8");
    const artifact = JSON.parse(rawData);

    if (!artifact.abi) {
      throw new Error("Invalid contract artifact: missing abi");
    }

    return {
      abi: artifact.abi as Abi,
    };
  } catch (error) {
    throw new Error(`Failed to load contract artifact: ${error instanceof Error ? error.message : "Unknown error"}`);
  }
}

/**
 * クライアントのセットアップ
 * @param config 設定
 * @returns publicClientとwalletClient
 */
function setupClients(config: Config) {
  const account = privateKeyToAccount(config.privateKey);

  const publicClient = createPublicClient({
    chain: sepolia,
    transport: http(config.rpcUrl),
  });

  const walletClient = createWalletClient({
    account,
    chain: sepolia,
    transport: http(config.rpcUrl),
  });

  return { publicClient, walletClient, account };
}

/**
 * SVGアートを生成
 */
function generateTestSVG(numShapes: number): string {
  let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" style="background: #f0f0f0">\n`;
  for (let i = 0; i < numShapes; i++) {
    const cx = Math.floor(Math.random() * 512);
    const cy = Math.floor(Math.random() * 512);
    const r = Math.floor(Math.random() * 40) + 15;
    const hue = Math.floor(Math.random() * 360);
    const color = `hsl(${hue}, 70%, 60%)`;
    svg += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${color}" opacity="0.7" />\n`;
  }
  svg += `</svg>`;
  return svg;
}

/**
 * SVGをDataURI(base64)に変換
 */
function svgToDataURI(svg: string): string {
  const base64 = Buffer.from(svg).toString("base64");
  return `data:image/svg+xml;base64,${base64}`;
}

//=====================================================
// メイン処理
//=====================================================

async function mintNFT(config: Config): Promise<void> {
  try {
    // クライアントのセットアップ
    const { publicClient, walletClient, account } = setupClients(config);

    // コントラクトアーティファクトの読み込み
    const artifactPath = join(
      __dirname,
      "../artifacts/contracts/FullOnChainNFT.sol/FullOnChainNFT.json"
    );
    const { abi } = loadContractArtifact(artifactPath);

    // SVG生成
    const svg = generateTestSVG(100);
    const image = svgToDataURI(svg);

    // ミント実行
    const hash = await walletClient.writeContract({
      abi,
      address: config.contractAddress,
      functionName: "mint",
      args: [account.address, config.nftName, image],
    });

    // トランザクションの完了を待つ
    const receipt = await publicClient.waitForTransactionReceipt({ hash });

    // parseEventLogs を使って Transfer イベントから tokenId を抽出
    let tokenId: bigint | null = null;
    try {
      const transferLogs = parseEventLogs({
        abi,
        logs: receipt.logs.filter(
          (l) => l.address.toLowerCase() === config.contractAddress.toLowerCase()
        ),
        eventName: "Transfer",
      });

      if (transferLogs.length > 0) {
        const last = transferLogs[transferLogs.length - 1];
        // viem の型上 any キャストして tokenId 取得(ERC721 Transfer の args: from,to,tokenId)
        tokenId = (last.args as any).tokenId as bigint;
      }
    } catch (e) {
      console.warn("Transferイベント解析に失敗しました", e);
    }

    // ミント結果を表示
    if (receipt.status === "success") {
      console.log("NFT minted successfully!");
      console.log(`Transaction Hash: ${hash}`);
      console.log(`Token ID: ${tokenId}`);
    } else {
      throw new Error("Transaction failed");
    }
  } catch (error) {
    console.error("Mint failed:");
    if (error instanceof Error) {
      console.error(`Error: ${error.message}`);
      if (error.stack) {
        console.error(`Stack: ${error.stack}`);
      }
    } else {
      console.error("Unknown error occurred");
    }
    process.exit(1);
  }
}

//=====================================================
// エントリーポイント
//=====================================================

(async () => {
  await mintNFT(config);
})();

 scriptsフォルダにファイル名を「mint.ts」として格納します。次に、以下のコマンドを実行し、ミント処理を実行します。

$ npx tsx scripts/mint.ts

 処理が成功すると、以下のようなログが出力されます。トランザクションハッシュ、トークンIDが出力されていることを確認し、トークンIDをメモしておきます。

Contract deployed successfully!
Transaction Hash: 0xdc945977ba5568d4985443acc3be06b0b6c2e90d9e434ecd268756f802ee653c
Contract Address: 0x9a9ce57bd07d7e78ebcf1811fa699be4d08633fa

6.6 MetaMaskで扱えるようにする

 次に、発行したNFTを、秘密鍵のエクスポート元であるMetaMaskにインポートします。これにより、MetaMask上でNFTの確認や移転の操作が可能になります。
 MetaMaskを起動し、Sepoliaネットワークに接続した上で、NFTタブに移動し、画面中央端の3つの点を選択し、「NFTインポート」を選択します(図4、図5)。

図4. 画面中央端の3つの点を選択
出所:Consensys Software Inc.のアプリMetaMaskより引用(2025年10月24日閲覧)

図5. 「NFTをインポート」を選択
出所:Consensys Software Inc.のアプリMetaMaskより引用(2025年10月24日閲覧)

 図6のような画面が表示されますので、「ネットワークを選択」項目で、Sepoliaを選択し、「アドレス」「トークンID」欄に先ほどメモしたコントラクトアドレスとトークンIDを入力し、「インポート」を選択します(図7)。

図6. NFTインポート画面(入力前)
出所:Consensys Software Inc.のアプリMetaMaskより引用(2025年10月24日閲覧)

図7. NFTインポート画面(入力後)
出所:Consensys Software Inc.のアプリMetaMaskより引用(2025年10月24日閲覧)

 インポートが完了すると、図8のようにNFT一覧画面にインポートしたNFTが表示されます。

図8. NFT一覧にNFTが追加される
出所:Consensys Software Inc.のアプリMetaMaskより引用(2025年10月24日閲覧)

 追加したNFTをクリックすることで、NFTの情報を確認することができます(図9)。

図9. NFTの詳細情報を確認
出所:Consensys Software Inc.のアプリMetaMaskより引用(2025年10月24日閲覧)

 「送金」を選択すると他のアドレスに移転することもできます(図10)。

図10. 「送金」を選択するとNFTを他のアドレスに移転できる
出所:Consensys Software Inc.のアプリMetaMaskより引用(2025年10月24日閲覧)

7 まとめ

 本記事では、ERC-721の規格を用いてフルオンチェーンNFTをEthereumのSepolia上で発行しました。ERC721トークンは、今回紹介したHardhat、ViemやOpenZeppelinなどのツールやライブラリを用いて、簡単に作成することができます。皆さんもぜひ試してみてください。

(本ブログの内容は2025年10月時点のものです)

お問い合わせ先

 大和総研では、ブロックチェーン・Web3分野の研究開発を行う専門のプロジェクトを発足し、ウォレットに関する特許を取得するなど、ブロックチェーン・Web3に関する取り組みを行っています。長年にわたるブロックチェーン・Web3関連の取り組みの実績を活かし、お客様のブロックチェーン・Web3ビジネスの検討やシステムの構築をサポートします。ご要望・ご不明点などがありましたら、ITソリューションサービスサイトよりお問い合わせください。

参考文献

(注1) 本邦初の PTS(私設取引システム)取扱セキュリティ・トークンの引受及び、 セキュリティ・トークンウォレット「Crossllet」開発のお知らせ
https://ssl4.eir-parts.net/doc/8601/ir_material3/218042/00.pdf
(注2)ethereum.org 「ERC-721: Non-Fungible Token Standard」
https://eips.ethereum.org/EIPS/eip-721
(注3)コンテンツ自体のハッシュ値でデータを特定する方式。データが少しでも変更されるとハッシュ値も変わるため、データの完全性を保証しやすい。
(注4)Dapper Labs
https://www.cryptokitties.co/
(注5)ENS Documentation
https://docs.ens.domains/
(注6)Ethereum向けスマートコントラクトの開発環境(開発ツール)
https://hardhat.org/
(注7)Ethereum向けのアプリケーション開発のためのTypeScriptのライブラリ
https://viem.sh/
(注8)スマートコントラクトの開発を行うためのライブラリ
https://www.openzeppelin.com/solidity-contracts
(注9)Node.jsでTypescriptを簡単に実行することができるライブラリ
https://tsx.is/
(注10)画像などのファイルをURL形式の文字列に変換する技術
(注11)スマートコントラクトが備える機能を定義したもの。