伝統的なインターネット業界の 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 契約内で「誰がどのトークンを所有しているか」の外部から取得可能なリストを維持すべきですか?
もしそうであれば、データはマーケット契約内のものと比較して冗長になり、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
を呼び出してマーケット契約に承認を与え、NFT の転送をマーケットに委託する必要があります。
連携も終了し、ついに最後のステップ ——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 契約アドレスが設定されているため、ローカルで直接実行できます。ぜひ参考にし、議論や指摘を歓迎します。