Solidity基礎:contract、stateおよびfunction

1. 概要

Solidityは、EthereumおよびEVM互換チェーン上でスマートコントラクトを書く主要言語です。この記事では、contract、state variable、functionという3つの基本構成要素について、シンプルなWalletコントラクトの例を通じて学びます。読み終わった後には、状態を保持するコントラクトを書き、modifierでアクセス権を制御し、Hardhatで最初のテストを書けるようになります。

2. Contractとは

Solidityでは、contractはJavaのclassのようなもので、blockchainにデプロイされて独立したアドレスを持つstate(データ)とbehavior(function)を定義します。

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

contract Wallet {
    // state variablesおよびfunctionはここに記述されます
}
  • // SPDX-License-Identifier行はライセンスを宣言し、コンパイラは不足している場合に警告します。
  • pragma solidity ^0.8.24はコンパイラバージョンを0.8.24以上(ただし0.9.0未満)に固定します。
  • コントラクトがデプロイされると、独立したアドレス(address)を持つようになり、blockchainに永続的に存在する「オブジェクト」のようです。

注意: productionではpragmaを>=0.8.0 <0.9.0のように緩く指定しないでください。新しいコンパイラはデフォルトの動作を変更する可能性があります(例:overflow check)。

3. State variable — on-chainに保存されるデータ

State variableはblockchainのstorageに直接書き込まれる変数で、複数の関数呼び出しの間で存在し、書き込み時にgasを消費します。

contract Wallet {
    address public owner;
    uint256 public balance;
    mapping(address => uint256) public allowance;

    constructor() {
        owner = msg.sender;
    }
}
  • address public owner:コントラクトのオーナーアドレスを保存します。キーワードpublicは自動的にgetter owner()を生成します。
  • uint256 public balance:残高。符号なし256ビット整数型で、Solidityで最も一般的な数値型です。
  • mapping(address => uint256):HashMap<Address, BigInteger>のようなものですが、キーを反復処理することはできず、キーによる検索のみが可能です。
  • constructor():デプロイ時に正確に1回実行されます。msg.senderはデプロイトランザクションを実行したアドレスです。

3.1. storage、memory、calldataの区別

function updateAllowance(address spender, uint256 amount) external {
    // amountはcalldataに存在します(読み取り専用、ガス効率が良い)
    allowance[spender] = amount; // storageに書き込む
}
  • storage:チェーン上に永続的に存在するデータ(state variable)。
  • memory:関数実行中の一時的なデータで、関数終了後に消えます。
  • calldata:memoryに似ていますが読み取り専用で、external関数のパラメータに使用されます。memoryよりもガス効率が良いです。

注意: array、struct、string型のパラメータにmemory/calldataを宣言し忘れるとコンパイラエラーになります。メモリ領域を気にする必要がないJavaとは異なります。

4. Functionとvisibility

Solidityではfunctionは明示的にvisibilityを宣言する必要があり、Javaのような「package-private」というデフォルトはありません。

contract Wallet {
    address public owner;
    uint256 public balance;

    constructor() {
        owner = msg.sender;
    }

    function deposit() external payable {
        balance += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(msg.sender == owner, "Wallet: not owner");
        require(amount <= balance, "Wallet: insufficient balance");

        balance -= amount;
        (bool success, ) = owner.call{value: amount}("");
        require(success, "Wallet: transfer failed");
    }

    function getBalance() external view returns (uint256) {
        return balance;
    }
}
  • external:contract外からのみ呼び出せます(例:MetaMaskウォレット、他のコントラクト)。パラメータが大きいarray/stringの場合、publicよりもガス効率が良いです。
  • payable:トランザクション付きでETHを受け取ることを許可します。msg.valueは送信されたweiの金額です。
  • view:状態を変更しないことをコミットし、off-chain(eth_call経由)で呼び出される場合、ガスを消費しません。
  • require(condition, "message"):条件をチェックし、失敗した場合はトランザクション全体をメッセージ付きで取り消します。IllegalArgumentExceptionを投げるのと似ていますが、状態とすべての使用されたgas(チェック用のgas以外)がロールバックされます。
  • owner.call{value: amount}(""):transfer()/send()の代わりに推奨される方法で、ハード的な2300 gasの制限がありません。

4つのvisibilityレベル:

Visibility contractの内部から呼び出し可能 派生contractから呼び出し可能 外部から呼び出し可能
private ✅ ❌ ❌
internal ✅ ✅ ❌
public ✅ ✅ ✅
external ❌ (this.foo()を使う必要があります) ❌ ✅

5. Modifier — チェック条件の再利用

複数のfunctionでrequire(msg.sender == owner, ...)を繰り返すのは、modifierに抽出すべき兆候です。

contract Wallet {
    address public owner;
    uint256 public balance;

    modifier onlyOwner() {
        require(msg.sender == owner, "Wallet: not owner");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function withdraw(uint256 amount) external onlyOwner {
        require(amount <= balance, "Wallet: insufficient balance");
        balance -= amount;
        payable(owner).transfer(amount);
    }
}
  • modifier onlyOwner()はチェックブロックを定義します。_;は元のfunctionの本体が挿入される位置です。
  • withdraw宣言後にonlyOwnerを付けることで、コードが「ウォレットのオーナーのみが引き出せる」と読みやすくなります。各関数でrequireを繰り返すより明確です。
  • Modifierはパラメータを受け取ることもできます。例:modifier minAmount(uint256 amount) { require(amount > 0, "..."); _; }。

6. Event — off-chainへのログ記録

Contractはhistoryデータをfrontendに直接返すことはできません。eventはdAppがリッスンできるログを記録するために使用されます。

contract Wallet {
    event Deposited(address indexed from, uint256 amount);
    event Withdrawn(address indexed to, uint256 amount);

    uint256 public balance;

    function deposit() external payable {
        balance += msg.value;
        emit Deposited(msg.sender, msg.value);
    }
}
  • event Deposited(...)はログの構造を宣言し、最大3つのindexedパラメータでフィルタリングを容易にします。
  • emit Deposited(...)はtransaction receiptにログを記録し、storageに保存するより大幅にガス効率が良いです。
  • Frontendはethers.jsのようなライブラリを使用してcontract.on("Deposited", ...)でリッスンし、UIをリアルタイムで更新できます。

7. Solidityを学ぶ際の一般的なエラー

  • ETH送信時にtransfer/send/callを混同する:transferは2300 gasに制限されており、複雑なロジック(proxy、multisigなど)を持つ受信コントラクトで失敗しやすいです。セクション4の例のようにcallとrequireを組み合わせて結果をチェックしてください。
  • 入力のrequireチェックを忘れる:検証がないとコントラクトが悪用される可能性があります(例:負の引き出し、overflow。Solidity 0.8以降は自動的にoverflowでrevertされますが、ビジネスロジックでも個別に検証する必要があります)。
  • externalとpublicを誤った文脈で比較する:外部からのみ呼び出されるfunctionにpublicを使用すると、パラメータがarray/stringの場合、Solidityはcalldataから直接読み込む代わりにmemoryにデータをコピーする必要があるため、ガスが余分に消費されます。
  • 可能な場合、view/pureを宣言しない:block explorerやウォレットなどのツールが読み取り専用functionを認識できず、ユーザーがガスを支払う必要があると誤解します。

functionをpublicにすべきでない場合: 他のコントラクトや派生元から呼び出されることがないfunctionについては、ガスを節約し、攻撃対象面を制限するためにexternalを使用してください。

8. Hardhatでテストを書く

Hardhatプロジェクト(Node.js)に依存関係を追加してください:

npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox

ownerのみが資金を引き出せることをテストします:

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

describe("Wallet", function () {
  it("ownerのみが資金を引き出せます", async function () {
    const [owner, stranger] = await ethers.getSigners();
    const Wallet = await ethers.getContractFactory("Wallet");
    const wallet = await Wallet.deploy();

    await wallet.connect(owner).deposit({ value: ethers.parseEther("1") });

    await expect(
      wallet.connect(stranger).withdraw(ethers.parseEther("1"))
    ).to.be.revertedWith("Wallet: not owner");

    await expect(
      wallet.connect(owner).withdraw(ethers.parseEther("1"))
    ).to.changeEtherBalance(owner, ethers.parseEther("1"));
  });
});
  • ethers.getContractFactory("Wallet")はコントラクトをコンパイルしてそのartifactをロードします。
  • wallet.connect(stranger)はowner以外のアドレスから送信されたトランザクションをシミュレートします。
  • revertedWith("...")は、トランザクションがrequireからの正確なメッセージで取り消されたことをアサートします。
  • changeEtherBalanceは、引き出し後のownerのETH残高がちょうど1 ETH増加したことをチェックします。戻り値のみをチェックするのではなく、一般的なテスト方法です。

テストを実行してください:

npx hardhat test
Wallet
  ✔ ownerのみが資金を引き出せます (120ms)

1 passing (120ms)

9. 結論

Solidityの4つの基礎概念を学びました:デプロイの単位としてのcontract、storage/memory/calldataの3つのメモリ領域を持つstate variableのon-chain保存、visibilityとmodifierでアクセス権を制御するfunction、およびoff-chainとの通信のためのeventです。これらの概念は、inheritance、interface、ERC-20/ERC-721標準などの高度なトピックへ進むための基盤であり、すべてこの4つの構成要素の上に構築されています。

solidityblockchainsmart-contractethereumweb3