【Ethereum】スマートコントラクトのベストプラクティスについてまとめる①
Ethereumでのスマートコントラクト開発、すなわちSolidityでのプログラミングでは、かなりしっかりとしたコーディングをすることが求められる。
なぜなら、
- 一度ブロックにデプロイしたコードは変更できない
- 悪意のある人の攻撃により資産を盗まれる可能性がある
- 下手に書くと、手数料のGASが必要以上にかかる
といった理由があるから。
そこで、SolidityでのベストプラクティスをConsenSysが出しているので、それをまとめる。
英語が時々更新されるので、最新情報を確認したい時は英語版を確認することをお勧めします。
割と長いのでここではざっくりのまとめです。
※自分の言葉に直してる部分があるので、元々の言い方や内容と違う部分もあります。
心構え
失敗は起きる前提で考える
しっかりテストして慎重にデプロイする
コントラクトコードはシンプルなものにする
- 複雑だとバグが起きやすい
- オンチェーンにすべきもののみコントラクトとする
- 共通部分をモジュール化する
最新のセキュリティ対策できるようコードをこまめにアップデートしていく
スマートコントラクトの特性を把握する
- 実行にGASがかかること
- 悪意のあるコントラクトへのコールがいること
- コードは公開されていること
シンプルさと複雑さのトレードオフを意識する
- 基本はアップグレードできて、モジュール化して、コードの再利用をすべき
- しかし、ベストプラクティスが最適でない複雑にすべきケースもあるので、意識しておく
推奨する実装
信用できない外部コントラクトと関わるものにはマークをする
変数、メソッド、コントラクト、インターフェースを対象に、
信用できない外部コントラクトを呼び出すものに関わる場合
- 信用できない場合:untrustedと名前に入れる
- 信用できる場合:trustedと名前に入れる
外部コントラクトを呼び出した後にStateの変更をすることを避ける
外部コントラクトを実行すると悪質なコードが実行されうる。
そうすると、コントラクト実行後にStateを変えようとしても、期待通りに実行されず、
制御フローを悪意のあるものに操作されることがある。
(Race Conditionが実例)
send(), transfer(), call.value()()を使い分ける
全て送信先がコントラクトだった場合、送信先のコントラクトでのコードが実行されうるのだが、
someAddress.call.value()()
の場合、送信先コントラクトが利用できるGAS量が制限されていない。
結果、GASを使い切るような攻撃をされるリスクが生まれる。
一方someAddress.send()
とsomeAddress.transfer()
は、2300GASまでと決まっているため、そういったリスクはない。
ただし、制限ゆえに使えないケースもあるので使い分けが大事。
※このあたりはややこしいので別途まとめる予定
external callのエラーハンドリング
external call時に、呼び出し先がコントラクトだと、呼び出し先のコードが実行され、
Exceptionがthrowされたり、falseがreturnされる。
その中でも、raw-levelなメソッド(someAddress.send()など)の場合には、呼び出し先でエラーになった時の処理を記述する。
if (!someAddress.send(100)){ //エラーが発生した場合の処理を書く(返金など) }
external call部分のトランザクションを分ける
連続する処理の中に、外部コントラクトの呼び出しをする場合、悪意あるユーザのコントラクトだった場合、そこで処理が止まってしまうことがある。
そこで、各外部呼び出しを、受信者が開始できる独自のトランザクションに分離する方がよい場合がある。
特に複数ユーザへの支払い時には、ユーザーに自動的に資金を送金するのではなく、各ユーザから資金を引き出してもらうほうがよい。
新たにデプロイされたコントラクトの残高が0と決めつけない
デプロイ前にコントラクトにweiを送金できるため、残高0と決めつけた設計をしないこと
オンチェーンのデータは公開されていることを忘れない
基本的にオンチェーンのデータは全て公開されている。
ゲームやオークションなどで非公開な情報の処理を行う場合は、それに応じた設計が必要。
例えばじゃんけんの場合、何も考えずにやると同時に手を出すのは難しく後出し側がオンチェーンデータを見れば勝ってしまう。
そこで互いに事前に出すものをハッシュ化したものを登録する。
実際に出したあと、出したもののハッシュ値が事前のものと同じかを検証する。
複数名の契約において、一部の参加者が途中でオフラインになることを考慮する
たとえばゲームにおいて途中で一人のプレイヤーが次のアクションを意図的に行なわなかったり、偶然できない場合を想定しておく。
(資金を預ける場合は、一定時間たったら資金の払い戻しを行うなどの対処を考える等)
assert()とrequire()を使う
require()
はユーザの入力へのチェック等に用いて、assert()
は不変プロパティのチェックや内部エラーのチェックに用いる
除算の丸めに注意する
除算では最も近い整数に切り下げられる。
uint a = 5 / 2
は2となる。
こういう時は、乗算を使う。
Etherを強制的にアカウントに送金できる
selfdestruct(victimAddress)
を使うことで、victimAddressに強制的に元のコントラクト内のEtherを送金できる。
victimAddress側ではフォールバック関数含め、コードは一切実行されないため防ぎようがない。
抽象コントラクトとインタフェースを使い分ける
インタフェースは、実装済みのfuctionを持つことはできない。
また、インタフェースには、ストレージにアクセスできない、または他のインタフェースから継承できないなどの制限もある。
しかし、インタフェースは実装前にコントラクトを設計する上では有用。
fallback関数をシンプルにする
Fallback関数は、引数なしのメッセージが送信されたとき、もしくは関数が一致しないときに呼び出される。
send()
または transfer()
から呼び出されたときは2300GAS分までの処理が可能。
これらのメソッドからEtherを受信したければ、fallback関数でできるのはeventでの記録をすることくらい。
それい以上のガスが必要な場合は、他の関数で呼び出されるような設計が必要
メソッド、変数のアクセス制限を明示しておく
メソッドや変数には、external
、public
、 internal
、private
を指定しておくこと
pragmaのバージョンを固定する
プラグマを固定すると、未知のバグのリスクが高い最新のコンパイラなどを使用して、誤った処理がされないようになる。
pragma solidity ^0.4.4;
はよろしくなく、
pragma solidity 0.4.4;
とすべきらしい
※世の中的に前者が割と多く、どこまで後者にすべきなのかはわからなかった。
関数とイベントの命名規則
関数とイベントの混乱の危険を避けるため、イベント名は大文字で開始し、大文字の前に接頭辞を付ける(Logを推奨)。
関数の場合はコンストラクタを除き、常に小文字で始める。
できるだけ新しい構造を使う
suicide
よりもselfdestruct
を使うなど
組み込み関数をシャドーイング出来ることに注意
msg
やrevert()
などの組み込み関数に対して、シャドーイング、すなわち同一名のメソッドを宣言することで
元の組み込み関数にアクセスできなくなる。
tx.originを使わない
msg.sender
を使うようにする。
tx.origin
は将来的に削除の予定らしい。
詳細はこちら
timestampの30秒ルール
minerは、ブロック生成時に、30秒間の範囲で好きなタイムスタンプを設定する権限を持つ。
もし、ゲームの乱数でtimestampを使っていた場合、minerにより乱数調整ができてしまう。
ただし、30秒の間で状態が変わらないのであれば、問題はない。
複数の継承を扱う場合の注意
例えば、BとCというコントラクトがあるときに
contract A is B, C { function A() public B(3) C(5) { setFee(); } }
としたときどうなるか?などの挙動を理解しておく。
(基本コードの左から順に継承していくらしい。C -> B -> A)
詳しくは、こちら
まだまだあるので続きは別記事にします。