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.
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.
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.)
(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:
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:
And check the balance:
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:
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:
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):
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
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:
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):
And the definition of all the opcodes:
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:
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:
Replacing the opcodes with the HEX values, we should get the real app:
Lets' try to execute it:
Oh, the status code is 1. It was executed. Let's check what's happened:
Stack top is on right.
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:
Looks like the real app, the only thing what we need is concatenate the lines and replaces the opcodes with the hex representation:
Let's execute it:
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:
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
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).
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:
Let's deploy this one:
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:
The return value is 3: Our first smart contract up and running!!!
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.
This post is heavily inspired by similar approaches by others: