伝統的なインターネット業界の Web フロントエンド開発に対して悲観的な大齢のフロントエンドエンジニアとして、私は自分の知識とスキルを最大限に活用できる道を探し続けてきました ——Web3 分野のフルスタック開発への転身を選びました。
この決断を下したとき、私は Web3 についてあまり理解していませんでした。業界の人間としても普通のユーザーとしても、私は迅速に入門できる方法を切実に求めていました!
偶然の機会で、私はOpenBuildの《Web3 フロントエンドトレーニングキャンプ》を知りました。内容の紹介を見たところ、私のニーズを満たすことができそうだったので、ためらうことなく申し込みました —— 無料だし、「マニー」も稼げるし、ためらう理由なんてありません!
この記事の内容は、トレーニングキャンプのコースの実践ノートであり、「フロントエンド開発の基礎があるスマートコントラクトの初心者が自分の最初の NFT マーケット dApp をどのように開発するか」というテーマで書かれています。つまり、task 3、task 4、およびtask 5をカバーします。
私は Web3 に転身したばかりの初心者なので、多くのことがよくわかりません。以下の内容は私の個人的な理解を示しており、誤りや漏れがあれば指摘してください。
「スマートコントラクト」という名前は、そのビジネス上の役割に由来していると思いますが、開発者にとっては単なるソフトウェアプログラムであり、特定のプログラミング言語でコードを書く必要があります。
したがって、イーサリアムのスマートコントラクトを書くためには、Solidity の構文、ERC、およびオンチェーンの相互作用プロセスを学び、理解する必要があります。これらを理解すればコードが正しく書けるようになり、残りはデプロイです。
Solidity の学習#
プログラミング経験が豊富な人は、Solidity がオブジェクト指向の静的型付け言語であることを一目で理解できます。いくつかの馴染みのないキーワードがありますが、全体として **「契約」という外衣をまとった「クラス」** として見ることができます。
そのため、TS や Java などの型付きのクラスベースのプログラミング言語に慣れている場合、マッピング関係を構築することで Solidity を迅速に理解することができます。
contract
キーワードはclass
キーワードのドメイン特化型変形と考えられ、「契約」という概念をより意味的に表現します。したがって、契約を書くことはクラスを書くことに相当します。
状態変数は契約内のデータを保存するために使用され、クラスのメンバー変数、つまりクラス属性に相当します。
関数は契約内部に定義することも、外部に定義することもできます —— 前者はクラスのメンバー関数、つまりクラスメソッドに相当し、後者は通常の関数で、一般的にはいくつかのユーティリティ関数です。
TS や Java とは異なり、Solidity では可視性修飾子は最初に配置されず、変数と関数に対して位置が不一致です。これは少し直感に反します。
private
とpublic
の意味は他の言語と同じですが、protected
はなく、代わりにinternal
があります。また、外部呼び出し専用のexternal
も追加されています。
関数修飾子は TS のデコレーターや Java のアノテーションに相当し、アスペクト指向プログラミング(AOP)を行うことができます。関数と関数修飾子は、派生した契約によってオーバーライドされることがあります。
以下のいくつかのタイプは ES のオブジェクトと見なすことができますが、使用シーンは異なります:
- 構造体(
struct
)はエンティティを定義するために使用されます; - 列挙型(
enum
)は有限の選択肢の集合です; - マッピング(
mapping
)は無限の選択肢です。
Solidity は多重継承と関数の多態性をサポートしており、再利用をより良く組み合わせることができます。契約の開発には ERC 駆動の傾向があるため、多重継承の副作用は他の言語ほど深刻ではないはずです。
Solidity はブロックチェーンのために生まれ、ブロックチェーン自体およびアプリケーションシーンの特性により、イベントと外部通信、エラーが発生した場合の以前の操作のロールバックをサポートすることは「必須」と言えます。したがって、文法レベルでイベントとエラー関連の処理をサポートしています。
require()
という関数の使い方は私にとって少し特別で、require(initialValue > 999, "Initial supply must be greater than 999.");
は以下の ES コードの簡潔な意味的バージョンに相当します:
if (initialValue <= 999) {
throw new Error('Initial supply must be greater than 999.');
}
ERC の理解#
イーサリアムにおける「ERC」の正式名称は「Ethereum Request for Comments」で、EIP(Ethereum Improvement Proposal)の一種であり、スマートコントラクトアプリケーションに関連する標準と合意を定義しています。
Web3 が推奨するのは分散化とオープン性であり、スマートコントラクトアプリケーションの相互運用性を保証することが基本的な要件となるため、この分野の標準としての ERC は非常に重要です。
イーサリアムのスマートコントラクトアプリケーション開発における最も基本的な ERC は以下の 2 つです:
- ERC-20—— 同質化トークンで、仮想通貨や貢献ポイントなど、準金融システムのインフラストラクチャとして機能します;
- ERC-721—— 非同質化トークン(NFT)で、勲章、証明書、チケットなど、アイデンティティシステムのインフラストラクチャとして機能します。
実際、ERC は権威ある API ドキュメントと見なすことができます。
スマートコントラクトの作成#
スマートコントラクトアプリケーションを開発する際には、支援するフレームワークを選択する必要があります。Hardhat と Foundry がよく使われているようですが —— 私は前者を選びました。なぜなら、JS 技術スタックに優しいからです。つまり、フロントエンド開発から転身する人にとって友好的です。
IDE の選択に関しては、多くの人がイーサリアム公式が提供する Remix を使用しますが、私は VS Code を使い続けました。主に、入門時に学習コストをできるだけ減らしたいからです。
Hardhat についてよく知らない場合は、公式チュートリアルに従って、選択的に一歩ずつ実行環境を構築できます。生成されるディレクトリ構造には、hardhat.config.ts
という設定ファイルの他に、基本的に 4 つのフォルダとそのファイルに注目すればよいです:
contracts
—— スマートコントラクトのソースコード;artifacts
——hardhat compile
によって生成されたコンパイル後のファイル;ignition
——Hardhat Ignition に基づいてスマートコントラクトをデプロイするための;test
—— スマートコントラクトの機能テストコード。
ignition
内にもコンパイル後のファイルが生成されますが、artifacts
とは異なり、デプロイ対象のチェーンにバインドされており、つまりデプロイするチェーン ID のフォルダに生成されます。
トレーニングキャンプの宿題である 3 つのタスクは、ERC-20 トークン、ERC-721 トークン、NFT マーケットの 3 つの契約に関わっています。そのうち前者の 2 つのトークン契約は、検証済みのOpenZeppelin Contractsを利用して、それを基に拡張することができます。
私の ERC-20 トークン RaiCoin の実装コードは以下の通りです:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract RaiCoin is ERC20("RaiCoin", "RAIC") {
constructor(uint256 initialValue) {
require(initialValue > 999, "Initial supply must be greater than 999.");
_mint(msg.sender, initialValue * 10 ** 2);
}
function decimals() public view virtual override returns (uint8) {
return 2;
}
}
初期化時に一定量のトークン(通常は非常に大きな数)をミントし、所有者を自分のアカウントアドレスに設定するのが最善です。そうしないと、後で取引を行う際に残高がないというエラーが表示され、処理が面倒になります。
上記のコードのmsg.sender
はconstructor()
内で実際に契約をデプロイするアカウントアドレスであり、自分のアカウントアドレスでデプロイする場合、初期トークンはすべて自分のアカウントに入ります。
自分の ERC-20 トークンはただの遊びの性質であり、価値が上がることはないので、OpenZeppelin のdecimals()
をオーバーライドして数値を少し小さく設定することを考慮できます。
以下は ERC-721 トークン RaiE の実装コードです:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract RaiE is ERC721 {
uint256 private _totalCount;
constructor() ERC721("RaiE", "RAIE") {
_totalCount++;
}
function mint() external returns (uint256) {
uint256 tokenId = _totalCount;
_safeMint(msg.sender, tokenId);
_totalCount++;
return tokenId;
}
}
私は追加でmint()
を実装しましたが、パラメータは一切なく、単純にトークンを発行するだけです。なぜなら、NFT には対応する画像が必要ではないのか?その具体的な理由は後述します。
これらの 2 つのトークン契約はほとんど手間がかからず、実際に考えるべきところは主に NFT マーケット契約に集中しています。たとえば ——
マーケット内の NFT リストはページ分割する必要がありますか?
ページ分割すると、ページをめくるたびに遅延が目立ち、フロントエンドのユーザー体験が悪化します。しかし、ページ分割しないと、NFT の数が多い場合にも同様の問題が発生します。
NFT の画像 URL はどこに保存すべきですか?NFT 契約内ですか、それともマーケット契約内ですか?
理論的には NFT 契約内に保存すべきですが、そうすると NFT リストを取得する際に外部呼び出しを頻繁に行うことになり、パフォーマンスとユーザー体験に影響を与えます。
NFT 契約内に「誰がどのトークンを所有しているか」の外部から取得可能なリストを維持すべきですか?
もし必要であれば、データはマーケット契約内と比較して冗長になり、NFT 契約が非常に肥大化します。もし必要でなければ、どのトークンが誰に属しているのかを明示的に知ることができません。
このように、ブロックチェーン関連技術だけに依存して製品レベルのアプリケーションを作成することは、現時点では大きな制限があり、ユーザー体験が非常に悪いです!
つまり、製品のパフォーマンスと体験は、従来のアプリケーションアーキテクチャに依存する必要があり、ブロックチェーンはあくまでアイデンティティ検証と一部のデータの「バックアップ」として使用されるべきです。
したがって、私は一時的に製品指向の思考を放棄し、どこが合理的かどうかにこだわらず、まずは宿題の要件を満たすことに焦点を当てることにしました —— 関連機能があればそれで良いのです。
こうすることで、決定は非常に簡単になります —— 宿題をより早く完成させるにはどうすればよいかを考えればいいのです!その結果、上記の 3 つの疑問はすぐに解消されました:
- マーケット内の NFT リストはページ分割しない ——NFT は数個しかないからです;
- NFT の画像 URL はマーケット契約内に保存 ——NFT 契約は自分のマーケット契約のみで使用されます;
- NFT 契約内でトークンの所有権リストを維持しない —— 一時的な操作時にどのアカウントがどのトークンをミントしたかを記憶できます。
NFT マーケット RaiGallery を実装する際に、配列のみが反復可能であり、mapping
はそうではないことに気づきました。また、初期化時に指定された長さの配列には.push()
で要素を追加できず、インデックスを使用する必要があります:
contract RaiGallery {
struct NftItem { address seller; address nftContract; uint tokenId; string tokenUrl; uint256 price; uint256 listedAt; bool listing; }
struct NftIndex { address nftContract; uint tokenId; }
NftIndex[] private _allNfts;
function getAll() external view returns (NftItem[] memory) {
// 初期化時に配列の長さを指定しました
NftItem[] memory allItem = new NftItem[](_allNfts.length);
NftIndex memory nftIdx;
for (uint256 i = 0; i < _allNfts.length; i++) {
nftIdx = _allNfts[i];
// ここで`allItem.push(_listedNfts[nftIdx.nftContract][nftIdx.tokenId])`を使うとエラーになります
allItem[i] = _listedNfts[nftIdx.nftContract][nftIdx.tokenId];
}
return allItem;
}
}
スマートコントラクトのデバッグ#
スマートコントラクトのソースコードを書き終えたら、まずテストコードを書いて、いくつかの基本的な問題を明らかにし、解決する必要があります。
前述のように、Hardhat プロジェクトではテストコードはtest
フォルダに配置され、基本的に各ファイルは 1 つの契約に対応しています。もちろん、異なるファイル間の再利用可能なロジックを抽出して、helper.ts
のような追加のファイルに置くこともできます。
テストコードは Mocha と Chai の API に基づいて書かれ、実際に契約機能をテストする前に、契約をローカル環境にデプロイする必要があります。これは内蔵のhardhat
でも、ローカルノードlocalhost
を起動することもできますが、私は前者を選びました。
この時、デプロイ方法は Hardhat Ignition モジュールを再利用できますが、まだその使い方を理解していないので、より理解しやすいloadFixture()
を使用しました。
テストはかなり手間がかかり、ほぼ 1 日の時間を費やしましたが、この過程で ERC-20 トークン、ERC-721 トークン、NFT マーケット、ユーザーの 4 者間の相互作用についてより深く理解することができました。たとえば:
- 契約インスタンスを直接使用してメソッドを呼び出す場合、呼び出し元は契約自体であるため、
契約インスタンス.connect(あるアカウント)
を使用してから呼び出す必要があります。これにより、ユーザーとの操作をシミュレートできます; - NFT の所有者は、自分のすべての NFT を NFT マーケットに上架する前に、
.setApprovalForAll(マーケット契約アドレス, true)
を呼び出して承認する必要があります。
スマートコントラクトの単体テストがほぼ完了したら、ローカルノードにデプロイしてフロントエンドと連携する必要があります。この時、Hardhat Ignition モジュールを使用します。
ドキュメントを見て学ぶ際、少し難解に感じ、見ていると眠くなるような印象を受けました。しかし、今振り返ると、各モジュールは実際にはそのモジュールに対応する契約をデプロイする際にどのように初期化すべきかを説明しているだけです。
Hardhat Ignition はサブモジュールをサポートしており、.useModule()
を使用することで、モジュールをコンパイルしてデプロイする際にサブモジュールも一緒に処理できます。つまり ——
私がRaiCoin.ts
、RaiE.ts
、RaiGallery.ts
の 3 つのモジュールを持っていると仮定します。この場合、RaiGallery.ts
をデプロイする際にRaiCoin.ts
から返されたアドレスが必要です。したがって、RaiCoin.ts
をRaiGallery.ts
のサブモジュールとして指定できます:
import { buildModule } from '@nomicfoundation/hardhat-ignition/modules';
import RaiCoin from './RaiCoin';
export default buildModule('RaiGallery', m => {
const { coin } = m.useModule(RaiCoin);
const gallery = m.contract('RaiGallery', [coin]);
return { gallery };
});
このように、RaiE.ts
は単独でデプロイされ、RaiGallery.ts
をデプロイする際にはRaiCoin.ts
も同時にデプロイされるため、デプロイコマンドを 2 回実行するだけで済みます。
次に、hardhat.config.ts
のdefaultNetwork
設定を'localhost'
に変更し、Hardhat プロジェクトのルートディレクトリでnpx hardhat node
を実行してローカルノードを起動します。次に、別のターミナルウィンドウを開いてスマートコントラクトをデプロイします:
npx hardhat ignition deploy ./ignition/modules/RaiE.ts
を実行して ERC-721 トークン契約をデプロイします;npx hardhat ignition deploy ./ignition/modules/RaiGallery.ts
を実行して ERC-20 トークン契約と NFT マーケット契約をデプロイします。
すべてのデプロイが成功すると、ignition/deployments/chain-31337
フォルダ(「31337」はローカルノードのチェーン ID)にコンパイル後の契約関連ファイルが生成されます:
deployed_addresses.json
には契約アドレスが列挙されています;artifacts
フォルダ内の JSON ファイルには契約の ABI が含まれています。
これらの 2 つの重要な情報は、フロントエンドプロジェクトのグローバル共有変数にコピー&ペーストして、連携時に使用します。
連携を開始する前に、MetaMask ウォレットで 2 つのことを行う必要があります:
- Hardhat ローカルノードをネットワークに追加します。YouTube 動画《Metamask にローカルテストネットを追加する方法》を参考にしてください;
- 公式サイトに従って、自分がテストしている ERC-20 トークン契約アドレスを追加して、アカウント残高を確認しやすくします。
フロントエンド部分で依存している主なサードパーティライブラリとフレームワークは Vite、React、Ant Design Web3、Wagmi です。フロントエンドは私が慣れている部分なので、特に感想はありませんが、詳しくは述べません。
しかし、フロントエンド部分を開発する際に、1 つの点でしばらく悩みました ——
プログラム上は新しい NFT をミントしてからマーケットに上架して取引する必要がありますが、インターフェース上では一度に完了するべきです。つまり、NFT に関連する情報を入力して「確定」をクリックすれば、直接上架されるべきです。
しかし、宿題の要件は先にミントしてから上架する 2 つの操作であり、これは少し不合理だと感じました。あるいは、ユーザー体験が良くないと言えます。
最終的には、Wagmi の使用に不慣れで実現方法を考え出せず、宿題を急いで提出したいと思い、これ以上悩むことはありませんでした……😂😂😂
連携中に問題が発生した場合は、以下の手順で順に確認してください:
- NFT を上架する際には、まず NFT トークン契約の
setApprovalForAll
を呼び出してマーケット契約に承認を与え、マーケットが NFT を転送できるようにします; - 上架リクエストを送信する前に、viem または ethers の
parseUnits
を使用して、自分の ERC-20 トークン契約で定義されたdecimals()
に合った数値に変換します(デフォルトは18
です); - NFT を購入する前に、ウォレットで現在のアカウントの自分の ERC-20 トークン残高が十分かどうかを確認します。イーサ(ETH)を自分の ERC-20 トークンの残高と混同しないように注意してください;
- NFT を購入する際には、自分の ERC-20 トークン契約の
approve
を呼び出してマーケット契約に承認を与え、マーケットが転送を代行できるようにします。
連携も終了し、ついに最後のステップ ——Sepolia テストネットへのデプロイです!
これには Sepolia のイーサが必要で、一般的な取得方法は「水道」から少しずつ受け取ることです。毎日少ししか得られませんが、@Mika-Lahtinenが提供した PoW の方法があり、詳細は@zer0fireのノート《🚀超簡単水道チュートリアル - 取引履歴やアカウント残高不要》を参照してください。
この時、Hardhat プロジェクトに目を戻し、hardhat.config.ts
ファイルを開いてdefaultNetwork
を一時的に'sepolia'
に変更し、networks
にsepolia
を追加します:
const config: HardhatUserConfig = {
defaultNetwork: 'sepolia', // デフォルトネットワークを一時的にこれに変更
networks: {
sepolia: {
url: 'あなたのSepoliaエンドポイントURL',
accounts: ['あなたのウォレットアカウントの秘密鍵'],
},
},
};
ここで、Sepolia エンドポイントはInfuraまたはAlchemyのアカウントを登録することで取得できます。
その後、前述のローカルノードへのデプロイ手順を再度実行し、フロントエンドでテストネット環境の機能を確認した後、宿題を提出できます!
結論#
私は NFT マーケットに関連するコードをすべてourai/my-first-nft-market
でオープンソースにしました。今後、上記で述べた悩みをできるだけ解決し、この種のデモの標準を作り上げるつもりです。
すでに Sepolia 契約アドレスが設定されているため、直接ローカルで実行して操作できます。ぜひ参考にし、議論や指摘を歓迎します。