ブロックチェーン技術ブログ

ブロックチェーンと英語、数学に関するブログ

Ethereum クライアント < Go Ethereum / Prysm >

今回は、Ethereumクライアントについてみていきます.
Ethereumクライアントは、Ethereumのブロックチェーンをダウンロードし、各ブロックを検証し、Ethereumのネットワークにアクセスするのに使用するソフトウェアです.
また、Ethereumクライアントが他のEthereumクライアントと接続しているとき、それをノード(node)と呼びます.

Execution Layer / Consensus Layer

Ethereumクライアントには、実行クライアント(execution client)とコンセンサスクライアント(consensus client)の2種類があります.

実行クライアントは、トランザクションを束ねて、EVM上でそれらを実行し、ステートの管理をおこないます.
コンセンサスクライアントは、proof-of-stakeコンセンサスアルゴリズムを調整する役割を担っています.

なお、The Merge 以降、実行クライアントを単独で実行することはできません.
Ethereumネットワークに参加するには、実行クライアントとコンセンサスクライアントの両方を実行する必要があります.

Node タイプ

Ethereumクライアントを実行するときは、いくつかのモードを選択します.

Full node

ブロックチェーン上の全データを保持し、全てのブロックとステートを検証します.(古いステートは削除される)

Light node

ブロックチェーン上のヘッダーデータのみ保持し、 そのデータを検証します.
データは、Full nodeから取得します.
また Light nodeの場合、マイナーやバリデーターになることはできません.

Geth

それでは、実際に実行クライアントとコンセンサスクライアントのソフトウェアをそれぞれみていきましょう.

Go Ethereum (Geth)

Go Ethereum(Geth)は、Go言語で実装された実行クライアントです.
現在、実行クライアントの大半を占めるソフトウェアです.

Sync モード

Gethが最新のブロックやグローバルステートを維持するのにいくつのか方法があります.

Full sync

Full syncは、最初のブロック(genesis block)から最新のブロックまでのトランザクションを順に実行して、最新のステートを生成します.
最も安全な方法ですが、時間がかかるという欠点があります.

Snap sync

Snap syncは、チェックポイントとなるブロックを取得し、その時点のステートを正しいとみなし、そのブロックを起点にして、最新のステートを生成します.
Full syncよりもはるかに高速で、GethのデフォルトのSync モードとなっています.

Prysm

Rrysmは、Go言語で実装されたコンセンサスクライアントです.
GethとRrysmを一緒に実行することで、サードパーティーを利用せずに、Ethereumネットワークにアクセスできます.

また、32ETHをステークすることで、バリデーターになることもできます.
バリデーターになることで、ステーキング報酬やトランザクション報酬を得ることができます.

Geth と Prysm を一緒に実行する

今回は、メインネットで、Geth と Prysm を動かしてみます.

Geth を実行する

まず、以下のコマンドを実行して、Gethを起動します.
--datadir のみデフォルトと異なる値を設定しています.

geth --datadir geth-example\
 --authrpc.addr localhost\
 --authrpc.port 8551\
 --authrpc.vhosts localhost

各オプションの説明は以下です.

オプション 説明
--datadir データベースとkey storeを格納するディレクト
default: ~/ethereum
--authrpc.addr 認証済みAPIのアドレス
default: localhost
--authrpc.port 認証済みAPIのポート番号
default: 8551
--authrpc.vhosts リクエストを受け入れる仮想ホスト名
default: localhost

上記のコマンドを実行すると、下記のログが出力されます.

threeislands:~$ geth --datadir geth-example --authrpc.addr localhost --authrpc.port 8551 --authrpc.vhosts localhost
INFO [11-29|11:23:30.394] Starting Geth on Ethereum mainnet... 
INFO [11-29|11:23:30.394] Bumping default cache on mainnet         provided=1024 updated=4096
INFO [11-29|11:23:30.396] Maximum peer count                       ETH=50 LES=0 total=50
INFO [11-29|11:23:30.400] Set global gas cap                       cap=50,000,000
INFO [11-29|11:23:30.401] Allocated trie memory caches             clean=614.00MiB dirty=1024.00MiB
INFO [11-29|11:23:30.401] Allocated cache and file handles         database=/Users/threeislands/geth-example/geth/chaindata cache=2.00GiB handles=5120
INFO [11-29|11:23:30.472] Opened ancient database                  database=/Users/threeislands/geth-example/geth/chaindata/ancient/chain readonly=false
INFO [11-29|11:23:30.472] Writing default main-net genesis block 
INFO [11-29|11:23:30.617] Persisted trie from memory database      nodes=12356 size=1.78MiB time=13.440166ms gcnodes=0 gcsize=0.00B gctime=0s livenodes=1 livesize=0.00B
INFO [11-29|11:23:30.625]  
INFO [11-29|11:23:30.625] --------------------------------------------------------------------------------------------------------------------------------------------------------- 
INFO [11-29|11:23:30.625] Chain ID:  1 (mainnet) 
INFO [11-29|11:23:30.625] Consensus: Beacon (proof-of-stake), merged from Ethash (proof-of-work) 
INFO [11-29|11:23:30.625]  
INFO [11-29|11:23:30.625] Pre-Merge hard forks: 
INFO [11-29|11:23:30.625]  - Homestead:                   1150000  (https://github.com/ethereum/execution-specs/blob/master/network-upgrades/mainnet-upgrades/homestead.md) 
INFO [11-29|11:23:30.625]  - DAO Fork:                    1920000  (https://github.com/ethereum/execution-specs/blob/master/network-upgrades/mainnet-upgrades/dao-fork.md) 
INFO [11-29|11:23:30.625]  - Tangerine Whistle (EIP 150): 2463000  (https://github.com/ethereum/execution-specs/blob/master/network-upgrades/mainnet-upgrades/tangerine-whistle.md) 
INFO [11-29|11:23:30.625]  - Spurious Dragon/1 (EIP 155): 2675000  (https://github.com/ethereum/execution-specs/blob/master/network-upgrades/mainnet-upgrades/spurious-dragon.md) 
INFO [11-29|11:23:30.625]  - Spurious Dragon/2 (EIP 158): 2675000  (https://github.com/ethereum/execution-specs/blob/master/network-upgrades/mainnet-upgrades/spurious-dragon.md) 

JWT Secret を使用して Geth と Prysm を接続する

Geth と Prysm を接続するには、JWT Secretを共有して認証する必要があります.
JWT Secretを生成する方法はいくつかありますが、今回はGeth起動時に生成さるものを使用します.

Gethのログに生成されたJWT Secretのパスが出力されるので、これをPrysmから参照します.

INFO [11-29|11:23:30.655] Generated JWT secret     path=/Users/threeislands/geth-example/geth/jwtsecret

Prysmを起動する

threeislands:prysm$ ./prysm.sh beacon-chain\
 --execution-endpoint=http://localhost:8551\
 --checkpoint-sync-url=https://checkpointz.pietjepuk.net\
 --genesis-beacon-api-url=https://checkpointz.pietjepuk.net\
 --jwt-secret=/Users/threeislands/geth-example/geth/jwtsecret\
 --datadir prysm-example

各オプションの説明は以下です.

オプション 説明
--execution-endpoint 実行クライアントのHTTPエンドポイントhttp://localhost:8551
--checkpoint-sync-url チェックポイントのデータを取得する同期済みのBeaconノードのURL
--genesis-beacon-api-url Genesis ステートを取得する同期済みのBeaconノードのURL
--jwt-secret 実行クライアントの認証に使用するJWT Secretのパス
--datadir データベースのディレクト
default: /root/.eth2

Prysmは、通常Genesisから同期しますが、その場合同期に時間がかかります.
そのため、今回は --checkpoint-sync-url, --genesis-beacon-api-url を指定して、チェックポイントから同期するようにしています.
(チェックポイントは、信頼できる機関から取得する必要があります)

上記のコマンドを実行すると、Prysmが起動し、下記のログが出力されます.

threeislands:prysm$ ./prysm.sh beacon-chain --execution-endpoint=http://localhost:8551 --checkpoint-sync-url=https://checkpointz.pietjepuk.net --genesis-beacon-api-url=https://checkpointz.pietjepuk.net  --jwt-secret=/Users/threeislands/geth-example/geth/jwtsecret --datadir prysm-example           
Latest Prysm version is v3.1.2.
Beacon chain is up to date.
Verifying binary integrity.
beacon-chain-v3.1.2-darwin-amd64: OK
gpg: Signature made 金 10/28 02:06:03 2022 JST
gpg:                using RSA key 0AE0051D647BA3C1A917AF4072E33E4DF1A5036E
gpg: Good signature from "Preston Van Loon <preston@prysmaticlabs.com>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: 0AE0 051D 647B A3C1 A917  AF40 72E3 3E4D F1A5 036E
Verified /Users/threeislands/ethereum/consensus/prysm/./dist/beacon-chain-v3.1.2-darwin-amd64 has been signed by Prysmatic Labs.
Starting Prysm beacon-chain --execution-endpoint=http://localhost:8551 --checkpoint-sync-url=https://checkpointz.pietjepuk.net --genesis-beacon-api-url=https://checkpointz.pietjepuk.net --jwt-secret=/Users/threeislands/geth-example/geth/jwtsecret --datadir prysm-example

Prysmatic Labs Terms of Use

By downloading, accessing or using the Prysm implementation (“Prysm”), you (referenced herein
as “you” or the “user”) certify that you have read and agreed to the terms and conditions below.

TERMS AND CONDITIONS: https://github.com/prysmaticlabs/prysm/blob/master/TERMS_OF_SERVICE.md


Type "accept" to accept this terms and conditions [accept/decline]: (default: decline):
accept
[2022-11-29 11:26:09]  INFO Finished reading JWT secret from /Users/threeislands/geth-example/geth/jwtsecret
[2022-11-29 11:26:09]  WARN flags: Running on Ethereum Mainnet
[2022-11-29 11:26:10]  WARN node: In order to receive transaction fees from proposing blocks, you must provide flag --suggested-fee-recipient with a valid ethereum address when starting your beacon node. Please see our documentation for more information on this requirement (https://docs.prylabs.network/docs/execution-node/fee-recipient).
[2022-11-29 11:26:10]  INFO node: Checking DB database-path=prysm-example/beaconchaindata
[2022-11-29 11:26:10]  INFO db: Opening Bolt DB at prysm-example/beaconchaindata/beaconchain.db
[2022-11-29 11:26:10]  WARN database contains genesis with htr=0x7e76880eb67bbdc86250aa578958e9d0675e64e714337855204fb5abaaf82c2b, ignoring remote genesis state parameter
[2022-11-29 11:26:10]  INFO requesting https://checkpointz.pietjepuk.net/eth/v2/debug/beacon/states/finalized
[2022-11-29 11:26:22]  INFO detected supported config in remote finalized state, name=mainnet, fork=bellatrix
[2022-11-29 11:26:26]  INFO requesting https://checkpointz.pietjepuk.net/eth/v2/beacon/blocks/0x2976eab762f2da894433b1d6db0c390086c722bef6b247ab2cf1624e3d5b892b
[2022-11-29 11:26:27]  INFO BeaconState slot=5238656, Block slot=5238656
[2022-11-29 11:26:27]  INFO BeaconState htr=0xb8b3dec9b6d8d0a9b3fc7699cc28af438d904ccc106718b24fc9ed5d3e29fb20d, Block state_root=0xb8b3dec9b6d8d0a9b3fc7699cc28af438d904ccc106718b24fc9ed5d3

GethのログがPrysmと接続されているかもしくはPrysmが同期済みに応じて、変化する様子をみてみます.

まずは、Prysmと接続されていないときのログです.
Geth(実行クライアント)は、Prysm(コンセンサスクライアント)なしで、同期できません.

WARN [11-29|11:53:48.736] Post-merge network, but no beacon client seen. Please launch one to follow the chain! 

次は、接続しているPrysmが同期されていないときのログです.

WARN [11-29|12:02:28.840] Beacon client online, but never received consensus updates. Please ensure your beacon client is operational to follow the chain! 

最後は、接続しているPrysmが同期されているときのログです.

INFO [11-29|12:05:36.182] Left PoW stage 
INFO [11-29|12:05:36.182] Forkchoice requested sync to new head    number=16,072,772 hash=22e82a..4c8d92
WARN [11-29|12:05:37.391] Dropping unsynced node during sync       id=ec77917b25d3db2f conn=dyndial addr=51.222.104.182:30304 type=Geth/v1.10.17-stable...
INFO [11-29|12:05:37.884] Syncing beacon headers                   downloaded=1024 left=16,071,747 eta=7h25m13.965s

今回は、Geth と Prysm を一緒に動かしてみました.
今後は、テストネットで32ETHをステークして、バリデーターの検証をしてみたいと思います.(32ETH集めるのが大変ですが。。)

ERC20を実装したトークンを作成する

今回は、ERC20の規格に準拠したトークンを作成してみましょう.

ERC20は、FT(Fungible Token)のためのインタフェースです。
ERC20に準拠することで、スマートコントラクトから扱いやすくなり、別の種類のトークンとの交換が容易になります。

ここで、FTとは代替可能なトークンであり、同じ種類のトークンであれば、すべてのトークンで同じ価値を持ちます.
BitcoinやEtherもFTです.
また、1 Bitcoinを0.7 Bitcoinと0.3 Bitcoinのように分割することも可能です.

FTと対になるものがNFT(Non-Fungible Token)です.
NFTは、各トークンが唯一無二であり、異なる性質を持ちます.
NFTはデータにイラストなどのコンテンツを参照するアドレスを保持します.
OpenSeaといったマーケットプレイスで、NFTを購入することができます.
また、FTのように、1つのNFTをより小さい単位に分割することはできません.

ERC20のインタフェース

ERC20に準拠するには、以下のMethodsとEventsを実装する必要があります.
以下に、MethodsとEventsのシグネチャと説明を記載しました.

EIP-20: Token Standard からMethods, Eventsのシグネチャを引用

Methods

メソッド名 引数 返り値 必須 説明
name - string - トークンの名前を返す
symbol - string - トークンのシンボル(ex: USDT)を返す
decimals - uint8 - ユーザのトークンの保有量を表示するときに、この数で割った値を使用する
totalSupply - uint256 トークンの総量を返す
balanceOf address _owner uint256 balance _ownerのアドレスのトークンの残高を返す
transfer address _to,
uint256 _value
bool success トークンを自身のアドレスからtoのアドレスに転送する
転送に成功した場合、Transferイベントを発火する必要がある
transferFrom address _from,
address _to,
uint256 _value
bool success トークンをfromのアドレスからtoのアドレスに転送する
転送に成功した場合、Transferイベントを発火する必要がある
approve address _spender,
uint256 _value
bool success ユーザが_valueで指定した量までのトークンを、_spenderが自身のアドレスに転送することを許可する
allowance address _owner,
address _spender
uint256 remaining ユーザが_ownerのアドレスから自身のアドレスに転送可能なトークンの量を返す

Events

イベント名 引数 説明
Transfer address indexed _from,
address indexed _to,
uint256 _value
トークンが転送されたときや新たにミントされたときに発火するイベント
Approval address indexed _owner,
address indexed _spender,
uint256 _value
approveの呼び出しに成功したときに発火するイベント

ERC20に準拠したトークンの実装

では、実際にトークンを実装してみましょう.
今回は、OpenZeppelinのERC20を継承して実装しました.
ERC 20 - OpenZeppelin Docs

ここで、ERC20IERC20を実装したものです.
以下のリンクからソースコードを確認できます.
github.com

ソースコード

今回、作成したトークンは、LotteryToken です.

LotteryToken は、以下の性質を持ちます.

  • mintと一緒にETHを送金する(コントラクトに預ける)ことで、送金したETHと同じの量のトークンを入手できる
  • 送金したETHと同じ量のトークンをburnすることで、コントラクトから送金したETHを引き落とすことができる
  • トークンを転送したとき(mint, burnを除く)に、トークンがmintされ、ETHを預けているアカウントのいずれかに転送される
    (トークンが転送されるアカウントは、預けているETHの割合に応じて決定される)

ソースコード全体は、以下のリンクから確認できます.
github.com

//SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/structs/EnumerableMap.sol";

contract LotteryToken is ERC20 {
    using EnumerableMap for EnumerableMap.AddressToUintMap;

    // transferが発生したときに、いずれかのアカウントに転送されるトークンの量
    uint REWARD = 100;

    // 各アカウント預けているETHの量を表すのに、EnumerableMapを使用した.
    // これを使うことで、Mapの要素数を取得したり、イテレートが可能.
    EnumerableMap.AddressToUintMap etherBalances;

    event Win(address indexed winner, uint amount);

    // コンストラクターで、name, symbol を設定
    constructor() ERC20("LotteryToken", "LT") {}

    function mint() external payable {
        require(msg.value > 0, "ETH is required to mint tokens.");

        (, uint amount) = etherBalances.tryGet(msg.sender);
        etherBalances.set(msg.sender, amount + msg.value);

        _mint(msg.sender, msg.value);
    }

    function redeem(uint amount) public {
        require(balanceOf(msg.sender) >= amount, "Redemption amount must not exceed balance.");

        // 引き落とすETHと同じ量のトークンを保持している必要がある
        (, uint curAmount) = etherBalances.tryGet(msg.sender);
        require(curAmount >= amount, "Redemption amount must not exceed ether balance.");

        _burn(msg.sender, amount);

        etherBalances.set(msg.sender, curAmount - amount);

        (bool sent,) = payable(msg.sender).call{value : amount}("");
        require(sent, "Failed to send Ether");
    }

    // transferの完了後に呼ばれるHook.
    function _afterTokenTransfer(address from, address to, uint256 amount) internal override {
        // mintもしくはburnしたときは、報酬なし
        if (from == address(0) || to == address(0)) return;

        // パラメータ群から、0 <= x < コントラクト残高 の範囲の値を生成する
        // 完全なランダムな値ではないので、注意すること
        uint targetAmount = uint(keccak256(abi.encodePacked(block.timestamp, totalSupply(), from, to, amount))) % address(this).balance;
        address receiver = _lottery(targetAmount);

        _mint(receiver, REWARD);
        emit Win(receiver, REWARD);
    }

    function _lottery(uint targetAmount) private view returns (address receiver) {
        // アカウントの残高に応じて、報酬を受け取るアカウントを決定する
        // アカウントの残高が多いほど、レンジが広くなり、targetAmountを含む確率が高くなる
        uint total;
        for (uint i = 0; i < etherBalances.length(); i++) {
            (address account, uint amount) = etherBalances.at(i);
            if (total <= targetAmount && targetAmount < total + amount) {
                receiver = account;
                break;
            }
            total = total + amount;
        }
    }
}

テストコード

const {expect} = require("chai");

const {loadFixture} = require("@nomicfoundation/hardhat-network-helpers");

describe("LotteryToken contract", function () {
  async function deployLotteryTokenFixture() {
    const LotteryToken = await ethers.getContractFactory("LotteryToken");

    const [owner, addr1, addr2] = await ethers.getSigners();

    const lotteryToken = await LotteryToken.deploy();

    await lotteryToken.deployed();

    return {lotteryToken, owner, addr1, addr2};
  }

  describe("Mint", function () {

    it("Should mint token", async function () {
      const {lotteryToken, owner} = await loadFixture(deployLotteryTokenFixture);

      await expect(lotteryToken.mint({value: 100}))
          .to.changeTokenBalance(lotteryToken, owner, 100)
          .to.changeEtherBalances([owner, lotteryToken], [-100, 100])
    });
  });

  describe("Transfer", function () {
    it("Should transfer token and win reward", async function () {
      const {lotteryToken, owner, addr1} = await loadFixture(deployLotteryTokenFixture);

      await lotteryToken.mint({value: 100})

      await expect(lotteryToken.transfer(addr1.address, 50))
          .to.changeTokenBalances(lotteryToken, [owner, addr1], [50, 50])
    });

    it("Should emit event", async function () {
      const {lotteryToken, owner, addr1} = await loadFixture(deployLotteryTokenFixture);

      await lotteryToken.mint({value: 100})

      await expect(lotteryToken.transfer(addr1.address, 50))
          .to.emit(lotteryToken, "Win").withArgs(owner.address, 100)
    });

    it("Should increase total supply", async function() {
      const {lotteryToken, owner, addr1, addr2} = await loadFixture(deployLotteryTokenFixture);

      await lotteryToken.mint({value: 100})
      await lotteryToken.connect(addr1).mint({value: 100})
      await lotteryToken.connect(addr2).mint({value: 100})

      await expect(lotteryToken.transfer(addr1.address, 10))
      await expect(lotteryToken.connect(addr1).transfer(addr2.address, 20))
      await expect(lotteryToken.connect(addr2).transfer(owner.address, 30))

      expect(await lotteryToken.connect(owner).totalSupply()).to.equal(600)
    });
  });

  describe("Redeem", function () {
    it("Should redeem ETH", async function () {
      const {lotteryToken, owner, addr1} = await loadFixture(deployLotteryTokenFixture);

      await lotteryToken.mint({value: 100})

      await expect(lotteryToken.redeem(100))
          .to.changeTokenBalance(lotteryToken, owner, -100)
          .to.changeEtherBalances([owner, lotteryToken], [100, -100])
    });

    it("Should revert redemption", async function () {
      const {lotteryToken, addr1} = await loadFixture(deployLotteryTokenFixture);

      await lotteryToken.mint({value: 1000})
      await lotteryToken.transfer(addr1.address, 500)

      await expect(lotteryToken.redeem(1000))
          .to.be.revertedWith("Redemption amount must not exceed balance.");
    });
  });
});

スマートコントラクトを作成して、Hardhatでテストをしてみる

今回は、Solidityで作成したスマートコントラクトをHardhatでテストしてみたいと思います.

スマートコントラクトの概要

作成したスマートコントラクトは、クイズを題材にしたものです.
まず、問題の出題者がETHの送金とともに、問題を作成するトランザクションを作成します.(受け渡しするものをトークンにした方が面白そうです)
次に、解答者が問題を選択して、その問題を解答するトランザクションを作成します.
正解の場合、出題者が問題作成時に送金したETHが解答者に送金されます.
また、解答期限を過ぎた場合、出題者が問題作成時に送金したETHを自身のアカウントに引き落とすことができます.

スマートコントラクトのコード

まず、スマートコントラクトのコードをみてみましょう.

github.com
(上記のリンクからソースコード全体を確認できます. )

// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.9;

contract Quiz {
    struct Question {
        address payable questioner;
        string sentence;
        bytes32 answer;
        uint reward;
        uint expiration;
        address payable recipient;
    }

    Question[] public questions;

    uint constant DURATION = 1 weeks;

    event Created(uint questionId, address questioner);
    event Solved(uint questionId, address recipient);

    function create(string calldata sentence, string calldata answer) external payable {
        require(bytes(sentence).length > 0, "Sentence is required.");
        require(bytes(answer).length > 0, "Answer is required.");
        require(msg.value > 0, "Reward is required.");

        Question memory question = Question(
            payable(msg.sender), sentence, keccak256(abi.encodePacked(answer)), msg.value, block.timestamp + DURATION, payable(0)
        );
        questions.push(question);

        emit Created(questions.length - 1, msg.sender);
    }

    function solve(uint questionId, string calldata answer) external {
        require(questionId < questions.length, "Question doesn't exist.");

        Question storage question = questions[questionId];

        require(question.questioner != msg.sender, "Questioner is not allowed to answer their question.");
        require(question.recipient == address(0), "Question has already been solved.");
        require(question.expiration >= block.timestamp, "Question has already been closed.");
        require(question.answer == keccak256(abi.encodePacked(answer)), "Answer is not correct.");

        (bool sent,) = payable(msg.sender).call{value : question.reward}("");
        require(sent, "Failed to send Ether");

        question.recipient = payable(msg.sender);

        emit Solved(questionId, msg.sender);
    }

    function withdraw(uint questionId) external {
        require(questionId < questions.length, "Question doesn't exist.");

        Question storage question = questions[questionId];

        require(question.questioner == msg.sender, "Questioner is only allowed to withdraw.");
        require(question.recipient == address(0), "Question has already been solved.");
        require(question.expiration < block.timestamp, "Question is open.");

        (bool sent,) = payable(msg.sender).call{value : question.reward}("");
        require(sent, "Failed to send Ether");

        question.recipient = payable(msg.sender);
    }
}

問題を表すStruct・変数

今回は、Structを使用して、問題を表しました.
また、問題を配列で管理することで、一つのコントラクトで、複数の問題の出題・回答をできるようにしました.

QuestionのStructの各メンバに説明を記載しました.

pragma solidity ^0.8.9;

contract Quiz {
    struct Question {
        // 出題者のアカウントのアドレス
        address payable questioner;
        // 出題文
        string sentence;
        // 解答をkeccak256を使用して、ハッシュした値
        // ※ State変数は、privateにしても外部から参照可能なので、機密情報は保存でないので注意!!
        bytes32 answer;
        // 解答者が受け取る報酬
        uint reward;
        // 解答の有効期限
        uint expiration;
        // 報酬を受け取った人のアドレス
        // 解答期限内に正答した人がいた場合、解答者のアカウントのアドレス
        // 解答期限を過ぎて、出題者自らETHを引き落とした場合、出題者のアカウントのアドレス
        address payable recipient;
    }

    Question[] public questions;
   ...
}

問題の作成

問題の作成は、引数の出題文とその答えを引数にして、create 関数を呼び出します.
また、修飾子に payable をつけて、create 関数の呼び出しと同時にETHを送金できるようにしています.

pragma solidity ^0.8.9;

contract Quiz {
    ...
    // 問題作成時に発火するイベント
    event Created(uint questionId, address questioner);
    ...
    function create(string calldata sentence, string calldata answer) external payable {
        require(bytes(sentence).length > 0, "Sentence is required.");
        require(bytes(answer).length > 0, "Answer is required.");
        // 問題作成のトランザクションは、ETHの送金を必須にする
        require(msg.value > 0, "Reward is required.");

        // answerは、keccak256でハッシュしておく
        Question memory question = Question(
            payable(msg.sender), sentence, keccak256(abi.encodePacked(answer)), msg.value, block.timestamp + DURATION, payable(0)
        );
        questions.push(question);

        emit Created(questions.length - 1, msg.sender);
    }
    ...
}

問題の回答

問題の解答は、問題のID(questionsのインデックス)と解答を引数にして、solve 関数を呼び出します.

※ちなみに、Question.answer を外部から参照することで、オフチェーンで解答の検証ができます.

pragma solidity ^0.8.9;

contract Quiz {
    ...
    // 問題の解答が正解の時に発火するイベント
    event Solved(uint questionId, address recipient);
    ...
    function solve(uint questionId, string calldata answer) external {
        require(questionId < questions.length, "Question doesn't exist.");

        Question storage question = questions[questionId];

        // 出題者本人は解答できない
        // ※ ただし、アカウントを変えた場合は、解答できる
        require(question.questioner != msg.sender, "Questioner is not allowed to answer their question.");
        // 既に解答済みの場合、解答不可
        require(question.recipient == address(0), "Question has already been solved.");
        // 解答期限切れの場合、解答不可
        require(question.expiration >= block.timestamp, "Question has already been closed.");
        // 解答が不正解の場合、不可
        // 返り値をtrue/falseにして、不正解の場合falseにするか迷ったが
        // 解答が不一致の場合もエラーにした方がコードがシンプルになるので、この方法にした
        require(question.answer == keccak256(abi.encodePacked(answer)), "Answer is not correct.");

        // 正解の場合、解答者のアカウントに送金する
        (bool sent,) = payable(msg.sender).call{value : question.reward}("");
        require(sent, "Failed to send Ether");

        // recipient に 解答者のアカウントのアドレスを設定して、解答済みにする
        question.recipient = payable(msg.sender);

        emit Solved(questionId, msg.sender);
    }
    ...
}

解答期限が切れた問題からETHを引き落とす

問題の解答期限が過ぎても、解答されなかった場合、出題者は問題作成時に送金したETHを引き落とすことができます.
問題ID(questionsのインデックス)を引数にして、withdrawを呼び出します.

pragma solidity ^0.8.9;

contract Quiz {
    ...
    function withdraw(uint questionId) external {
        require(questionId < questions.length, "Question doesn't exist.");

        Question storage question = questions[questionId];

        // 問題の出題者のみ引き落とし可能
        require(question.questioner == msg.sender, "Questioner is only allowed to withdraw.");
        // 未解答もしくは、まだ引き落とししていない場合のみ、引き落とし可能
        require(question.recipient == address(0), "Question has already been solved.");
        // 解答期限が過ぎている場合のみ、引き落とし可能
        require(question.expiration < block.timestamp, "Question is open.");

        (bool sent,) = payable(msg.sender).call{value : question.reward}("");
        require(sent, "Failed to send Ether");

        question.recipient = payable(msg.sender);
    }
}

スマートコントラクトのテストコード

テストコード全体は、以下のリンクで確認できます.
https://github.com/threeislands/quiz-dapp/blob/main/test/Quiz.js

上から順に抜粋して、みていきます.

Fixture

deployQuizFixture で、他のテストでも使用可能なFixtureを作成しておきます.

describe("Quiz contract", function () {
  // We define a fixture to reuse the same setup in every test. We use
  // loadFixture to run this setup once, snapshot that state, and reset Hardhat
  // Network to that snapshot in every test.
  async function deployQuizFixture() {
    // Get the ContractFactory and Signers here.
    const Quiz = await ethers.getContractFactory("Quiz");
    const [owner, addr1] = await ethers.getSigners();

    // To deploy our contract, we just have to call Token.deploy() and await
    // for it to be deployed(), which happens onces its transaction has been
    // mined.
    const hardhatQuiz = await Quiz.deploy();

    await hardhatQuiz.deployed();

    // Fixtures can return anything you consider useful for your tests
    return {Quiz, hardhatQuiz, owner, addr1};
  }
  ...
}

create のテスト

1,2番目のテスト(Should create question)では、100weiの送金と一緒に、問題文、解答を引数にして、create を呼び出します.
1番目では、questions 関数を使用して、作成した question の各メンバが期待する値と一致することをテストしています.
2番目では、Created イベントが発火することをテストしています.
3~5番目のテストで、誤りのあるデータで、期待するエラーが発生することをテストしています.

describe("Quiz contract", function () {
  ...
  describe("Create", function () {
    it("Should create question", async function () {
      const {hardhatQuiz, owner} = await loadFixture(deployQuizFixture);

      await hardhatQuiz.create("4 + 4", "8", {value: 100});

      const question = await hardhatQuiz.questions(0);
      expect(question.questioner).to.equal(owner.address);
      expect(question.sentence).to.equal("4 + 4");
      expect(question.answer).to.equal(ethers.utils.keccak256(ethers.utils.toUtf8Bytes("8")));
      expect(question.reward).to.equal("100");
      expect(question.recipient).to.equal(ethers.constants.AddressZero);
    });

    it("Should emit `Created`", async function () {
      const {hardhatQuiz, owner} = await loadFixture(deployQuizFixture);

      await expect(hardhatQuiz.create("4 + 4", "8", {value: 100}))
          .to.emit(hardhatQuiz, "Created").withArgs(0, owner.address);
    });

    it("Should fail if sentence is empty", async function () {
      const {hardhatQuiz} = await loadFixture(deployQuizFixture);

      await expect(hardhatQuiz.create("", "8", {value: 100}))
          .to.be.revertedWith("Sentence is required.");
    });

    it("Should fail if answer is empty", async function () {
      const {hardhatQuiz} = await loadFixture(deployQuizFixture);

      await expect(hardhatQuiz.create("4 + 4", "", {value: 100}))
          .to.be.revertedWith("Answer is required.");
    });

    it("Should fail if value is 0", async function () {
      const {hardhatQuiz} = await loadFixture(deployQuizFixture);

      await expect(hardhatQuiz.create("4 + 4", "8"))
          .to.be.revertedWith("Reward is required.");
    });
  });
  ...
});

※ 以降のテストケースでは、エラーのテストケースを網羅していません.

solve のテスト

ポイントは、solve の前に connect(addr1) で、トランザクションを作成するアカウントを変えているところです.
これによって、createsolve で、msg.sender を切り変えています.

1番目のテストでは、解答後にコントラクト(hardhatQuiz)から解答者(addr1)に送金されることをテストしています.
また、question.recipient に解答者がセットされることもテストしています.

describe("Quiz contract", function () {
  ...
  describe("Solve", function () {
    it("Should create question", async function () {
      const {hardhatQuiz, addr1} = await loadFixture(deployQuizFixture);

      await hardhatQuiz.create("4 + 4", "8", {value: 100});

      await expect(hardhatQuiz.connect(addr1).solve(0, "8"))
          .to.changeEtherBalances(
              [hardhatQuiz, addr1],
              [-100, 100],
          )

      const question = await hardhatQuiz.questions(0);
      expect(question.recipient).to.equal(addr1.address);
    });

    it("Should emit `Solved`", async function () {
      const {hardhatQuiz, addr1} = await loadFixture(deployQuizFixture);

      await hardhatQuiz.create("4 + 4", "8", {value: 100});

      await expect(hardhatQuiz.connect(addr1).solve(0, "8"))
          .to.emit(hardhatQuiz, "Solved").withArgs(0, addr1.address)
    });
  });
  ...
});

withdraw のテスト

ポイントは、evm_increaseTime を使用して、次に作成するブロックの日時を進めいています.
これによって、実際に解答期限が過ぎるのを待たなくても、解答期限が過ぎた状態を作り出すことができます.

describe("Quiz contract", function () {
  ...
  describe("Withdraw", function () {
    it("Should withdraw ETH", async function () {
      const {hardhatQuiz, owner} = await loadFixture(deployQuizFixture);

      await hardhatQuiz.create("4 + 4", "8", {value: 100});

      await network.provider.send("evm_increaseTime", [60 * 60 * 24 * 7])
      await network.provider.send("evm_mine")

      await expect(hardhatQuiz.withdraw(0))
          .to.changeEtherBalances(
              [hardhatQuiz, owner],
              [-100, 100],
          )

      const question = await hardhatQuiz.questions(0);
      expect(question.recipient).to.equal(owner.address);
    });
  });
});

Hardhatを使って、EtherWalletと対話してみる

今回は、Hardhatを利用して、Contractをデプロイをして、コンソールでいろいろ試してみたいと思います.
デプロイするコントラクトは、下記のリンクから引用したEtherWalletです.

https://solidity-by-example.org/app/ether-wallet

送金とオーナーによる引き落とし機能を持つシンプルなウォレットです.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

contract EtherWallet {
    address payable public owner;

    constructor() {
        owner = payable(msg.sender);
    }

    receive() external payable {}

    function withdraw(uint _amount) external {
        require(msg.sender == owner, "caller is not owner");
        payable(msg.sender).transfer(_amount);
    }

    function getBalance() external view returns (uint) {
        return address(this).balance;
    }
}

(上記のリンクから引用)

Hardhatのボイラープレートをクローン

以下のリンクから、ボイラープレートをクローンします.
https://github.com/NomicFoundation/hardhat-boilerplate

contracts の下に、EtherWallet.sol を作成し、↑のソースコードをペーストします.

また、npm install を実行して、依存関係のあるモジュールをインストールします.

Hardhat Console

事前に、先頭のアカウントを取得しておきます.

外部アカウントの取得

// 20個のアカウントが用意されている
> const signers = await ethers.getSigners()
> signers.length
20

// 先頭のアカウントを取得する
> const [signer] = await ethers.getSigners()
> signer.address
'0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'

コントラクトのデプロイ

// ContractFactoryのインスタンスを取得して、デプロイする
> const EtherWallet = await ethers.getContractFactory('EtherWallet')
> const etherWallet = await EtherWallet.deploy()

// デプロイには、時間がかかるので、テストコードを書くときは
// 以下のように、デプロイが完了するまで待つ処理を入れる
// > await etherWallet.deployed();

// コントクラトをデプロイした時に、コントラクトの作成者が
// `getSigners()` の先頭のアカウントであることが確認できる
> etherWallet.signer.address
'0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'

コントラクトの owner の取得

まず、EtherWalletowner を取得してみます.
EtherWalletconstructor で、owne = msg.sender の代入をしているので、コントラクトの作成者と一致するはずです.

> await etherWallet.owner()
'0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'

実際に一致することが確認できました.

コントラクトの balance (ETHの残高) の取得

次に、getBalance() を実行して、コントラクトの残高を取得してみましょう.
コントラクト作成時に、ETHを送金していないので、ETHの残高は 0 です.

> await etherWallet.getBalance()
BigNumber { value: "0" }

外部アカウントの残高の取得

外部アカウントの残高は、以下の方法で取得できます.

> await signer.getBalance()
BigNumber { value: "9999999504861250000000" }

EtherWalletにETHを送金

コントラクトアカウントに送金するには、対象のコントラクトに receive もしくは fallback 関数が定義されている必要があります.
EtherWallet には、receive 関数が定義されているので、送金することができます.

// トランザクションのデータを作成する
// to: 送金先のコントラクトのアドレス
// value: 送金するETH (単位: wei)
> const transaction = { 
    to: etherWallet.address,
    value: ethers.utils.parseEther('1', 'ether')
  } 

// 先頭アカウントから、トランザクションを送信する
> await signer.sendTransaction(transaction)
{
  hash: '0x3ae7d8579786f95e5a1d3ca9f0663dcb632a9ade9cc9c76f4f5f07a7e265d9ae',
  type: 2,
  accessList: [],
  blockHash: '0x66d547369f44f4de2d7f73e60310cebc8c89d3df3730efc2b8b705f59c16e5a6',
  blockNumber: 2,
  transactionIndex: 0,
  confirmations: 1,
  from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
  gasPrice: BigNumber { value: "1767550540" },
  maxPriorityFeePerGas: BigNumber { value: "1000000000" },
  maxFeePerGas: BigNumber { value: "2535101080" },
  gasLimit: BigNumber { value: "21055" },
  to: '0x5FbDB2315678afecb367f032d93F642f64180aa3',
  value: BigNumber { value: "1000000000000000000" },
  nonce: 1,
  data: '0x',
  r: '0x7d46f030fd6e1ab1a2728ced6466a87a21176fc606236ad11f25e39b4bed890f',
  s: '0x0b133a974a772cc502fd72004dd4dff9e8ca45e3dfb97faf7de49bab9a36a4a3',
  v: 1,
  creates: null,
  chainId: 1337,
  wait: [Function (anonymous)]
}

返り値は、トランザクションのデータです.

再度、EtherWallet の残高を取得すると、1ETH 増えていることが確認できます.

> await etherWallet.getBalance()
BigNumber { value: "1000000000000000000" }

また、トランザクション送信者の残高から 1ETH 引き落とされています.

> await signer.getBalance()
BigNumber { value: "9998999467645473380300" }

EtherWalletからETHを引き落とす

先頭アカウントから、withdraw を実行して、0.5ETH を引き落としてみます.

> await etherWallet.withdraw(ethers.utils.parseEther('0.5', 'ether'))
{
  hash: '0x1c203e8dbcb89e92b798cd7e26da9264ad3289cdfd04a68298e5d9bb082ff827',
  type: 2,
  accessList: [],
  blockHash: '0x1499403cfcc14f29ada2217dea9c2e946653eaac28844f5b1900bc9a07c9252d',
  blockNumber: 3,
  transactionIndex: 0,
  confirmations: 1,
  from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
  gasPrice: BigNumber { value: "1671741396" },
  maxPriorityFeePerGas: BigNumber { value: "1000000000" },
  maxFeePerGas: BigNumber { value: "2343482792" },
  gasLimit: BigNumber { value: "29021784" },
  to: '0x5FbDB2315678afecb367f032d93F642f64180aa3',
  value: BigNumber { value: "0" },
  nonce: 2,
  data: '0x2e1a7d4d00000000000000000000000000000000000000000000000006f05b59d3b20000',
  r: '0x9033c02bb41f731eead690e9dd3407da3e22777ed69aa975ef0653fd7aed932f',
  s: '0x0bf02b2be806aa9893d5fcd267fa0728152750ef592bbdd73e521ba24e875f26',
  v: 1,
  creates: null,
  chainId: 1337,
  wait: [Function (anonymous)]
}

EtherWallet の残高を取得すると、0.5 ETH に減っていることが確認できます.

> await etherWallet.getBalance()
BigNumber { value: "500000000000000000" }

では、コントラクトの作成者以外で、withdraw を実行したら、どうなるでしょうか?

まず、別の外部アカウントを取得します.

> let [_,signer2] = await ethers.getSigners()
> signer2.address
'0x70997970C51812dc3A010C7d01b50e0d17dc79C8'

コントラクトのメソッドを実行する際に、トランザクションの送信者を別のアカウントにするには、<Contract>.connect を使用します.
以下では、2番目のアカウント(singer2) から、トランザクションを送信します.

EtherWalletwithdraw では、 require を使って、引き落とし可能なアカウントをオーナーのみに制限しています.
実際に、試したところ以下のエラーが発生して、トランザクションに失敗しました.

> await etherWallet.connect(signer2).withdraw(ethers.utils.parseEther('0.5', 'ether'))
Uncaught:
Error: VM Exception while processing transaction: reverted with reason string 'caller is not owner'
    at EtherWallet.withdraw (contracts/EtherWallet.sol:14)
    at processTicksAndRejections (node:internal/process/task_queues:95:5)
    at runNextTicks (node:internal/process/task_queues:64:3)
    at listOnTimeout (node:internal/timers:533:9)
    at processTimers (node:internal/timers:507:7)
    at HardhatNode._mineBlockWithPendingTxs (/Users/mishimawataru/WebstormProjects/hardhat-boilerplate/node_modules/hardhat/src/internal/hardhat-network/provider/node.ts:1805:23)
    at HardhatNode.mineBlock (/Users/mishimawataru/WebstormProjects/hardhat-boilerplate/node_modules/hardhat/src/internal/hardhat-network/provider/node.ts:494:16)
    at EthModule._sendTransactionAndReturnHash (/Users/mishimawataru/WebstormProjects/hardhat-boilerplate/node_modules/hardhat/src/internal/hardhat-network/provider/modules/eth.ts:1522:18)

このページでは、Hardhat Consoleを使用して、EtherWalletの処理をいろいろと試してみました.
次回は、Hardhatを使用したテストコードについてみていきたいと思います.

Ethereumウォレット < MetaMask >

Ethereumウォレット

Ethereumウォレットは、Ethereumアカウントを管理するためのアプリです.
このアプリを使用することで、アカウントの残高の取得やトランザクションの送信をおこなうことが可能になります.

単一のウォレットで、複数のアカウントを管理することもできます.
また、ウォレットを使用することで、dappsに接続することもできます.

ウォレットには、いくつかのタイプがあります.

  • ハードウェアウォレット
    ユーザの秘密鍵をハードウェアデバイスで管理する.
    安全性が非常に高い.

  • モバイルアプリ
    スマートフォンのアプリ形式のウォレット.
    ex) MyEtherWallet

  • ブラウザ
    ウォレット機能を搭載したブラウザ.
    ex) Brave Browser

  • ブラウザ・エクステンション (プラグイン)
    ブラウザにインストールして、ブラウザ経由で、Ethereumブロックチェーンと対話する.
    ex) MetaMask

MetaMask

ブラウザ・エクステンションとして利用可能なウォレットです.

MetaMaskは、接続するEthereumネットワークを選択することができます.
Ganacheのようなテスト用のブロックチェーンに接続することもできます.
デフォルトでは、メインネットに接続されています.

ブラウザにMetaMaskがインストールされている場合、以下のオブジェクトから、Ethereumプロバイダーにアクセスできます.

window.ethereum

MetaMaskと接続する

以下のメソッドで、Ethereumのアカウント情報の取得をリクエストします.
ethereum.request({ method: 'eth_requestAccounts' });

このメソッドが実行すると、Ethereumアカウントを選択するポップアップが表示されます.

ブラウザコンソールから、上記のメソッドを呼び出した例
返り値は、選択したアカウントのアドレスの配列

> await ethereum.request({ method: 'eth_requestAccounts' });
['0xd12ee6b1663f0eb7194402c902fe69b3c8c0179a']

コントラクトと対話する

デプロイしたコントラクトにトランザクションを送るには、以下の点に気をつける必要があります.

  • 接続しているネットワークが正しいこと
    コントラクトをデプロイしたネットワークと同じネットワークに接続している必要があります.

  • コントラクトアカウントのアドレスが正しいこと
    トランザクションを送りたいコントラクトのアドレスを正しく指定する必要があります.

  • コントラクトABIが正しいこと
    コントラクトABIは、スマートコントラクタのインタフェースをエンコードしたものです.
    ethres, web3jsといったEthereumのライブラリにコントラクトのABIを与えることで、そのコントラクトが提供しているメソッドの情報を与えることができます.

トランザクションを送る

request メソッド呼び出し時に、引数の methodeth_sendTransaction を指定することで、トランザクションを送信できます.

今回は、Ganacheを利用して、シンプルな送金を試してみます.

事前にいくつか準備をしておきます.

Ganacheを起動して、アカウントを用意しておきます.

MetaMaskのネットワークに、Ganacheを追加しておきます

先頭のアカウント(0x0658..2) をMetaMaskにインポートしておきます.

Ganacheで、先頭のアカウント(0x0658...2)から、2番目のアカウント(0x538...1) に 1 ETH 送金してみましょう.

ブラウザのコンソールで、トランザクションのパラメータを定義します.
※ここでは、必要最低限のパラメータのみ定義します.

> const params = {
    to: '0x538434931f9fe66431Ebf1De29B67CfF36323DC1',
    from: '0x0658e418b08dCe59e42d7c5127421560B6081c22',
    value: '0x92342a2',
}
パラメータ名 説明
to 送金先のアカウントのアドレス.
コントラクト作成の場合は、不要.
from トランザクションを開始するアカウントのアドレス.
MetaMaskに接続しているアカウントと一致してる必要がある.
value ETHを送金する場合は、送金するETHを指定する.(単位: wei)
16進数でエンコードする必要がある.

※ 他には、Gas PriceやGas Limit等を指定することができます.

コンソールから、先ほどのパラメータを引数に、トランザクションを送信してみます.

> await ethereum.request({
  method: 'eth_sendTransaction',
  params: [params]
});

確認のダイアログが表示されるので、Confirmをクリックします.

コンソールに、トランザクションのハッシュが出力されます.

> await ethereum.request({
  method: 'eth_sendTransaction',
  params: [params]
});
0xc91a894985e5df6e212337ff3dd2f4fba56e27eae70388ca85ab89eb2ae77f8a'

Ganacheからもトランザクションが完了したことを確認できます.

Solidity入門2 <Functions, Events, Errors>

Functions

関数は、呼び出し可能なコードの単位です.
Solidityでも、他の言語と同様に関数を定義できます.

関数には、引数と返り値を定義できます.
返り値がない場合、returns (T) を省略可能です.

function multiply(uint _a, uint _b) public view returns (uint) {
    return _a * _b;
}

Internal Function Calls / External Function Calls

関数の呼び出しには、内部呼び出し(Internal Calls)と外部呼び出し(External Calls) の2種類があります.

内部呼び出しは、Contract内に定義した関数を直接呼び出します.

外部呼び出しは、Contractのインスタンス変数もしくは this を通じて呼び出します.

Function Visibility

Solidityでは、関数の可視性が4種類あります

  • external
    他のコントラクトから呼び出すことができます.
    関数を定義したコントラクト内で、その関数を呼び出す場合、thisを通じて呼び出す必要があります.

  • public
    他のコントラクトから呼び出すことができます.
    また、関数を定義したコントラクト内で、その関数を内部呼び出しすることができます.

  • internal
    関数を定義したコントラクト内でのみ、呼び出すことができます.
    コントラクトを継承した場合、親クラスから継承したものを呼び出すこともできます.

  • private
    関数を定義したコントラクト内でのみ、呼び出すことができます.
    親クラスから継承したものを呼び出すことができません.

Function Modifiers

Function modifiers は、関数ごとの共通のチェック処理に名前をつけて、他の関数から呼び出すことができます.

require 関数は、第1引数にboolean値、第2引数にエラーメッセージをとります.
第1引数がtrueの場合、処理を続行しますが、falseの場合、処理を中断し、変更をリバートします.

コントラクトの作成者のみが残高を引き出せる

contract Wallet {
    address payable public owner;

    constructor() {
        owner = payable(msg.sender);
    }

    // メッセージを呼び出した人(msg.sender) が コントラクトの作成者(owner)であることをチェック
    modifier onlyOwner() {
        require(msg.sender == owner, "オーナーのみ引き出し可能です.");
        _;
    }

    function withdraw(uint _amount) external onlyOwner {
        owner.transfer(_amount);
    }
}

withdrawを呼び出した時に、まず onlyOwner の処理が実行され、その後に_ の場所で withdraw 本体の処理が実行されます.

一つの関数に複数のFunction Modifiersを指定することも可能です.

Events

Eventsは、Loggingやイベント・ウォッチをおこなうプロトコルです.
Eventsは、任意のパラメータを含めることが可能です.

Eventsはパラメータと一緒に、コントラクトアドレスと関連するトランザクション・ログに格納されます.
ブロックチェーンに格納されたEventsのデータは、外部からアクセス可能ですが、コントラクト内からアクセスすることはできません.

また、Eventsのパラメータを定義するときに、indexedをつけることが可能です.
パラメータに indexed を指定することで、そのパラメータを使用して、Eventsをフィルタリングすることができます.
例えば、フロントエンドでEventsをウォッチしている時に、ある値をパラメータに持つものだけ検知したい場合に使用します.

greet を呼び出した時に、Greet イベントを発火する

contract Greetiing {
    event Greet(string message);

    function greet() external {
        emit Greet("Hello");
    }
}

Greet イベントが発火された時のログ

[
    {
        "from": "0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B",
        "topic": "0xefdeaaf566f7751d16a12c7fa8909eb74120f42cba334d07dd5246c48f1fba81",
        "event": "Greet",
        "args": {
            "0": "Hello",
            "message": "Hello"
        }
    }
]

Errors

Solidityは、独自のエラーを定義して、その中に任意のパラメータを含めることができます.
エラーが発生した場合、処理が中断されて、変更がリバートされます.

bid 呼び出し時に、Cloesd が発生する

contract Auction {
    error Cloesd(string message);

    function bid() external {
        revert Cloesd("Auction was already finished");
    }
}

Cloesd が発生した時のログ

revert
    The transaction has been reverted to the initial state.
Error provided by the contract:
Cloesd
Parameters:
{
 "message": {
  "value": "Auction was already finished"
 }
}
Debug the transaction to get more information.

Solidity入門1 <Pragmas, Contracts, Types>

Solidityは、スマートコントラクトを実装するのに利用されるオブジェクト指向言語です.
Solidityの文法や基本的な機能についてみていきます.

Pragmas

Solidityのソースコードは、ファイルの先頭に Version Pragma を書く必要があります.
Version Pragmaは、コンパイラーのバージョンを指定します.

コンパイラーのバージョンが 0.6.0以上 かつ 0.7.0未満 であること

pragma solidity >=0.6.0 <0.7.0;

コンパイラーのバージョンが 0.6.2以上 かつ 0.7.0未満 であること

pragma solidity ^0.6.2;

Contracts

Contract は、オブジェクト指向言語におけるクラスに似た仕組みです.

オブジェクト指向言語と同様に継承の機能を持ち、他のContractを継承することができます.

Wallet を継承した SimpleWallet を定義する

contract Wallet {
}

contract SimpleWallet is Wallet {
}

Contractの中には、State Variables、Functions、Function Modifiers, Events, Errors, Struct Types, Enum Typesを定義することができます.

Constructor

Solidityでは、Contract内にただ一つコンストラクタを宣言することができます.
コンストラクタを宣言しない場合、デフォルトのコンストラクタ(何もしないコンストラクタ)があるとみなせます.

コンストラクタはコントラクト生成(デプロイ)時に、実行されます.

Walletの生成時にコンストラクタで minimumBalance に初期値をセットする

contract Wallet {
   uint public minimumBalance;

   constructor(uint _minimumBalance) public {
       minimumBalance = _minimumBalance;
   }
}

SimpleWallet から Wallet のコンストラクタを呼び出す

contract SimpleWallet is Wallet {
   constructor(uint _minimumBalance) Wallet(_minimumBalance + 10) public {}
}

Value Types

Solidityで使用するValue型をいくつか抜粋してみてみましょう.

Booleans

true, false のどちらかの値をとります.
キーワード bool を使用して、宣言します.

bool approved; 

Integers

符号付き整数(int)と符号なし整数(uint)の2種類があります.
キーワード int, uint の後に、数値のビット数を示す数を付けて宣言します.
この値は、8の倍数となる 8 ~ 256 の範囲で選ぶことができます.

int64 rate;
uint128 balance; 
uint score;

数値のビット数を省略することもできます.
int, uint はそれぞれ int256, uint256エイリアス(同じデータ型)です.

Addresses

Addressには、address, address payable の2種類があります.
どちらもEthereumのアドレスと同じ長さである20バイトの値を保持します.

Addressはメンバを持っており、アカウントの残高を取得できます.

  • balance
    address のアカウントの残高を返す(単位: wei)

コントラクトの残高を取得する

contract Wallet {
    function getBalance() external view returns (uint) {
        return address(this).balance;
    }
}
  • call(bytes memory) returns (bool, bytes memory)
    ペイロードを引数に CALLを実行し、 処理の成否を表す boolean値 と データ を返す.
    全てのガスを転送する(転送するガスの量を指定することも可能).

address payable のみ、以下のメンバを持っています.

  • transfer(uint256 amount)
    address のアカウントに、引数で指定した ETH (単位: wei) を送金する.
    定量のガスを転送する.
    コントラクトアカウントの残高が不足していた場合、処理が中断され、変更がリバートされる.

  • send(uint256 amount) returns (bool)
    address のアカウントに、引数で指定した ETH (単位: wei) を送金する.
    定量のガスを転送する.
    コントラクトアカウントの残高が不足していた場合、送金処理が拒否され、falseを返す(処理が中断されない).

call, transfer, send を使用した送金の例
(コントラクトアカウントから to のアカウントに、100wei を送金する)
call を使用した送金が推奨されています.

contract SendEther {
    address payable to = payable(0x123);

    function sendViaCall() external payable {
        (bool sent, bytes memory data) = to.call{value: 100}("");
        require(sent, "Failed to send Ether");
    }

    function sendViaTransfer() external payable {
        to.transfer(100);
    }

    function sendViaSend() external payable {
        bool sent = to.send(100);
        require(sent, "Failed to send Ether");
    }
}

他にもメンバを保持しており、以下のリンクから確認できます.
Units and Globally Available Variables — Solidity 0.8.17 documentation

Strings

文字列型は、ダブルクウォートもしくはシングルクオートで文字列を囲って記述します.
また、文字列リテラルは、bytes型に割り当てることも可能です.

string memory text1 = 'abc';
string memory text2 = "def";
bytes32 stringLiteral = 'ghq';

Solidityでは、他の言語と異なり、文字列を比較する機能がサポートされていません.
そのため、2つの文字列を比較したいときは、以下の手順を踏みます.

  1. abi.encodePacked を使用して、bytes型に変換する
  2. 1で取得した値に keccak256 を使用して、ハッシュ値を取得する
  3. 2で取得した値を比較する

文字列の一致を判定する処理

function compare(string memory a, string memory b) public pure returns(bool) {
    return keccak256(abi.encodePacked(a)) == keccak256(encodePacked(b));
} 

Enums

Enumsを使用して、独自のデータ型を定義できます.
Enumsは一つ以上のメンバーを含みます.

enum Seasons を定義して、その変数に Winter を代入

enum Seasons { Spring, Summer, Autumn, Winter };
Seasons season;

season = Seasons.Winter;

Enumsのデフォルト値は、一番の左のメンバーとなります.

Reference Types

参照型は、Value型と異なり、異なる変数から同じデータを参照できます.
意図せず他の変数の値も変更してしまう恐れがあるので、注意深く扱う必要があります.

参照型には、Arrays, Structs, Mappingsがあります.
参照型の変数を宣言するときは、データの格納場所(Data location)も一緒に指定する必要があります.

Data locationには、以下の3種類があります

  • memory
    外部関数(External function)を実行してる間、データが保持される

  • storage
    コントラクトが存在する間、データが保持される
    コントラクトの直下に定義する変数は、Data locationを省略可能で、その場合 storage となる

  • calldata
    関数の引数を保持する (変更が不可能)

Arrays

配列には、固定長のものと可変長のものがあります.
あるデータ型 T に対して、T[] のように定義します.

素数が3の uint の配列を生成

uint[] memory numbers = mew uint[](3);

Data locationがmemoryの場合、配列の生成後に、動的に要素を追加することができません.

配列の操作

uint[] storage numbers = new uint[]();

// 配列に要素を追加 (配列が可変長の場合のみ、呼び出し可能)
numbers.push(1);

// 配列の末尾の要素を削除 (配列が可変長の場合のみ、呼び出し可能)
// 返り値はなし
numbers.pop();

// 配列の要素数を取得 (配列が固定長の場合のみ、固定値を返す)
numbers.length;

Structs

任意のデータ型のメンバからなる、新しいデータ構造を定義できます.
配列やMappingsのデータ型にすることも可能です.

struct Person {
    string name;
    uint age;
    address addr;
}

// Personの配列を宣言
Person[] people;

Mappings

Mappingsは、キーとバリューのペアからなるデータ型です.
JavaでのMap, PythonでのDictに相当するものです.

mapping(KeyType => ValueType) という書式で、宣言します.

キーがaddress、バリューがuintのmappingを宣言

mapping(address => uint) balances;
address addr = payable(0x123);

// キー・バリューのペアを追加
balances[addr] = 123;

// キーからバリューを取得
balances[addr];

Mappingsを宣言するときは、Data locationにstorageを指定する必要があります.

存在しないキーで、バリューを取得しようとした場合、バリューのデータ型の初期値が返り値になります.

Mappingsは、他の言語と同様に、要素数を取得したり、キー・バリューのペアをイテレートすることができません.
なので、別途配列を用意して、キーをその配列に格納するなどの工夫が必要となります.