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

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

Solidity
erc-20
初級
オリ・ポメランツ
2021年3月9日
49 分で読めます

はじめに

イーサリアムの最も一般的な用途の1つは、グループが取引可能なトークン、ある意味で独自の通貨を作成することです。これらのトークンは通常、ERC-20という標準に従っています。この標準により、流動性プールやウォレットなど、すべてのERC-20トークンで機能するツールを作成できるようになります。この記事では、オープンツェッペリンの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)を作成します。トークン・コントラクトを使用する必要があるコードは、メタマスクのようなウォレットであれ、etherscan.ioのような分散型アプリケーション (dapp) であれ、流動性プールのような別のコントラクトであれ、インターフェース内の同じ定義を使用することで、それを使用するすべてのトークン・コントラクトと互換性を持つことができます。

Illustration of the ERC-20 interface

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

これはオープンツェッペリンによる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で定義されているERC-20標準のインターフェース。
 */

コメント内の@devは、ソースコードからドキュメントを生成するために使用されるNatSpecフォーマット (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 呼び出し元のアカウントから `recipient` へ `amount` 分のトークンを送金します。
     *
     * 操作が成功したかどうかを示すブール値を返します。
     *
     * {Transfer} イベントを発行します。
     */
    function transfer(address recipient, uint256 amount) external returns (bool);

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

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

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

コントラクトの状態を変更する他の関数でも、同じタイプの出力が作成されます。

 

アローワンスは、あるアカウントが別の所有者に属するトークンの一部を消費することを許可します。これは、例えば販売者として機能するコントラクトにとって有用です。コントラクトはイベントを監視できないため、購入者が販売者コントラクトに直接トークンを送金した場合、そのコントラクトは支払いが行われたことを知ることができません。代わりに、購入者は販売者コントラクトに一定額の消費を許可し、販売者がその額を送金します。これは販売者コントラクトが呼び出す関数を通じて行われるため、販売者コントラクトはそれが成功したかどうかを知ることができます。

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

allowance関数を使用すると、あるアドレス(owner)が別のアドレス(spender)に消費を許可しているアローワンスがいくらであるかを誰でも照会できます。

 

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

 

    /**
     * @dev アローワンスのメカニズムを使用して、`sender` から `recipient` へ `amount` 分のトークンを送金します。その後、`amount` は呼び出し元のアローワンスから差し引かれます。
     *
     * 操作が成功したかどうかを示すブール値を返します。
     *
     * {Transfer} イベントを発行します。
     */
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);

最後に、transferFromは、消費者が実際にアローワンスを消費するために使用されます。

 

これらのイベントは、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は、イーサを持たないユーザーがブロックチェーンを使用できるようにするシステムである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はそうではありません。

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

解決策は、整数を追跡しつつ、実際のトークンの代わりに、ほとんど価値のない分割されたトークンを数えることです。イーサの場合、分割されたトークンは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でしょうか? イーサの場合は1 ETHあたり10^18 Weiと定義されていますが、独自のトークンでは異なる値を選択できます。トークンを分割することに意味がない場合は、_decimalsの値をゼロにすることができます。ETHと同じ標準を使用したい場合は、値として18を使用します。

コンストラクタ

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

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

これらの関数(namesymbol、およびdecimals)は、ユーザーインターフェースがコントラクトについて認識し、適切に表示できるようにするのに役立ちます。

戻り値の型はstring memoryであり、メモリに保存された文字列を返すことを意味します。文字列などの変数は、次の3つの場所に保存できます。

ライフタイムコントラクトのアクセスガス・コスト
Memory関数呼び出し読み取り/書き込み数十から数百(上位の場所ほど高くなる)
Calldata関数呼び出し読み取り専用戻り値の型としては使用できず、関数のパラメータ型としてのみ使用可能
Storage変更されるまで読み取り/書き込み高い(読み取りに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関数が実際の作業を行います。これは、他のコントラクト関数からのみ呼び出すことができるプライベート関数です。慣例として、プライベート関数は状態変数と同様に_<something>のように名付けられます。

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

アローワンス関数

これらはアローワンス機能を実装する関数です:allowanceapprovetransferFrom、および_approve。さらに、オープンツェッペリンの実装は基本標準を超えて、セキュリティを向上させるいくつかの機能(increaseAllowanceおよびdecreaseAllowance)を含んでいます。

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関数

これは、消費者がアローワンスを消費するために呼び出す関数です。これには2つの操作が必要です。消費される金額を送金することと、その金額分だけアローワンスを減らすことです。

 

a.sub(b, "message")関数の呼び出しは2つのことを行います。まず、新しいアローワンスであるa-bを計算します。次に、この結果が負でないことを確認します。負の場合、呼び出しは提供されたメッセージとともにリバートされます。呼び出しがリバートされると、その呼び出し中に以前に行われた処理はすべて無視されるため、_transferを元に戻す必要はないことに注意してください。

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

オープンツェッペリンの安全性追加機能

ゼロでないアローワンスを別のゼロでない値に設定することは危険です。なぜなら、制御できるのは自分自身のトランザクションの順序だけであり、他人のトランザクションの順序は制御できないからです。純真なアリスと不誠実なビルという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つの関数(increaseAllowanceおよびdecreaseAllowance)を使用すると、特定の金額だけアローワンスを変更できます。したがって、ビルがすでに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");

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

 

        _beforeTokenTransfer(sender, recipient, amount);

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

  1. 独自のコードのテンプレートとして使用する
  2. それを継承し (opens in a new tab)、変更する必要がある関数のみをオーバーライドする

オープンツェッペリンの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);

これらが実際に送金を行う行です。これらの間には何もなく、受信者に追加する前に送信者から送金額を差し引いていることに注意してください。これは重要です。なぜなら、途中で別のコントラクトへの呼び出しがあった場合、このコントラクトを騙すために使用される可能性があるからです。この方法により、送金はアトミックになり、その途中で何も起こることはありません。

 

        emit Transfer(sender, recipient, amount);
    }

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

_mint関数と_burn関数

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

注: すべてのERC-20トークンには、トークン管理を規定する独自のビジネスロジックがあります。例えば、固定供給量のコントラクトはコンストラクタで_mintを呼び出すだけで、_burnを呼び出すことはないかもしれません。トークンを販売するコントラクトは、支払いを受けたときに_mintを呼び出し、暴走するインフレを避けるためにある時点で_burnを呼び出すと考えられます。

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

 

_burn関数は、逆方向の処理を行うことを除けば、_mintとほぼ同じです。

_approve関数

これは実際にアローワンスを指定する関数です。所有者が現在の残高よりも高いアローワンスを指定できることに注意してください。送金時に残高がチェックされ、その時点ではアローワンス作成時の残高とは異なっている可能性があるため、これは問題ありません。

 

Approvalイベントを発行します。アプリケーションの記述方法に応じて、消費者コントラクトは、所有者から、またはこれらのイベントをリッスンするサーバーから承認について通知を受けることができます。

        emit Approval(owner, spender, amount);
    }

decimals変数の変更

この関数は、ユーザーインターフェースに金額の解釈方法を伝えるために使用される_decimals変数を変更します。これはコンストラクタから呼び出す必要があります。それ以降の任意の時点で呼び出すことは不誠実であり、アプリケーションはそれを処理するように設計されていません。

フック

これは送金中に呼び出されるフック関数です。ここでは空ですが、何かを実行する必要がある場合はオーバーライドするだけです。

おわりに

復習として、このコントラクトにおける最も重要なアイデアのいくつかを以下に示します(私の意見であり、あなたの意見は異なるかもしれません)。

  • ブロックチェーン上に秘密はありません。スマート・コントラクトがアクセスできる情報はすべて、全世界に公開されています。
  • 自分自身のトランザクションの順序は制御できますが、他人のトランザクションがいつ発生するかは制御できません。これが、アローワンスの変更が危険になり得る理由です。なぜなら、消費者が両方のアローワンスの合計を消費できるようになるからです。
  • uint256型の値はラップアラウンドします。言い換えれば、_0-1=2^256-1_となります。それが望ましくない動作である場合は、それをチェックする必要があります(または、代わりに行ってくれるSafeMathライブラリを使用します)。これはSolidity 0.8.0 (opens in a new tab)で変更されたことに注意してください。
  • 特定のタイプの状態変更はすべて特定の場所で行います。これにより監査が容易になるからです。これが、例えばapprovetransferFromincreaseAllowance、およびdecreaseAllowanceから呼び出される_approveが存在する理由です。
  • 状態の変更はアトミックであるべきであり、その途中に他のアクションを含めるべきではありません(_transferで見られるように)。これは、状態の変更中は状態が不整合になるためです。例えば、送信者の残高から差し引いてから受信者の残高に追加するまでの間、存在するトークンは本来あるべき数よりも少なくなります。その間に操作、特に別のコントラクトへの呼び出しがある場合、これが悪用される可能性があります。

オープンツェッペリンのERC-20コントラクトがどのように書かれているか、そして特にどのように安全性が高められているかを確認したところで、あなた自身の安全なコントラクトやアプリケーションを書いてみましょう。

私の他の作品はこちらをご覧ください (opens in a new tab)