Writing Ethereum Contracts the Hard Way - Part 1

July 28, 2022

Abstractions make our life easier, but they also hide the details, i.e., the real structure of the underlying layers. When we take the hard-way, we try to use as few abstractions as possible to create smart-contracts on any Ethereum compatible chain. First, it may look harder, but we do it with the hope that the simplicity of the basic layers may help to make the rules of the smart contract clear and behaviors of its users better, and -- finally -- help to understand the abstractions as well.

In this post, we will write smart contracts without any higher-level language (like solidity) and will see by the end if this way is really hard or can improve our understanding of EVM based smart contracts.

The environment

First, we need a blockchain. While any testnet can be used (with enough balance to cover gas fee) a simple local chain can be easier to use without possible limitations. The following repository provides a docker-compose file for running a geth Ethereum node with developer settings.

git clone https://github.com/elek/localgeth.git
cd localgeth 
docker-compose up -d

We also need something to call the Ethereum JSON-RPC API. We can use something very low-level (like curl or httpie), but even with those we need to generated keys and signatures with the help of some tools

In this experiment we are more interested in the low-level details of the contracts, not the transaction sending; therefore we will use a small CLI application which is a thin wrapper to call RPC calls. (Next time we can explore the hard-way of creating raw transactions.)

go install github.com/elek/cethacea@latest

(This requires Go to be already installed, but binaries can also be downloaded from the Github releases page of the repository)

Let's generate some accounts for submitting/signing new transactions:

ceth account generate
ceth account generate
ceth account list

 key1 0x6fa89198E8b58f2715e691BE8CE70Da4EdbB4A7C
 key2 0x98a76BBeE2839082e5BC965Bf182Acf1E88CCbaE

Please note that private keys are saved to .accounts.yml in unencrypted form. But it should be fine for now.

We also need some balance for covering gas fees but as we have an own chain, it's easy to get. jJust execute faucet.sh with the address of key:

./faucet.sh 0x6fa89198E8b58f2715e691BE8CE70Da4EdbB4A7C

And check the balance:

export CETH_CHAIN=http://localhost:8545

cethacea balance --account=key1
100

If this command returns with a value, we are ready to create some smart contract which should execute code on the blockchain itself.

First steps: transactions

The first task is executing some code on blockchain.

We can start with checking the available methods on Ethereum JSON-RPC API to find something convenient to execute code. Unfortunately there are no such methods for managing contracts, so maybe we start with eth_sendTransaction/eth_sendRawTransaction which should be generic enough to communicate with the blockchain.

Let's start with it:

cethacea tx submit --debug --account=key1 --to=0x98a76BBeE2839082e5BC965Bf182Acf1E88CCbaE --value 1

{"level":"debug","from":"6fa89198e8b58f2715e691be8ce70da4edbb4a7c","to":"0x98a76BBeE2839082e5BC965Bf182Acf1E88CCbaE","value":"1","data":"","time":"2022-04-01T12:26:12+02:00","message":"eth_sendRawTransaction"}
hash:       0x2e7d4a30c773a6bc42d055214f40731823627078c802b1f840fb93131f943fe9
to:         0x98a76BBeE2839082e5BC965Bf182Acf1E88CCbaE
value:      1
gas:        8000000
gasFeeCap:  1341244268
gasTipCap:  1
type:       2
nonce:      2
data:       
status:     1
block:      0x02f3532af4adf98dfc719fe7176fe7dc7d0d6841c084f47afb9bfc285ecd5139
contract:   0x0000000000000000000000000000000000000000
gasUsed:    21000
cumulative: 21000

The debug line shows that this command just creates a serialized transaction object and sends it to the chain using eth_sendRawTransactioncall.

The following lines contain the response. It appears to be a transaction. Status is 1 which is promising (let's assume success), and we have all the details of the transaction.

Checking the balance of the target wallet:

cethacea balance --account=0x98a76BBeE2839082e5BC965Bf182Acf1E88CCbaE
0.000000000000000001

So far so good, we have a transaction on-chain. We transferred a very small amount of money (here the 100 transferred Wei printed in ETH) . Unfortunately no code is executed; the contract line has zero address.

So let's do the something with the --data parameter which is supposed to have some binary data – hopefully executable code.

Because it's HEX we can choose any hex for now, so let's start with the magic bytes of Java classes: 0xCAFEBABE. Java runs everywhere, maybe it makes us lucky (and it's a nice HEX anyway):

cethacea tx submit --debug --account=key1 --to=0x98a76BBeE2839082e5BC965Bf182Acf1E88CCbaE --value 1 --data=0xCAFEBABE

{"level":"debug","from":"6fa89198e8b58f2715e691be8ce70da4edbb4a7c","to":"0x98a76BBeE2839082e5BC965Bf182Acf1E88CCbaE","value":"1","data":"cafebabe","time":"2022-04-01T12:35:19+02:00","message":"eth_sendRawTransaction"}
hash:       0xe9a8d14e641a92416265c8094f6e847d1ef580e1edba197ab04c291ac3b047d8
to:         0x98a76BBeE2839082e5BC965Bf182Acf1E88CCbaE
value:      1
gas:        8000000
gasFeeCap:  1174202840
gasTipCap:  1
type:       2
nonce:      3
data:       cafebabe
status:     1
block:      0xb42817c6848a678d946a52b5679d54bf04e5fce912267e7b35d15fe0be6c28b0
contract:   0x0000000000000000000000000000000000000000
gasUsed:    21064
cumulative: 21064

Ok, nothing really changed here, we have the data in the transaction, but the contract address is still zero. Maybe we should send it to the gods of the blockchain (--to=null) instead of a specific address. Let's try it out with removing the --to parameter

cethacea tx submit --debug --account=key1 --value 1 --data=0xCAFEBABE 

{"level":"debug","from":"6fa89198e8b58f2715e691be8ce70da4edbb4a7c","to":"","value":"1","data":"cafebabe","time":"2022-04-01T12:37:40+02:00","message":"eth_sendRawTransaction"}
hash:       0x7fa454dc9ab13edb3444a481b6ac5261cb4ec1bfedc2f136ccec5147bb821823
to:         
value:      1
gas:        8000000
gasFeeCap:  1027965634
gasTipCap:  1
type:       2
nonce:      4
data:       cafebabe
status:     0
block:      0x884f972961854aa4cd5add132fe610e92c8a0ee288d307d8ad93b2b99f89505f
contract:   0x7D7c77d6467AC87b32043AfCC7B26AcB3C2D2E78
gasUsed:    8000000
cumulative: 8000000

Finally, something happened: we have a contract address filled in, which suggests that our code is executed. Unfortunately the status is zero so we may have some errors in the input.

Just write some code

As we run a local geth node we can get more information about the code executions with a specific debug_traceCall. This is not an official Ethereum API. It's just provided by the node what we run (geth) and luckily, the docker-compose based node already turned it on:

cethacea tx debug 0x7fa454dc9ab13edb3444a481b6ac5261cb4ec1bfedc2f136ccec5147bb821823
Failed:    true
Gas:       8e+06
Ret:       

Stack top is on right.

0     opcode 0xca not defined 0  1  []

Oops, it's definitely failed, and it seems we have no opcodes for 0xca , the beginning of our 0xCAFEBABE data. Time to check the defined opcodes. One list is available on https://ethervm.io/:

We can see the available opcode values (and 0xCA is really missing):

image-20220401124327105

And the definition of all the opcodes:

image-20220401124407121

Note that they are very basic. Contracts are supposed to be executed on each node of the Ethereum network, and the output should be deterministic. Therefore we couldn't access any resources outside of the chain. No network, no filesystem, no kernel parameters, just accessing the internal state of the chain / contract.

Let's try to write something which is valid and just RETURNs with zero:

image-20220401124637260

Ethereum VM is a stack based virtual machine, which means that we have a dedicated stack (in addition to the internal, ephemeral memory), and we can push values to the stack and use the values from the top of the stack.

The RETURN requires two parameters:

  • the offset of the return value in the ephemeral memory (supposed to be on the top of the stack)
  • The length of the return value (size of the memory block from offset)

By default the full memory contains just zeros, so let's try to return with 32 bytes of 0 (it's just a zero but a very big zero). We need to push the offset and length to the stack (PUSH1 ops-code should do this) and execute RETURN:

image-20220401125321533

Replacing the opcodes with the HEX values, we should get the real app:

image-20220401125350211

Lets' try to execute it:

cethacea tx submit --debug --account=key1 --value 1 --data=0x600d6000F3             
{"level":"debug","from":"6fa89198e8b58f2715e691be8ce70da4edbb4a7c","to":"
nil","value":"1","data":"600d6000f3","time":"2022-04-01T12:54:17+02:00","message":"eth_sendRawTransaction"}
hash:       0x01f4930b8dd03c53090dfce778a2712c6bbdd681f77954bca54ad4e07de0b18a
to:         nil
value:      1
gas:        8000000
gasFeeCap:  899942954
gasTipCap:  1
type:       2
nonce:      5
data:       600d6000f3
status:     1
block:      0x822ec996ea330b302ef6481752fa1ba60b8ac21f40a67acbaa4bfd3be16506af
contract:   0x465C523A55e29c6667ca6318FBD8266dd8C17CAc
gasUsed:    55677
cumulative: 55677

Oh, the status code is 1. It was executed. Let's check what's happened:

cethacea tx debug 0x01f4930b8dd03c53090dfce778a2712c6bbdd681f77954bca54ad4e07de0b18a
Failed:    false
Gas:       55677
Ret:       00000000000000000000000000

Stack top is on right.

0     PUSH1          3  1  []
2     PUSH1          3  1  [0xd]
4     RETURN         3  1  [0xd 0x0]
                           0000000000000000000000000000000000000000000000000000000000000000

And yes, this is what we expected. Ret is the return value, a big zero. We can also see the stack content before the execution. Before executing the second PUSH1 the stack has 0xd which is added by the PUSH1of the first line.

And our code is just executed on the blockchain. Everything is in order.

'Add' more logic

Independent of how big is our zero it's just a very simple program without any logic. Let's try to create some more a sophisticated app with real logic. Let's add two numbers. We need to use two new opcodes:

  • ADD: consumes the two values from the stack (which supposed to be added earlier) and put the results back to the stack instead
  • MSTORE: saves values from the stack to the memory (as RETURN requires data on memory)

Our program will look like something like this:

PUSH1 0x01
PUSH1 0x02

;this replaces the two values (1,2) with the result (3)
ADD

; the value is already on the stack we need the offset where it should be saved
PUSH1 0x00
MSTORE

; we have the result from 0x00 in the memory and it's 32 bytes (size of one element on the stack)
PUSH1 0x20
PUSH1 0x00
RETURN

Looks like the real app, the only thing what we need is concatenate the lines and replaces the opcodes with the hex representation:

6001 6002 01 6000 52 6020 6000 f3

Let's execute it:

cethacea tx submit --account=key1 --data=600160020160005260206000f3        

hash:       0x47d1d7fac1ea4527114d791a1e0364381c396e23793e7bb596d1c0edd1597b54
to:         
value:      0
...
data:       600160020160005260206000f3
status:     1
block:      0x70f6768f27a69add4fbf7629502a8ac617348ffcd4c522130ce56159faacf1a8
contract:   0x21d8eAB0E139B4FE7EfcBee1738aA7053bE59fbB

ceth tx debug 0x47d1d7fac1ea4527114d791a1e0364381c396e23793e7bb596d1c0edd1597b54

Failed:    false
Gas:       59608
Ret:       0000000000000000000000000000000000000000000000000000000000000003

Stack top is on right

0     PUSH1          3  1  []
2     PUSH1          3  1  [0x1]
4     ADD            3  1  [0x1 0x2]
5     PUSH1          3  1  [0x3]
7     MSTORE         6  1  [0x3 0x0]
                           0000000000000000000000000000000000000000000000000000000000000000
8     PUSH1          3  1  []
                           0000000000000000000000000000000000000000000000000000000000000003
10    PUSH1          3  1  [0x20]
                           0000000000000000000000000000000000000000000000000000000000000003
12    RETURN         0  1  [0x20 0x0]
                           0000000000000000000000000000000000000000000000000000000000000003

It's executed, and the return value is 3 (good to know). And tx debug showed all the internal state.

(Note: we don't send --value with the transaction any more as it seems to be working without any bribe...)

Where is my contract?

Now we are very good in executing byte code on chain, but where is our contract?

In the previous example, a contract address is printed out. Let's check it.

Ethereum RPC-API contains a method eth_getCodeand cethacea has a single wrapper for it:

cethacea contract code --contract=0x21d8eAB0E139B4FE7EfcBee1738aA7053bE59fbB
0000000000000000000000000000000000000000000000000000000000000003

Interesting. We created a program which returned with 0x00.....03 and Ethereum created a contract where the code is 0x00.....03. Looks we need a program which returns the code of another program.

Let's write a new code which returns the code of the previous one (600160020160005260206000f3)

We are lucky, because it's short enough, and we can push it directly to the stack. It's just 13 bytes and we have PUSH13


PUSH13 600160020160005260206000f3
PUSH1 0x00
MSTORE
PUSH1 0x0D
#return without leading zeros
PUSH1 0x013
RETURN

The last PUSH1 is somewhat tricky: when we push 13 bytes to the stack it will be stored in a 32 bytes slot (=the size of a stack elements). And the full 32 bytes will be copied to the address 0x00. To ignore the first 32 - 13 = 19 bytes we should return only 13 (0x0d) bytes from offset 19 (0x13).

image-20220401133228468

The previous assembly can be converted to data by replacing the instruction with the hex representation. Because hard way should not be boring, this repetitive task can also be done with cethacea:

cethacea evm compile deploy.asm
6c600160020160005260206000f3600052600d6013f3

Let's deploy this one:

cethacea tx submit --account=key1 --data=6c600160020160005260206000f3600052600d6013f3

hash:       0xdf21c1def77367d4a4acca1ff30380a205aa136de8631e0986670a43a74fcc50
to:         nil
value:      0
...
data:       6c600160020160005260206000f3600052600d6013f3
status:     1
block:      0x2b70baa117f176a21f23cc2d5b00f04dc6ed178fa6b85d3df250f8ab4a91c373
contract:   0x9cAB1292FbE55dcefd4582Acb209001651535cCf


ceth contract code --contract=0x9cAB1292FbE55dcefd4582Acb209001651535cCf
600160020160005260206000f3

So far, so good. But can we execute the code of the contract itself? Let's do it with sending some data to the contract:

cethacea tx submit --account=key1 --to=0x9cAB1292FbE55dcefd4582Acb209001651535cCf --data=0x00
hash:       0x9f863f6a651f4331291817d3740fa5754081be3e3b815ae6f79a9f51ab020775
to:         0x9cAB1292FbE55dcefd4582Acb209001651535cCf
...
data:       00
status:     1
block:      0x5a4ddc15fb990468633686ce51ff3e6438988516c69a1532214a9926bc2d094c
contract:   0x0000000000000000000000000000000000000000

ceth tx debug 0x9f863f6a651f4331291817d3740fa5754081be3e3b815ae6f79a9f51ab020775
Failed:    false
Gas:       21028
Ret:       0000000000000000000000000000000000000000000000000000000000000003

Stack top is on right

0     PUSH1          3  1  []
2     PUSH1          3  1  [0x1]
...

The return value is 3: Our first smart contract up and running!!!

Conclusion

To sum up, in this blog, we saw how to write smart contracts without abstractions and any higher-level language in order to improve our understanding of the intentions of the smart contract.

In the next part we will further improve our contract to accept parameters and store data on the chain.

Credits:

This post is heavily inspired by similar approaches by others:

Share this blog post

Build on the
decentralized cloud.

Kickstart your next project and grow your revenue with this high-converting, beautifully crafted template.
Start for free