メインコンテンツへスキップ

ERC-20コントラクトのウォークスルー

Solidity
ERC-20
初級
Ori Pomerantz
2021年3月9日
48 分の読書

はじめに

イーサリアムの最も一般的な用途の1つは、グループが取引可能なトークン、いわば独自の通貨を作ることです。 これらのトークンは、通常ERC-20という規格に従います。 この規格により、流動性プールやウォレットなど、すべてのERC-20トークンで利用できるツールの作成が可能になります。 この記事では、OpenZeppelinのSolidityによるERC20実装 (opens in a new tab)と、インターフェース定義 (opens in a new tab)を分析します。

ここではアノテーション付きのソースコードを見ていきます。 ERC-20を実装したい場合は、このチュートリアル (opens in a new tab)をお読みください。

インターフェース

ERC-20のような規格の目的は、ウォレットや分散型取引所のようなアプリケーション間で相互運用可能な多くのトークンの実装を可能にすることです。 それを実現するために、インターフェース (opens in a new tab)を作成します。 トークンコントラクトを使用する必要があるコードは、それがMetaMaskのようなウォレット、etherscan.ioのようなdapp、または流動性プールのような別のコントラクトであっても、インターフェースで同じ定義を使用でき、そのインターフェースを使用するすべてのトークンコントラクトと互換性があります。

ERC-20インターフェースの図

経験豊富なプログラマーであれば、Java (opens in a new tab)Cのヘッダーファイル (opens in a new tab)で同様の構造を見たことがあるかもしれません。

これはOpenZeppelinによるERC-20インターフェース (opens in a new tab)の定義です。 これは、人間が読める標準規格 (opens in a new tab)をSolidityコードに変換したものです。 もちろん、インターフェース自体は、何かを行う_方法_を定義するものではありません。 これは後述のコントラクトのソースコードで説明されています。

 

// SPDX-License-Identifier: MIT

Solidityのファイルには、ライセンス識別子が含まれているはずです。 ライセンスのリストはこちら (opens in a new tab)で確認できます。 別のライセンスが必要な場合は、コメントでその旨を説明してください。

 

pragma solidity >=0.6.0 <0.8.0;

Solidity言語は現在も急速に進化しており、新しいバージョンは古いコードと互換性がない場合があります(詳しくはこちら (opens in a new tab))。 そのため、言語の最小バージョンだけでなく、最大バージョン、つまりコードをテストした最新のバージョンも指定することをお勧めします。

 

/**
 * @dev EIPで定義されているERC20標準のインターフェース。
 */

コメント中の@devNatSpec形式 (opens in a new tab)の一部で、ソースコードからドキュメントを作成するために使用されます。

 

interface IERC20 {

慣例では、インターフェース名はIで始まります。

 

    /**
     * @dev 存在するトークンの総量を返します。
     */
    function totalSupply() external view returns (uint256);

この関数はexternalであり、コントラクトの外部からのみ呼び出すことができる (opens in a new tab)ことを意味します。 コントラクト内のトークンの総供給量を返します。 この値は、イーサリアムで最も一般的な型である符号なし256ビット(256ビットはEVMのネイティブワードサイズ)で返されます。 この関数もviewであり、状態を変更しないため、ブロックチェーンのすべてのノードで実行するのではなく、単一のノードで実行できます。 このような関数はトランザクションを生成せず、ガス代もかかりません。

注: 理論的には、コントラクト作成者が、実際の値よりも少ない総供給量を返し、各トークンを実際の価格よりも高価に見せることで、不正を行うことが出来るように思えるかもしれません。 しかし、そのような心配をするということは、ブロックチェーンの本質を忘れているということになります。 ブロックチェーン上で起こるすべてのことは、すべてのノードで検証できます。 これを実現するため、すべてのコントラクトの機械語コードとストレージは、全ノードで入手可能です。 コントラクトのSolidityコードを公開する必要はありませんが、提供した機械語コードと照合して検証できるように、ソースコードとそれをコンパイルしたSolidityのバージョンを公開しない限り、誰もあなたのコントラクトを本気で相手にしないでしょう。 例えば、このコントラクト (opens in a new tab)をご覧ください。

 

    /**
     * @dev `account`が所有するトークンの量を返します。
     */
    function balanceOf(address account) external view returns (uint256);

その名の通り、balanceOfはアカウントの残高を返します。 Solidityでは、イーサリアムアカウントは160ビットを保持するaddress型で識別されます。 この関数もexternalかつviewです。

 

    /**
     * @dev `amount`のトークンを呼び出し元のアカウントから`recipient`に移動させます。
     *
     * 操作が成功したかどうかを示すブール値を返します。
     *
     * {Transfer}イベントを発行します。
     */
    function transfer(address recipient, uint256 amount) external returns (bool);

transfer関数は、呼び出し元から別のアドレスにトークンを転送します。 これは状態の変化を伴うので、viewではありません。 ユーザーがこの関数を呼び出すと、トランザクションが作成され、ガス代がかかります。 さらに、Transferというイベントが発行され、ブロックチェーン上の全員にそのイベントが通知されます。

この関数には、2つの異なる呼び出し元のタイプに応じて、2つのタイプの出力があります。

  • ユーザーがユーザーインターフェースから関数を直接呼び出す場合。 通常、ユーザーはトランザクションを送信すると、応答を待ちません。応答には不確定な時間がかかる可能性があるためです。 ユーザーは、トランザクションレシート(トランザクションハッシュによって識別される)を探すか、Transferイベントを探すことで状況を把握できます。
  • 他のコントラクトが、全体的なトランザクションの一部として関数を呼び出す場合。 これらのコントラクトは、同じトランザクション内で実行されるため、関数の戻り値を使用でき、すぐに結果を得ることができます。

同じタイプの出力は、コントラクトの状態を変更する他の関数によって作成されます。

 

割当量(allowance)により、あるアカウントが別の所有者に属しているトークンの一部を使えるようになります。 これは、例えば、コントラクトが売り手として機能する場合などに役立ちます。 コントラクトはイベントを監視できないため、買い手が売り手のコントラクトにトークンを直接転送した場合、売り手のコントラクトはその支払いを認識できません。 代わりに、買い手が売り手のコントラクトに一定量の使用を許可し、売り手がその量を転送します。 この処理は売り手のコントラクトが呼び出す関数を通して行われるため、売り手のコントラクトは処理が成功したかどうかを確認できます。

    /**
     * @dev {transferFrom}を通じて`spender`が`owner`に代わって使用できる、残りのトークン数を返します。これは
     * デフォルトではゼロです。
     *
     * この値は、{approve}または{transferFrom}が呼び出されると変化します。
     */
    function allowance(address owner, address spender) external view returns (uint256);

allowance関数により、誰でも、あるアドレス(owner)が別のアドレス(spender)に使用を許可している割当量を照会できます。

 

approve関数は、割当量を作成します。 これがどのように悪用される可能性があるのかについて、該当のメッセージを必ず読んでください。 イーサリアムでは、自分のトランザクションの順序は制御できますが、相手方のトランザクションが発生したことを確認してから自分のトランザクションを送信しない限り、他のユーザーのトランザクションが実行される順序を制御することはできません。

 

    /**
     * @dev 割当メカニズムを使用して、`amount`のトークンを`sender`から`recipient`に移動させます。`amount`は呼び出し元の割当量から差し引かれます。
     *
     * 操作が成功したかを示すブール値を返します。
     *
     * {Transfer}イベントを発行します。
     */
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);

最後に、使用者(spender)がtransferFromを使用して、割当量(allowance)を実際に使用します。

 

これらのイベントは、ERC-20コントラクトの状態が変更されると発行されます。

実際のコントラクト

これはERC-20規格を実装した実際のコントラクトで、こちら (opens in a new tab)から引用しました。 これはそのまま使用するためのものではありませんが、それから継承 (opens in a new tab)して、使えるものに拡張することができます。

// SPDX-License-Identifier: MIT
pragma solidity >=0.6.0 <0.8.0;

 

インポート文

コントラクト定義では、上記のインターフェース定義に加え、別の2つのファイルをインポートします。


import "../../GSN/Context.sol";
import "./IERC20.sol";
import "../../math/SafeMath.sol";
  • GSN/Context.solは、etherを持たないユーザーがブロックチェーンを使用できるようにするシステムであるOpenGSN (opens in a new tab)を使用するために必要な定義です。 これは古いバージョンであることに注意してください。OpenGSNと統合する場合は、このチュートリアル (opens in a new tab)を使用してください。
  • SafeMathライブラリ (opens in a new tab)は、Solidityバージョン**<0.8.0**の算術オーバーフロー/アンダーフローを防ぎます。 Solidity ≥0.8.0では、算術演算はオーバーフロー/アンダーフローで自動的にリバートするため、SafeMathは不要です。 このコントラクトは、古いコンパイラバージョンとの後方互換性のためにSafeMathを使用しています。

 

このコメントは、コントラクトの目的を説明しています。

コントラクト定義

contract ERC20 is Context, IERC20 {

この行では、継承を指定しています。ここでは、上記のIERC20と、OpenGSNのためのContextを継承しています。

 


    using SafeMath for uint256;

この行では、SafeMathライブラリをuint256型にアタッチしています。 このライブラリはこちら (opens in a new tab)で確認できます。

変数定義

これらの定義では、コントラクトの状態変数を指定します。 これらの変数はprivateで宣言されていますが、これはブロックチェーン上の他のコントラクトから読み取れないというだけです。 ブロックチェーンに秘密はありません。すべてのノードのソフトウェアは、すべてのブロックですべてのコントラクトの状態を保持します。 慣例として、状態変数は_<something>と命名されます。

最初の2つの変数はマッピング (opens in a new tab)であり、キーが数値である点を除けば、おおよそ連想配列 (opens in a new tab)と同じように動作します。 ストレージは、デフォルト(ゼロ)とは異なる値を持つエントリにのみ割り当てられます。

    mapping (address => uint256) private _balances;

最初のマッピングである_balancesは、アドレスとそれぞれのこのトークンの残高です。 残高にアクセスするには、_balances[<address>]という構文を使用します。

 

    mapping (address => mapping (address => uint256)) private _allowances;

この変数_allowancesには、前述の割当量が格納されます。 最初のインデックスはトークンの所有者であり、2番目のインデックスは割当量を持つコントラクトです。 アドレスAがアドレスBのアカウントから使える量にアクセスするには、_allowances[B][A]を使用します。

 

    uint256 private _totalSupply;

名前が示すように、この変数はトークンの総供給量を追跡します。

 

    string private _name;
    string private _symbol;
    uint8 private _decimals;

この3つの変数は、可読性を向上させるために使用されます。 最初の2つの変数は自明ですが、_decimalsはそうではありません。

一方で、イーサリアムには浮動小数点変数も小数変数もありません。 他方で、人間はトークンを分割できることを好みます。 人々が金を通貨に決めた理由の一つは、誰かが牛の価値のアヒル分を買おうとしたときに、お釣りを作るのが難しかったからです。

解決策は、整数で追跡し、実際のトークンの代わりに、ほとんど価値のない小数トークンを数えることです。 etherの場合、小数トークンはweiと呼ばれ、10^18 weiが1 ETHと等価です。 執筆時点で、10,000,000,000,000 weiは、約1米ドルまたは1ユーロセントです。

アプリケーションは、トークンの残高を表示する方法を知る必要があります。 ユーザーが3,141,000,000,000,000,000 weiを持っている場合、それは3.14 ETHでしょうか? 31.41 ETHでしょうか? 3,141 ETHでしょうか? etherの場合、10^18 weiが1 ETHと定義されていますが、あなたのトークンでは別の値を選択できます。 トークンを分割する必要がなければ、値がゼロの_decimalsを使用できます。 ETHと同じ基準を使用したい場合は、値18を使用してください。

コンストラクタ

コンストラクタは、コントラクトが最初に作成されたときに呼び出されます。 慣例として関数パラメータは、<something>_のように命名されます。

ユーザーインターフェース関数

これらの関数namesymboldecimalsは、ユーザーインターフェースがあなたのコントラクトについて知り、正しく表示できるようにするのに役立ちます。

戻り値の型はstring memoryで、メモリに格納されている文字列を返すことを意味します。 文字列などの変数は、3つの場所に格納できます。

存続期間コントラクトアクセスガス代
Memory関数呼び出し中読み取り/書き込み数十または数百(上位のロケーションほど高い)
コールデータ関数呼び出し中読み取り専用戻り値型としては使用できず、関数パラメータ型のみ
ストレージ変更されるまで読み取り/書き込み高額(読み取りに800、書き込みに20k)

このケースでは、memoryの使用が最善の選択肢です。

トークン情報の読み取り

以下の関数は、トークンの情報(総供給量またはアカウントの残高のいずれか)を提供します。

    /**
     * @dev {IERC20-totalSupply}を参照してください。
     */
    function totalSupply() public view override returns (uint256) {
        return _totalSupply;
    }

totalSupply関数は、トークンの総供給量を返します。

 

    /**
     * @dev {IERC20-balanceOf}を参照してください。
     */
    function balanceOf(address account) public view override returns (uint256) {
        return _balances[account];
    }

アカウントの残高を読み取ります。 誰でも他の人のアカウント残高を取得できることに注意してください。 この情報はどのノードでも入手可能であるため、隠そうとしても無駄です。 ブロックチェーンに秘密はありません。

トークンの転送

transfer関数は、送信者のアカウントから別のアカウントにトークンを転送するために呼び出します。 戻り値がブール値ですが、その値は常にtrueであることに注意してください。 転送が失敗した場合、コントラクトは呼び出しをリバートします。

 

        _transfer(_msgSender(), recipient, amount);
        return true;
    }

_transfer関数が実際の作業を行います。 これはprivate関数であり、他のコントラクト関数からのみ呼び出せます。 慣例として、private関数は状態変数と同様に_<something>と命名されます。

通常、Solidityでは、メッセージ送信者にmsg.senderを使用します。 しかし、それではOpenGSN (opens in a new tab)が機能しません。 トークンでetherレスのトランザクションを許可したい場合は、_msgSender()を使用する必要があります。 通常のトランザクションではmsg.senderを返しますが、etherレスのトランザクションの場合は、メッセージを中継したコントラクトではなく、元の署名者を返します。

割当量関数

これらは割当量機能を実装する関数です: allowanceapprovetransferFrom_approve。 さらに、OpenZeppelin実装は、セキュリティを向上させる機能であるincreaseAllowancedecreaseAllowanceを含むように、基本的な標準を超えています。

allowance関数

    /**
     * @dev {IERC20-allowance}を参照してください。
     */
    function allowance(address owner, address spender) public view virtual override returns (uint256) {
        return _allowances[owner][spender];
    }

allowance関数を使用すると、誰でも任意の割当量を確認することができます。

approve関数

    /**
     * @dev {IERC20-approve}を参照してください。
     *
     * 要件:
     *
     * - `spender`はゼロアドレスであってはなりません。
     */
    function approve(address spender, uint256 amount) public virtual override returns (bool) {

この関数は、割当量を作成するときに呼び出します。 上記のtransfer関数と似ています。

  • この関数は、単に実際に作業を行う内部関数(この場合は_approve)を呼び出します。
  • この関数は、成功した場合はtrueを返し、失敗した場合はリバートします。

 

        _approve(_msgSender(), spender, amount);
        return true;
    }

内部関数を使用して、状態変更が起こる場所の数を最小限にしています。 状態を変更する_すべての_関数には潜在的なセキュリティリスクがあり、セキュリティ監査が必要です。 この方法で、間違いを犯す可能性を下げています。

transferFrom関数

これは、使用者(spender)が割当量を使用するために呼び出す関数です。 これには、使用される量を転送し、その量だけ割当量を減らすという2つの操作が必要です。

 

a.sub(b, "message")関数の呼び出しでは、次の2つのことを行います。 まず、a-bを計算します。これが新しい割当量になります。 次に、この結果が負の数になっていないかをチェックします。 負になっている場合、提供されているメッセージを表示して呼び出しがリバートされます。 呼び出しがリバートされると、その呼び出し中に以前に実行されたすべての処理は無視されるため、_transferを元に戻す必要はありません。

        _approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount,
             "ERC20: transfer amount exceeds allowance"));
        return true;
    }

OpenZeppelinの安全追加機能

ゼロ以外の割当量を別のゼロ以外の値に設定することにはリスクが伴います。なぜなら、自分が制御できるのは自分のトランザクションの順序のみであり、他のユーザーのトランザクションの順序は制御できないからです。 経験の浅いアリスと、不誠実なビルという2人のユーザーがいるとします。 アリスはビルからサービスを受けたいと考えており、その費用は5トークンだと思っています。そこで、彼女はビルに5トークンの割当量を与えます。

その後、何かが変わり、ビルの価格は10トークンに上がりました。 まだサービスを受けたいアリスは、ビルの割当量を10に設定するトランザクションを送信します。 ビルはこの新しいトランザクションをトランザクションプールで確認した瞬間に、アリスの5トークンを使うトランザクションを送信し、より早くマイニングされるように非常に高いガス価格を設定します。 この方法で、ビルは最初に5トークンを使い、アリスの新しい割当量がマイニングされたら、さらに10トークンを使って合計15トークンを得ることができます。これはアリスが承認するつもりだった額よりも多いです。 このテクニックはフロントランニング (opens in a new tab)と呼ばれます。

アリスのトランザクションアリスのノンスビルのトランザクションビルのノンスビルの割当量ビルのアリスからの総収入
approve(Bill, 5)1050
transferFrom(Alice, Bill, 5)10,12305
approve(Bill, 10)11105
transferFrom(Alice, Bill, 10)10,124015

この問題を回避するには、2つの関数(increaseAllowancedecreaseAllowance)を使用して、割当量を特定の量だけ変更します。 これにより、ビルがすでに5トークンを使用していた場合でも、その後に使用できるのは追加の5トークンのみとなります。 タイミングに応じて、次のような2つの動作が考えられますが、いずれの場合でもビルが取得するのは10トークンのみです。

A:

アリスのトランザクションアリスのノンスビルのトランザクションビルのノンスビルの割当量ビルのアリスからの総収入
approve(Bill, 5)1050
transferFrom(Alice, Bill, 5)10,12305
increaseAllowance(Bill, 5)110+5 = 55
transferFrom(Alice, Bill, 5)10,124010

B:

アリスのトランザクションアリスのノンスビルのトランザクションビルのノンスビルの割当量ビルのアリスからの総収入
approve(Bill, 5)1050
increaseAllowance(Bill, 5)115+5 = 100
transferFrom(Alice, Bill, 10)10,124010

a.add(b)関数は、安全な加算です。 万が一a+b>=2^256となっても、通常の加算のようにラップアラウンドしません。

トークン情報を変更する関数

次の4つの関数(_transfer_mint_burn_approve)は、実際の処理を行います。

_transfer関数

この_transfer関数は、トークンをあるアカウントから別のアカウントへ転送します。 この関数は、transfer(送信者自身のアカウントからの転送)とtransferFrom(割当量を使用して他のユーザーのアカウントから転送)の両方から呼び出されます。

 

        require(sender != address(0), "ERC20: transfer from the zero address");
        require(recipient != address(0), "ERC20: transfer to the zero address");

イーサリアムでは、実際にアドレス0を所有しているユーザーはいません(つまり、対応する公開鍵がゼロアドレスに変換される秘密鍵を知っているユーザーはいないということです)。 誰かがこのアドレスを使っている場合、通常それはソフトウェアのバグです。そのため、送信者または受取人にゼロアドレスが指定されている場合は失敗します。

 

        _beforeTokenTransfer(sender, recipient, amount);

このコントラクトを使用するには2つの方法があります。

  1. 自分のコードのテンプレートとして使う
  2. コントラクトから継承 (opens in a new tab)し、修正が必要な関数のみをオーバーライドする

OpenZeppelinのERC-20コードはすでに監査を受けており、安全であることが示されているため、2つ目の方法がはるかに優れています。 継承を使用すると、変更した関数が明らかになり、人々はコントラクトを信頼するためにそれらの特定の関数のみを監査すればよくなります。

多くの場合、トークンの所有者が変わるたびに関数を実行すると便利です。 しかし、_transferは非常に重要な関数であり、安全でない書き方をしてしまう可能性があるため(下記参照)、オーバーライドしないことをお勧めします。 解決策は、フック関数 (opens in a new tab)である_beforeTokenTransferです。 この関数をオーバーライドすれば、転送のたびに呼び出されるようになります。

 

        _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
        _balances[recipient] = _balances[recipient].add(amount);

この2行で、実際に転送を行っています。 この2行の間には何もなく、受取人に加算する前に送信者から転送額を減算していることに注意してください。 この2行の間に別のコントラクトへの呼び出しがある場合、このコントラクトで不正を行うために使用される可能性があるため、このことは非常に重要になります。 こうすることで、転送がアトミックになり、その途中で何も起こらなくなります。

 

        emit Transfer(sender, recipient, amount);
    }

最後に、Transferイベントを発行します。 イベントはスマートコントラクトからアクセスできませんが、ブロックチェーンの外で実行されているコードは、イベントをリッスンして対応することができます。 例えば、ウォレットは所有者がより多くのトークンを得た時期を追跡できます。

_mint関数と_burn関数

この2つの関数(_mint_burn)は、トークンの総供給量を変更します。 これらは内部関数であり、このコントラクト内にこれらを呼び出す関数はありません。そのため、コントラクトを継承し、どのような条件下で新しいトークンをミントし、既存のトークンをバーンするかを決定する独自のロジックを追加する場合にのみ役立ちます。

注: すべてのERC-20トークンには、トークン管理を規定する独自のビジネスロジックがあります。 例えば、固定供給量のコントラクトでは、コンストラクタ内で_mintのみを呼び出し、_burnを呼び出すことはありません。 トークンを販売するコントラクトは、支払いが行われたタイミングで_mintを呼び出し、おそらく、天井知らずのインフレを避けるためにある時点で_burnを呼び出します。

トークンの総数が変更された場合は、_totalSupplyを必ずアップデートしてください。

 

_burn関数は、方向が逆であることを除き_mintとほぼ同じです。

_approve関数

これは、実際に割当量を指定する関数です。 所有者は自身の現在の残高よりも高い割当量を指定できることに注意してください。 残高は転送時にチェックされ、割当量の作成時の残高と異なる可能性があるため、これは問題ありません。

 

Approvalイベントを発行します。 アプリケーションがどのように書かれているかによって異なりますが、使用者(spender)のコントラクトには、所有者またはこれらのイベントをリッスンしているサーバーのいずれかによって承認が通知されます。

        emit Approval(owner, spender, amount);
    }

decimals変数の変更

この関数は_decimals変数を変更します。この変数は、量の解釈方法をユーザーインターフェースに伝えるのに使用されます。 コンストラクタから呼び出すべきです。 その後のどの時点においてもこの関数を呼び出すと不正になり、アプリケーションはこのような処理をするようには設計されていません。

フック

このフック関数は、転送中に呼び出されます。 ここでは空になっていますが、何かを実行するのにこの関数が必要な場合は、オーバーライドしてください。

結論

確認のため、このコントラクトの最も重要な点を以下にまとめています(個人的な意見のため、他者にとって重要な点とは異なる場合があります) 。

  • ブロックチェーンには秘密はありません。 スマートコントラクトがアクセスできる情報は、全世界で利用可能です。
  • 自分のトランザクションの順序は自分で制御できますが、他のユーザーのトランザクションが発生するタイミングは制御できません。 これが、割当量の変更が危険となりうる理由です。変更により、使用者(spender)が両方の割当量の合計を使用できてしまうためです。
  • uint256型の値はラップアラウンドします。 言い換えると、_0-1=2^256-1_となります。 これが望ましい動作ではない場合、プログラムで確認する必要があります(または、それを行うSafeMathライブラリを使用します)。 これはSolidity 0.8.0 (opens in a new tab)で変更されたことに注意してください。
  • 監査を容易にするため、特定の場所で特定の型のすべての状態変更を行います。 これが、例えばapprovetransferFromincreaseAllowancedecreaseAllowanceによって呼び出される_approveが存在する理由です。
  • 状態変更は、(_transferで見られるように)処理の途中で他のアクションに割り込まれることがないアトミックである必要があります。 これは状態変更中に、一貫性のない状態が存在するためです。 例えば、送信者の残高から差し引いた時点から、受取人の残高に加えるまでの間は、存在するべき数よりも少ないトークンが存在することになります。 この2つの処理の間に別の操作(特に、異なるコントラクトの呼び出しなど)がある場合、このトークンの状態が悪用される可能性があります。

ここまで、OpenZeppelin ERC-20コントラクトがどのように書かれているか、特に、より安全に記述する方法を学びました。是非自分でも安全なコントラクトとアプリケーションを作成してみてください。

私の他の作品はこちらでご覧いただけます (opens in a new tab).

ページの最終更新: 2026年4月15日

このチュートリアルは役に立ちましたか?