GST2 Bytecode Deep Dive
This post seeks to break down the bytecode and assembly code used by GST2 (Gas Token 2) in order to understand how it works at a low-level. We will be stepping through the functions used to mint and redeem gas tokens, as well as the bytecode which governs how a gas token “dummy contract” works. Note that this post will assume you understand some Solidity assembly, the concept of a stack machine, and how the EVM reads bytecode.
GST2 functions similarly to GST1 and CHI, two popular and well-known gas tokens. I hope that by illustrating how GST2 works under-the-hood, GST1 and CHI can be better understood as well.
Warning: There are active discussions to remove the
SELFDESTRUCT feature prior to the merge to Proof of Stake, as well as gas refunds in general. If this were to happen, then gas tokens would no longer be usable for gas, and hence impact their value.
This change could occur as early as the London network upgrade (July 2021).
You can read about this (potential) decision in the following articles:
- List of EVM features potentially worth removing.
- Pragmatic destruction of
- EIP-3298: Removal of refunds
makeChild() bytecode: Initialization data
In this section, we’ll examine exactly how the bytecode deployed in the
makeChild function operates and becomes a destructable “dummy” contract. This section specifically details the initialization data, which is the portion of the bytecode that actually creates the contract, versus the bytecode which is the contract (that portion is discussed in the next section).
makeChild function contains 3 lines of assembly code which do everything.
let solidity_free_mem_ptr := mload(0x40) mstore(solidity_free_mem_ptr, 0x00756eb3f879cb30fe243b4dfee438691c043318585733ff6000526016600af3) addr := create(0, add(solidity_free_mem_ptr, 1), 31)
The first line gets a reference to the “free memory pointer”, which is just the first memory location that can be safely used.
The second line stores in memory the child contract creation bytecode at the free memory pointer slot. In other words, whatever memory address the free memory pointer references, the bytecode
0x00756eb3f879cb30fe243b4dfee438691c043318585733ff6000526016600af3 will get stored there. Notice that its length is 32 bytes, which is perfect because that’s how much a memory slot can store.
The third line creates the child contract using the
CREATE instruction. The
add(solidity_free_mem_ptr, 1) operation will evaluate to
solidity_free_mem_ptr + 1, which represents the pointer to the memory location at which the bytecode is stored plus 1 byte. This essentially means that we are creating a contract with bytecode stored in memory of 31 bytes of length, starting at memory location
free memory pointer + 1.
This turns the original bytecode:
00 at the beginning is missing, which effectively just means some blank bytes got cut off. That’s fine, and it saves us a little on gas.
But, what does this bytecode DO?
Well, two things. It will deploy the destructable contract, AND it will be the destructable contract. The instructions for deployment are a necessary portion of the bytecode usually known as initialization code (initcode for short). Within the initcode is the actual contract bytecode that will get deployed and stored on the blockchain, and dictates how the child contract will operate when we call it. This portion of the bytecode is usually called the runtime code.
Here is the entire, 31-byte initcode from before:
The first instruction is
0x75, which represents a
PUSH22, and pushes 22 bytes to the stack. These 22 bytes are none other than the next 22 bytes of the bytecode. After pushing these 22 bytes onto the stack, our remaining opcodes to evaluate, and the stack are:
The next instruction is
PUSH1, which pushes 1 byte to the stack. This byte will be the next two hex digits in the initcode:
00. Our remaining opcodes and stack now look like this:
TOP: 0x00 | 0x6eb3f879cb30fe243b4dfee438691c043318585733ff
0x52 is next: this is
MSTORE. It will consume our only two stack items, storing the stack item
6eb3f879cb30fe243b4dfee438691c043318585733ff at memory location
But remember that the EVM uses a word size of 32 bytes. So these 22 bytes
6eb3f879cb30fe243b4dfee438691c043318585733ff are actually stored in the first 32 bytes of the EVM memory, which will look like
We still have the following opcodes to execute, and an empty stack:
TOP: (stack is empty)
60 16 and
60 0a instructions are pushing (with
PUSH1) two new items to the stack:
0x0a. The remaining opcode and current stack are:
TOP: 0x0a | 0x16
The final opcode
RETURN, which returns a sequence of bytes from memory, reading the beginning and length of the sequence from the stack. In decimal notation,
RETURN will consume these two elements, returning the “memory starting at slot 10, spanning 22 bytes”. This will end up reading bytes 10 through 31 (inclusive) in memory, which is none other than
6eb3f879cb30fe243b4dfee438691c043318585733ff. Note that
RETURN starts at byte 10 to chop off the first 10 bytes, which are just
0x00 (as explained earlier).
So, this final opcode returns
6eb3f879cb30fe243b4dfee438691c043318585733ff, which is the runtime code that the EVM will store in the newly created account. This is just how contract creation in the EVM works – it returns the contract’s runtime bytecode. So, we can safely assume that this returned data is the bytecode of the child contract.
And now we’ll dig into it.
Child contract bytecode
This section digs into the bytecode that actually is the contract once it’s been deployed. It’s a portion of the bytecode deployed via
create, except it doesn’t include the initcode. Let’s look at this friendly runtime code and make sense of it:
We already know what it’s intended to do: “self-destruct if called by the GST2 token, otherwise throw”.
Upon calling the child contract holding this runtime code, the first instruction that will be executed is
0x6e, which represents
PUSH15. It will push 15 bytes of data to the stack. So it ends up pushing the bytes
0xb3f879cb30fe243b4dfee438691c04. Why push these bytes to the stack? In case you hadn’t noticed, the GST2 contract address is
0x0000000000b3f879cb30fe243b4dfee438691c04, or 5-bytes-of-zeroes-and-
b3f879cb30fe243b4dfee438691c04. So what we just did is storing the hardcoded GST2 token address on the stack.
At this stage, the bytecode left to be executed and stack are:
CALLER, which is akin to
msg.sender in regular Solidity. This will put the caller’s address on the stack. Our remaining bytecode and stack now look like this:
TOP: msg.sender | 0xb3f879cb30fe243b4dfee438691c04
The next opcode to evaluate is
XOR. It will consume two stack elements, performing a bitwise XOR operation between the two. Remember that XOR outputs
1 when the bits differ, and
0 when they are equal. So, if
XOR operation will return all zeroes.
Once XOR is applied, the stack is going to contain just one element.
msg.sender is the GST2 contract, or not zero otherwise. For ease of reference, let’s just call this result
XRES (short for “XOR Result”). This is now the only element on the stack, and we still have some remaining instructions to execute.
The next one is
0x58, which will push the value of the program counter to the stack. The program counter basically tracks the opcode the EVM is looking at at a given time during program execution, starting with 0 for the very first opcode and increasing by 1 for each byte. In other words, it allows the EVM to say “we’re at byte of the contract bytecode now”. But here, this opcode is being used for convenience to serve a different purpose.
PC is one of the cheapest opcodes which pushes a value to the stack. As we’ll see very soon, all we really need is to push a value to the stack here. For now, lets refer to the value that
PC pushes to the stack as
PCRES for “PC result”.
The remaining bytecode and current stack are:
TOP: PCRES | XRES
The next opcode,
0x57, is the
JUMPI instruction, which consumes two items from the stack and changes the program counter, causing execution to jump if the second item on the stack is any non-zero value.
XRES is not zero (when
msg.sender IS NOT the GST2 contract), a jump will occur and cause the program to jump to the instruction at position
PCRES in the contract’s code. And it will cause execution to throw. This is because in the EVM,
JUMPs require valid
JUMPDESTs to exist. As there are no
JUMPDESTs in the bytecode, this will end contract execution, which is the desired behavior. This is what prevents any caller aside from the GST2 contract from destroying child contracts.
On the other hand, if
XRES is zero (when
msg.sender IS the GST2 contract), the jump will not occur. Both elements on the stack will be consumed, and we will continue execution, going now to the last two opcodes,
0x33 (which is
CALLER) will push the caller’s address to the stack, which at this point must be the
GST2 contract. Then,
0xff will trigger a
SELFDESTRUCT, consuming one stack element (the caller’s address stored previously). This will destroy the contract, first sending funds to the specified destination. Consequently, any Ether in balance and gas refund resulting from destruction of the contract is forwarded back to the parent GST2 contract.
That’s it! Isn’t bytecode fun and easy?
What is the difference between GST2 and CHI?
Here’s the bytecode passed into
create2 to deploy child contracts of the CHI contract:
Essentially, the CHI contract deploys a very similar child contract compared to GST2, with the exception that a few opcodes change.
The CHI contract address has one more byte of zeroes compared to the GST2 contract address. This allows the CHI contract’s bytecode to be 1 byte smaller. Therefore, the
PUSH15 instructions from GST2 become
PUSH14 instructions in CHI, since the child bytecode is one byte smaller.
Another small difference is that the zero-bytes in CHI are at the end of the bytestring rather than at the beginning. This doesn’t actually make much of a difference, except that we need to pay attention to byte indices when appropriate. CHI does so, as shown in the call to
create2. Instead of using indices 1-32, like in GST, the
create2 opcode here uses index 0 through 30. The second and third parameters being
30 code for this, effectively saying “use the data in memory, starting at byte 0 and spanning for 30 bytes”.
Although GST2 uses
create and CHI uses
create2, they both share the very important property that the address at which a new contract will be created can be computed within a smart contract. This computation is performed within the CHI when calculating the address at which to destroy child contracts.
Also note that the operations of the initcode for the child contract will differ, as they will pass 1 less byte back as memory. The second-to-last non-zero byte of the bytecode changes from
0x0b, and the fourth-to-last non-zero byte changes from
0x15. This is done to indicate that the
RETURN operation should be returning a value at memory from slot
0x0b and spanning for
0x15 bytes, rather than starting at
0x0a and spanning for
0x16 bytes as in GST2. This difference is what makes the contract return (and therefore deploy) 21 bytes of runtime code, rather than 22.
And, of course, the 15 bytes of contract address in the GST2 contract will be replaced by 14 bytes in the CHI contract, so don’t let that throw you off. It’s just a reference to the token contract address.