はじめに
この記事はSolidityをちょっと学んだ人にHardhatを触ってもらおうと思って書いています。Hardhatはスマートコントラクトの開発ツールであり、簡単にコンパイル、テスト、デプロイができるほか、Hardhat Networkによって開発用のプライベートイーサリアムネットワークを構築することが可能です。Hardhatに慣れればスマートコントラクトの開発は今までよりも爆速になることでしょう。本記事ではHardhatのインストールからスマートコントラクトのテストネットワークへのデプロイまでをテストコードを書きながらやっていきます。
How to start
とりあえず、チュートリアル用のディレクトリを作り、そこに移動しましょう
$ mkdir hardhat-tutorial; cd hardhat-tutorial
Hardhatは以下のコマンドでインストールができます。(npm, node.jsがインストールされている必要があります)
$ npm install --save-dev hardhat
次に、以下のコマンドでhardhatのサンプルプロジェクトを作ります。
$ npx hardhat init
ここで、プロジェクトについていろんな選択肢が出てくると思いますが、今回は”Create a TypeScript project”を選びましょう。残りの選択肢は全部エンターを押していればOKです。
この時点で $ ls
の結果は以下のようになっていると思います
README.md hardhat.config.ts package-lock.json scripts tsconfig.json
contracts node_modules package.json test
超シンプルなコントラクトを作ってみる
まず、非常にシンプルな数をカウントのみを行うコントラクトを作ってみましょう。デフォルトでcontractsディレクトリにはLock.solというサンプルコードがありますが、これを削除し、代わりにCounter.solを作成します。
$ rm contracts/Lock.sol
$ touch contracts/Counter.sol
Counter.solはこんな感じでどうでしょうか。increment関数でカウントアップ、getCount関数で現在の_count
の値を取得できます。
pragma solidity ^0.8.19;
// import "hardhat/console.sol";
contract Counter {
uint private _count;
constructor(uint initial_value) {
_count = initial_value;
}
function increment() public {
_count++;
}
function getCount() public view returns(uint) {
return _count;
}
}
では、コンパイルしてみましょう。
$ npx hardhat compile
成功した場合には以下のようなログが出ると思います。
Generating typings for: 1 artifacts in dir: typechain-types for target: ethers-v6
Successfully generated 6 typings!
Compiled 1 Solidity file successfully (evm target: paris).
ところで、コンパイルが通った際に、typechain-typesというディレクトリが作成されていることに気が付きましたか?ここにはTypeScriptの型が生成されているので、TypeScriptにanyを生やさなくて済みます!
テストを書いてみる
テストを書いていきましょう。Hardhatでは、テストはTypeScriptで書くことができます。先程typechainで生成した型を適用することにより、補完が効くようになって楽に書くことができるでしょう。
test/Lock.tsは使わないので削除しましょう。代わりに、ここではCounter.tsを作成します。
$ rm test/Lock.ts
$ touch test/Counter.ts
テストはこんな感じでしょうか。beforeEachの中でテスト用のコントラクトインスタンスを作成し、itの中でincrementが正しく動いていることをチェックしています。
import { assert } from "chai";
import { ethers } from "hardhat";
import { Counter } from "../typechain-types";
describe('Counter Test', function() {
let counterContract: Counter;
beforeEach(async ()=> {
// コントラクトを指定してFactoryを取り出す
let counterContractFactory = await ethers.getContractFactory("Counter");
// コントラクトのデプロイ
counterContract = await counterContractFactory.deploy(0);
});
it("can increment the counter", async function () {
assert.equal(await counterContract.getCount(), BigInt(0));
await counterContract.increment();
assert.equal(await counterContract.getCount(), BigInt(1));
});
})
$ npx hardhat test
を実行して、テストが通れば成功です。
console.logを試してみよう
HardhatではSolidity上でconsole.logを実行し、デバッグを行うことが可能です。さっそく試してみましょう。contracts/Counter.solの3行目でコメントアウトしているimport "hardhat/console.sol"
を有効化し、increment()
にconsole.logを挿入してください。
function increment() public {
console.log("before:", _count);
_count++;
console.log("after:", _count);
}
その後、$ npx hardhat test
を再度実行してください。npx hardhat test
ではコンパイルも行われるので、npx hardhat compile
は不要です。
Counter Test
before: 0
after: 1
✔ can increment the counter
1 passing (1s)
のように、beforeとafterのcountの値が出れば成功です!
console.logを利用することで関数の内部で何が起こっているかを把握しやすくなるため、ぜひ積極的に活用していきましょう!
etherを扱うコントラクトを作ってみる
スマートコントラクトでよく行われるのがお金のやり取りです(むしろそれ以外のユースケースが現状あんまりない…)。increment関数をいじって、1weiの支払いを義務付けるようにしましょう。
function increment() public payable {
require(msg.value == 1, "You have to pay 1 wei to increment.");
_count++;
}
その後、$ npx hardhat test
を再度実行すると、以下のようなエラーが出て失敗するはずです。
Error: VM Exception while processing transaction: reverted with reason string 'You have to pay 1 wei to increment.’
そうです。テスト側のコードでは1weiを払わずにincrementをしようとしているのでテストが落ちます。
increment()
に{value: 1}を指定してください。これで通るようになるはずです。
it("can increment the counter", async function () {
assert.equal(await counterContract.getCount(), BigInt(0));
await counterContract.increment({value: 1});
assert.equal(await counterContract.getCount(), BigInt(1));
// コントラクトの残高が1weiになっていることを確認
assert.equal(await ethers.provider.getBalance(await counterContract.getAddress()), BigInt(1));
});
現状のコントラクトでは受け取った1weiを回収する方法はありません。そこで、decrement関数を作り、その際に1weiが返ってくるようにしてみましょう。以下の関数をCounter.solに記述してください。
function decrement() public payable {
_count--;
msg.sender.transfer(1);
}
上記の関数では_count
がアンダーフローするかもしれないと思われるのですが、Solidityのv0.8.0以降は計算時にオーバーフロー、アンダーフローするとリバートするので、まぁ良しとします。
$ npx hardhat compile
でコンパイルが通ることを確認&typechainを再生成します。
さて、次のdecrementのテストなんですが、count
の値が変わることだけでなく、1weiが返ってきているかどうかも検証したいはずです。Singerを利用して、increment関数、decrement関数の実行者を設定し、残高に変化があるかどうかを調べます。
it("can decrement the counter and get reimbursement", async function () {
let signer: Signer = (await ethers.getSigners())[0];
await counterContract.increment({value: 1});
let signerBalanceBefore = await ethers.provider.getBalance(await signer.getAddress());
// decrementの実行者をsignerにする
await counterContract.connect(signer).decrement();
let signerBalanceAfter = await ethers.provider.getBalance(await signer.getAddress());
// コントラクトの残高が0weiになっていることを確認
assert.equal(await ethers.provider.getBalance(await counterContract.getAddress()), BigInt(0));
// 1wei増えていることを確認
assert.equal(signerBalanceAfter - signerBalanceBefore, BigInt(1));
});
これだけだとテストは落ちるはずです。Signerがdecrementを実行したときにガス代分のetherが減るため、実行前と実行後の差が1ではなくなるからです。そこで、hardhatのガス代を0にする設定を行います。
hardhat.config.tsを開き、configを以下のように変えてください
const config: HardhatUserConfig = {
solidity: "0.8.19",
networks: {
hardhat: {
gasPrice: 0,
initialBaseFeePerGas: 0,
},
},
};
これでhardhatネットワークではガス代が0隣、テストが通るようになると思います。
独自のERC20トークンを作ってみる
ここまで、支払いにはetherを利用していましたが、独自トークンに置き換えてみたくはないですか?ということで、独自のERC20トークンを作ってみましょう。ERC20というのは広く使われている代替可能トークンの標準規格の一つで、USDTやBraveブラウザのBAT(Basic Attention Token)などがその例です。
独自のERC20トークンを作るのは驚くほど簡単です。まずは以下のコマンドでopenzeppelinのコントラクトライブラリを利用できるようにしましょう。(今回はライブラリのバージョンを指定していますが、最新のものを取ってきて、Solidityの方のバージョンを合わせるのが理想かもしれません)
$ npm install @openzeppelin/contracts@v4.9.3
続いて、contractディレクトリにSampleToken.solというファイルを作ります。
$ touch contracts/SampleToken.sol
コードですが、本当にこれだけで良いです。とりあえず適当に、デプロイした人に10000000000STを発行するという設定にしています。
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract SampleToken is ERC20 {
constructor() ERC20("Sample Token", "ST"){
_mint(msg.sender, 10000000000);
}
}
次にCounter側をetherからSampleTokenを使うように変えていきましょう。同じくopenzeppelinにIERC20というインターフェイスが用意されているので、これを使えばERC20コントラクトの操作は簡単です。変更後のCounter.solは以下のようになります。コンストラクタでSampleTokenContractのアドレスを渡しています。
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract Counter {
uint private _count;
IERC20 public sample_token;
// SampleTokenコントラクトのアドレスを渡す
constructor(uint initial_value, address tokenAddress) {
_count = initial_value;
sample_token = IERC20(tokenAddress);
}
function increment() public payable {
require(sample_token.transferFrom(msg.sender, address(this), 1), "Transfer failed");
_count++;
}
function decrement() public payable {
_count--;
require(sample_token.transfer(msg.sender, 1), "Transfer failed");
}
function getCount() public view returns(uint) {
return _count;
}
}
それから、テストも変える必要がありますね。ですがその前に、コンストラクタの引数が若干変わったので $ npx hardhat compile
をしましょう。これでtypechainが更新されるはずです。
テストですが、SampleTokenContractを先にデプロイする必要がありますね。なので、上側はこんな感じです。sampleTokenContractのデプロイを行うのはsinger
なので、singer
のアドレスにSTが渡されることになります。(connectの指定を指定しなくてもawait ethers.getSingers()
で取れる最初のSingerがデプロイしたことになるっぽいんですが、わかりやすさのために明示的に指定しています。)
describe('Counter Test', function() {
let sampleTokenContract: SampleToken
let counterContract: Counter;
beforeEach(async ()=> {
let signer: Signer = (await ethers.getSigners())[0];
// コントラクトを指定してFactoryを取り出す
let sampleTokenContractFactory = await ethers.getContractFactory("SampleToken");
let counterContractFactory = await ethers.getContractFactory("Counter");
sampleTokenContract = await sampleTokenContractFactory.connect(signer).deploy();
// コンストラクタへの引数として0, SampleTokenContractのアドレスを指定
counterContract = await counterContractFactory.deploy(0, await sampleTokenContract.getAddress());
});
後半はetherを取得するために利用していたethers.provider.getBalance
を、sampleTokenContract.balanceOf()
に変えれば良さそうです。ということで、
it("can increment the counter", async function () {
assert.equal(await counterContract.getCount(), BigInt(0));
await counterContract.increment();
assert.equal(await counterContract.getCount(), BigInt(1));
// コントラクトの残高が1weiになっていることを確認
assert.equal(await sampleTokenContract.balanceOf(await counterContract.getAddress()), BigInt(1));
});
it("can decrement the counter and get reimbursement", async function () {
let signer: Signer = (await ethers.getSigners())[0];
await counterContract.increment();
let signerBalanceBefore = await sampleTokenContract.balanceOf(await signer.getAddress());
await counterContract.connect(signer).decrement();
let signerBalanceAfter = await sampleTokenContract.balanceOf(await signer.getAddress());
// コントラクトの残高が0weiになっていることを確認
assert.equal(await sampleTokenContract.balanceOf(await counterContract.getAddress()), BigInt(0));
// 1wei増えていることを確認
assert.equal(signerBalanceAfter - signerBalanceBefore, BigInt(1));
});
})
これで $ npx hardhat test
をすると落ちます!
これはなぜかというとincrement関数で実行するsample_token.transferFrom(msg.sender, address(this), 1)
は他人の資産を移動させる関数だからです。つまり、SampleTokenContractの視点ではCounterContractがmsg.senderの財布から勝手に小銭を抜き去っているように見えるので、失敗させています。なので、事前に「CounterContractが1 SampleTokenを抜くことを許可します」というのをSampleTokenContractに伝える必要があるわけです。
これを伝えるために、ERC20にはapprove
という関数があります。それぞれのテストケースに一箇所ずつあるawait counterContract.increment();
の一行前に、await sampleTokenContract.approve(await counterContract.getAddress(), 1);
を挿入すれば、テストが通るようになるはずです。
テストネットへのデプロイ
ここまで作ったコントラクトをデプロイしてみましょう。もちろん、デプロイと言ってもテストネットなのでお金はかかりません。まずは、デプロイ用のスクリプトをscripts/deploy.tsに書き込みます。
import { ethers } from "hardhat";
async function main() {
let sampleTokenContractFactory = await ethers.getContractFactory("SampleToken");
let counterContractFactory = await ethers.getContractFactory("Counter");
// コントラクトのデプロイ
let sampleTokenContract = await sampleTokenContractFactory.deploy();
await sampleTokenContract.waitForDeployment();
let counterContract = await counterContractFactory.deploy(0, await sampleTokenContract.getAddress());
await counterContract.waitForDeployment();
console.log("Deployment succeeded!");
console.log("Address of sampleTokenContract:", await sampleTokenContract.getAddress());
console.log("Address of counterContract:", await counterContract.getAddress());
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
テストコードのbeforeEachの宣言方法に似ていますが、waitForDeployment()
を入れています。また、成功がわかりやすいようにログを出しています。
試しにhardhatネットワークにこのコントラクトをデプロイしてみましょう。
$ npx hardhat node
このコマンドでhardhatネットワークを起動できるかと思います。
続いて、別のターミナルを開いて、以下のコマンドを叩いてください
$ npx hardhat run scripts/deploy.ts --network localhost
”Deployment succeeded!”が表示されたでしょうか。また、成功の場合はhardhatネットワークのログに以下の二つが出力されているはずです。
eth_sendTransaction
Contract deployment: SampleToken
Contract address: 0x5fbdb2315678afecb367f032d93f642f64180aa3
Transaction: 0xc5b3b80f5e1aa2fa4a6d1c42f59acc923950db3b839d0e03c9d5567a0739e66d
From: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Value: 0 ETH
Gas used: 1169421 of 30000000
Block #1: 0x05f9b48549ceb3635dd8d93d9b03c51f25dd9229573a1574a111d86e6563ccf2
.
.
.
eth_sendTransaction
Contract deployment: Counter
Contract address: 0xe7f1725e7734ce288f8367e1bb143e90bb3f0512
Transaction: 0x696cb5987e4014ef2434692169e4d4aca173d18d43d143dc74ca39e7e1ab37eb
From: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Value: 0 ETH
Gas used: 419184 of 30000000
Block #2: 0x474b8710d00f9324064629563bde652ac8db795359f13ee78fc1711d81803c80
上のコマンドでは、--network localhostで、デプロイ先のネットワークを指定しています。次はテストネットのSepoliaETHにデプロイしてみましょうか。
テストネットへのデプロイはゲートウェイを使うか、自分がノードを立ち上げるなど、複数選択肢がありますが、今回はAlchemyを使います。ログインして(初めての方は新規作成)、Appsからcreate new Appsを押してください。ChainはEthereum, NetworkはEthereum Sepoliaを選択します。Nameは何でもいいんですが、”hardhat-tutorial”としましょう。appの作成ができたら、APIキーを確認してください。
次に、hardhat configにネットワークの設定を書き込んでいきます。hardhat.config.tsを開き、networksを以下のように変更します
networks: {
hardhat: {
gasPrice: 0,
initialBaseFeePerGas: 0,
},
sepolia: {
url: 'https://eth-sepolia.g.alchemy.com/v2/ALCHEMY_API_KEY',
accounts: ['SEPOLIA_PRIVATE_KEY']
}
},
ALCHEMY_API_KEYを先程のAPIキー、SEPOLIA_PRIVATE_KEYをあなたのイーサリアムアカウントの秘密鍵で置き換えてください(アカウントにSepoliaのETHがない方はhttps://sepoliafaucet.com/ から入手してください)。
秘密鍵は絶対に公開しないようにしてください!
秘密鍵は.envファイルで管理し、dotenvで取得するのが良い気がします。
$ npx hardhat run scripts/deploy.ts --network sepolia
を実行して、以下のようなログが出れば大成功です。
Deployment succeeded!
address of sampleTokenContract: …
address of counterContract: …
今作成したコントラクトは、https://sepolia.etherscan.io/ を開いて、検索窓にコントラクトアドレスを入れてみると見つかるはずです。
おわりに
本記事ではHardhatを用いたスマートコントラクトの開発、テスト、デプロイまでを一通りやりました。Hardhatはドキュメントが充実していたり、問題に当たった時はそれらが参考になるでしょう。また、多くのプライグインがあったりするので、興味がある方は見てみると良いでしょう。