このページでは、HuffでEVMの命令を直接記述することでコントラクトを開発します。 安全なコントラクトを開発する上で、EVMの命令に精通することは重要です。
目次
Huffとは、EVM命令(PUSH1
やMSTORE
など)レベルでコントラクトを開発できるプログラミング言語です。
Solidityコントラクト内部でよく使われるYul言語に近いですが、Yulよりも低いレイヤーでの細かな操作ができます。
Huffの用途の一つに、ガスの最適化があります。 ほぼ全てのケースで理論上最低のガス消費量を実現できます。 そのため、様々なコントラクトで頻繁に使われる基礎的なコントラクトやライブラリをHuffで記述する取り組み(huff-language/huffmate)があります。
もう一つの用途として、EVMの仕様を学ぶために使えます。 コントラクト開発において、そのコントラクトがどのようなEVMバイトコードにコンパイルされ、どのような状態遷移を行うかを意識することは、セキュリティ観点から大事です。
https://docs.huff.sh/get-started/installing/ に従ってください。
EthereumのスマートコントラクトはEthereum Virtual Machine (EVM)と呼ばれる仮想マシン上で実行されます。 EVMはオペコードが150個弱あり、オペランド数あるいはオペランド長が異なるだけのオペコードを同一視すると80個もありません。
EVMバイトコードとは、その名の通りEVMで実行できるコードのことです。 Solidityコンパイラは、SolidityコードをEVMバイトコードに変換しています。
例えば、forge init
して作成される下記のCounter
コントラクトのEVMのバイトコードを見てみましょう。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Counter {
uint256 public number;
function setNumber(uint256 newNumber) public {
number = newNumber;
}
function increment() public {
number++;
}
}
forge init
したディレクトリで、forge build
を実行するとコンパイルが行われ、out
ディレクトリにABI、バイトコード、メタデータ、抽象構文木などのコンパイル結果が出力されます。
Counter
コントラクトのデータは、out/Counter.sol/Counter.json
にあります。
バイトコード部分だけ抜粋すると次のデータが載っています。
"bytecode": {
"object": "0x608060405234801561001057600080fd5b5060f78061001f6000396000f3fe6080604052348015600f57600080fd5b5060043610603c5760003560e01c80633fb5c1cb1460415780638381f58a146053578063d09de08a14606d575b600080fd5b6051604c3660046083565b600055565b005b605b60005481565b60405190815260200160405180910390f35b6051600080549080607c83609b565b9190505550565b600060208284031215609457600080fd5b5035919050565b60006001820160ba57634e487b7160e01b600052601160045260246000fd5b506001019056fea2646970667358221220fae0b1cefc14f831678071dac56d7c756dba4a7e705742be3f473c8c85e2769564736f6c63430008140033",
"sourceMap": "65:192:19:-:0;;;;;;;;;;;;;;;;;;;",
"linkReferences": {}
},
"deployedBytecode": {
"object": "0x6080604052348015600f57600080fd5b5060043610603c5760003560e01c80633fb5c1cb1460415780638381f58a146053578063d09de08a14606d575b600080fd5b6051604c3660046083565b600055565b005b605b60005481565b60405190815260200160405180910390f35b6051600080549080607c83609b565b9190505550565b600060208284031215609457600080fd5b5035919050565b60006001820160ba57634e487b7160e01b600052601160045260246000fd5b506001019056fea2646970667358221220fae0b1cefc14f831678071dac56d7c756dba4a7e705742be3f473c8c85e2769564736f6c63430008140033",
"sourceMap": "65:192:19:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;116:80;;;;;;:::i;:::-;171:6;:18;116:80;;;88:21;;;;;;;;;345:25:21;;;333:2;318:18;88:21:19;;;;;;;202:53;;240:6;:8;;;:6;:8;;;:::i;:::-;;;;;;202:53::o;14:180:21:-;73:6;126:2;114:9;105:7;101:23;97:32;94:52;;;142:1;139;132:12;94:52;-1:-1:-1;165:23:21;;14:180;-1:-1:-1;14:180:21:o;381:232::-;420:3;441:17;;;438:140;;500:10;495:3;491:20;488:1;481:31;535:4;532:1;525:15;563:4;560:1;553:15;438:140;-1:-1:-1;605:1:21;594:13;;381:232::o",
"linkReferences": {}
},
bytecode
とdeployedBytecode
のobject
フィールドにあるデータがバイトコードです。
バイトコードが2つありますが、この違いはbytecode
がデプロイ時のバイトコードで、deployedBytecode
が実行時のバイトコードであることです。
Yellow Paperに準拠すれば、トランザクションにはto
フィールドとinit
フィールドがあります。
トランザクションの受信者であるto
フィールドが空であるとき、init
フィールドに指定されたバイト列が実行されます。
ただし、Gethなどの実際のクライアントはdata
フィールドがinit
フィールドを兼任していることがあります。
以降、data
フィールドに統一します。
to
フィールドが空であるとき、data
フィールドにbytecode
が実行されて、Counter
コントラクトのための新しいアドレスが割り当てられ、そのアドレスにコントラクトがデプロイされます。
この中身についての解説は「Reversing EVM Bytecodes」で行います。
EVMにおいて、データを保存できる領域が3つあります。 ストレージ、メモリ、スタックです。 EVMバイトコードを読み書きする上で、この3つの領域の性質を正確に把握することは重要です。 ここでは、簡単に概要を説明します。
ストレージは、コントラクトごとに用意される永続的なデータ領域であり、32バイトのスロットから32バイトの値へのマッピングです。
初期状態では全ての値が0
になっています。
SELFDESTRUCT
命令が実行されると破壊されます。
ストレージは他のコントラクトから直接読み書きできません。
SLOAD
命令で読み取り、SSTORE
命令で書き込みができます。
メモリは、コールごとに用意される一時的なデータ領域であり、任意の位置に読み書きができる連続的なものです。
初期状態ではサイズ0
となっています。
コールが終了すると、メモリも破棄されます。
任意の位置に読み書きできますが、インデックスが大きければ大きいほど二次関数的にガスコストが増加します。
MLOAD
命令で読み取り、MSTORE
命令で書き込みができます。
スタックは、コールごとに用意される一時的なデータ領域であり、一般的なスタック構造と同様にプッシュとポップの2つの操作ができ、後にプッシュしたものが先にポップされます。
PUSHx
命令でプッシュができ、POP
命令でポップができます。
その他にも、DUPx
命令で複製や、SWAPx
命令でスタック内の要素のスワップなどができます。
コールが終了すると、スタックも破棄されます。
スタックに保管できる要素の制限は1024であり、その数を超えてプッシュしようとするとStack Too Deepと呼ばれるエラーが起き、トランザクションがリバートします。
EthernautのMagic Numberは、whatIsTheMeaningOfLife()
という関数呼び出しに対して、42
という数値を返すコントラクトを作成する問題です。
ただし、コントラクトサイズが10を超えてはいけません。
まず、SolidityでwhatIsTheMeaningOfLife()
に対して42
を返すコントラクトを書くと次のようになります。
contract MagicNumberSolverNaive {
function whatIsTheMeaningOfLife() public pure returns (uint256) {
return 42;
}
}
このコントラクトのバイトコードは、次のようになります。
$ solc course/evm-with-huff/challenge-magic-number/MagicNumberSolverNaive.sol --bin-runtime --optimize --no-cbor-metadata
======= course/evm-with-huff/challenge-magic-number/MagicNumberSolverNaive.sol:MagicNumberSolverNaive =======
Binary of the runtime part:
6080604052348015600e575f80fd5b50600436106026575f3560e01c8063650500c114602a575b5f80fd5b602a60405190815260200160405180910390f3
solc
はデフォルトで、コントラクトのバイトコードにCBORメタデータを付加するので、--no-cbor-metadata
オプションを指定することで削除しています。
このメタデータについては、Solidityドキュメントの「バイトコードにおけるメタデータハッシュのエンコーディング」に詳しく書いてあります。
出力されたコントラクトのバイトコードのサイズを調べると62バイトであり、条件の10
を満たすには程遠いです。
そのため、条件を満たすコントラクトを作るには、EVM命令を直接記述する必要がありそうです。
SolidityにはYulというEVM命令にかなり近い記述ができる言語がありますが、HuffはEVM命令を直接記述できます。 そのHuffで今回の条件を満たすコントラクトを書くと次のようになります。
#define macro MAIN() = takes (0) returns (0) {
0x2a // [0x2a]
0x00 // [0x00, 0x2a]
mstore // []
0x20 // [0x20]
0x00 // [0x00, 0x20]
return // []
}
まず、#define macro MAIN() = takes (0) returns (0) {
と}
については一旦置いといて、これに囲まれた部分が実行されるイメージを持ってください。
Huffでは命令を1行ずつ書く慣習があります。
0x2a
はPUSH1 0x2a
を表します。
// [0x2a]
はコメントであり、PUSH1 0x2a
を実行後にスタックが[0x2a]
になることを表しています。
このスタックのコメントも、スタックの中身と遷移が自明でない限り記述するのが慣習です。
次に0x00
があります。
0x00
はPUSH0
を表します。
PUSH0
は2023年4月のShanghai
アップグレードによって導入されたオペコードです。
そのため、以前のHuffではPUSH1 0x00
を表していました。
新しい値がプッシュされたら左に値を追加するのが慣習で、コメントは[0x00, 0x2a]
になります。
続くmstore
命令は、offset
とvalue
の2つの引数をスタックから順にポップして受け取り、メモリの[offset,offset+32)
の位置にvalue
をストアする命令です。
現在のスタックが[0x00, 0x2a]
なので、0x00
の位置に0x2a
が32バイトの値としてストアされ、メモリは次のようになります。
0x00: 000000000000000000000000000000000000000000000000000000000000002a
同様に、PUSH1 0x20
とPUSH0
が処理されて、スタックが[0x00, 0x20]
になります。
最後に、return
命令が実行されます。
return
命令は、offset
とsize
の2つの引数をスタックから順にポップして受け取り、メモリの[offset,offset+size)
の位置のデータを呼び出し側に返します。
スタックが[0x00, 0x20]
なので、返るデータは000000000000000000000000000000000000000000000000000000000000002a
になり、42
という数値が正しく返っていることがわかります。
上記のHuffコードをコンパイルして、コントラクトのバイトコードを取得してみます。
$ huffc -r course/evm-with-huff/challenge-magic-number/MagicNumberSolver.huff
⠙ Compiling...
602a5f5260205ff3⏎
出力された602a5f5260205ff3
は8バイトです。
これで、MagicNumberが解けたことになります。
Solidityで書いた最適化を行ったコントラクトが62バイトだったので、およそ1/8ほどのバイト長になったことがわかります。
バイト長が小さくなると、デプロイ時のガスコストが低下しますが、それだけでなく実行時のコストも低下しています。
上記のように、HuffではPUSH
命令以外の全ての命令は、ニーモニックをそのまま記述すれば良いです。
PUSH
命令のみニーモニックを書くのではなく、プッシュする値を記述します。
以下、演習です。 演習では以上で説明したHuffの機能以外に使用しませんが、他にも様々な機能があります。
上記のコントラクト(以下再掲)のバイトコードは8バイトです。
#define macro MAIN() = takes (0) returns (0) {
0x2a // [0x2a]
0x00 // [0x00, 0x2a]
mstore // []
0x20 // [0x20]
0x00 // [0x00, 0x20]
return // []
}
このコントラクトのサイズを1バイト小さくしてください。
MagicNumberSolver.huff
を書き換えてください。
この演習はEVMへの理解を深めるための問題なので、evm.codesを参考にして解くことを推奨します。
以下のコマンドを実行して、テストがパスしたらクリアです。
forge test --match-path course/evm-with-huff/challenge-magic-number/MagicNumberSolver.t.sol --match-test test7Bytes -vvv
ヒント1
この6つの命令のうち、どれか1つを別の命令に変える必要があります。
ヒント2
PUSH1 0x20
を他の命令に置き換えられないでしょうか?
偶数を判定するコントラクトをHuffで記述してください。
より正確には、コールデータに与えられる32バイトの数値が偶数ならば1
を、奇数ならば0
を返してください。
コントラクトサイズは11以下にしてください。
以下のコマンドを実行して、テストがパスしたらクリアです。
forge test --match-path course/evm-with-huff/challenge-even/EvenSolver.t.sol -vvv
Quineとは、自身のソースコードと完全に同じ文字列を出力するプログラムのことです。
例えば、Pythonにおいては次のコードがQuineの一つです。
a='a=%r;print(a%%a)';print(a%a)
実際に実行してみると、Quineであることを確認できます。
>>> a='a=%r;print(a%%a)';print(a%a)
a='a=%r;print(a%%a)';print(a%a)
EVMにおけるQuineとして、コントラクトにコールをするとそのコントラクトのバイトコードを返すコントラクトが考えられます。
より厳密には、0xXXYYZZ
というバイトコードがあったときに、そのバイトコードを実行すると0xXXYYZZ
がRETURN
命令で返るということです。
この問題では、「デプロイ時のコード」と「デプロイされたコントラクトのコード」と「そのコントラクトにSTATICCALL
命令を行ったときの返り値」が全て一致するようなEVM Quineを作成してください。
コントラクトサイズの制限はありません。
以下のコマンドを実行して、テストがパスしたらクリアです。
forge test --match-path course/evm-with-huff/challenge-quine/QuineSolver.t.sol -vvv
前の問題では全ての命令が許可されていました。
この問題では、より難しくするために次の命令群の使用を禁止します。 (前の問題のネタバレにならないように隠しています。)
使用不可の命令一覧
0x30
~0x48
SLOAD
SSTORE
CREATE
CALL
CALLCODE
DELEGATECALL
CREATE2
STATICCALL
SELFDETRUCT
また、コントラクトサイズを33以下にしてください。
以下のコマンドを実行して、テストがパスしたらクリアです。
forge test --match-path course/evm-with-huff/challenge-quine-hard/QuineSolver.t.sol -vvv