State Transition Tests¶
This tutorial teaches you to create a state transition execution specification test. These tests verify that a starting pre-state will reach a specified post-state after executing a single transaction.
Pre-requisites¶
Before proceeding with this tutorial, it is assumed that you have prior knowledge and experience with the following:
- Set up and run an execution specification test as outlined in the quick start guide.
- Understand how to read a static state transition test.
- Know the basics of Yul, which is an EVM assembly language.
- Familiarity with Python.
Example Tests¶
The most effective method of learning how to write tests is to study a couple of straightforward examples. In this tutorial we will go over the Yul state test.
Yul Test¶
You can find the source code for the Yul test in tests/homestead/yul/test_yul_example.py. It is the spec test equivalent of this static test.
Lets examine each section.
"""
Test Yul Source Code Examples
"""
In Python, multi-line strings are denoted using """
. As a convention, a file's purpose is often described in the opening string of the file.
from ethereum_test_forks import Fork, Frontier, Homestead
from ethereum_test_tools import (
Account,
Alloc,
Environment,
StateTestFiller,
Transaction,
YulCompiler,
)
In this snippet the required constants, types and helper functions are imported from ethereum_test_tools
and ethereum_test_forks
. We will go over these as we come across them.
@pytest.mark.valid_from("Homestead")
In Python this kind of definition is called a decorator. It modifies the action of the function after it. In this case, the decorator is a custom pytest fixture defined by the execution-specs-test framework that specifies that the test is valid for the Homestead fork and all forks after it. The framework will then execute this test case for all forks in the fork range specified by the command-line arguments.
Executing the test
To execute this test for all the specified forks, we can specify pytest's -k
flag that filters test cases by keyword expression:
fill -k test_yul
and to execute it for a specific fork range, we can provide the --from
and --until
command-line arguments:
fill -k test_yul --from London --until Paris
def test_yul(state_test: StateTestFiller, pre: Alloc, yul: YulCompiler, fork: Fork):
"""
Test YUL compiled bytecode.
"""
This is the format of a Python function.
It starts with def <function name>(<parameters>):
, and then has indented code for the function.
The function definition ends when there is a line that is no longer indented. As with files, by convention functions start with a string that explains what the function does.
The state_test
function argument
This test defines a state test and, as such, must include the state_test
in its function arguments. This is a callable object (actually a wrapper class to the StateTest
); we will see how it is called later.
The pre
function argument
For all types of tests, it is highly encouraged that we define the pre
allocation as a function argument, which will be populated with the pre-state requirements during the execution of the test function (see below).
env = Environment()
This line specifies that env
is an Environment
object, and that we just use the default parameters.
If necessary we can modify the environment to have different block gas limits, block numbers, etc.
In most tests the defaults are good enough.
For more information, see the static test documentation.
Pre State¶
For every test we need to define the pre-state requirements, so we are certain of what is on the "blockchain" before the test executes.
It can be used as a dictionary, which is the Python term for an associative array, but the appropriate way to populate it is by using the methods fund_eoa
, deploy_contract
or fund_address
from the Alloc
object.
In this example we are using the deploy_contract
method to deploy a contract to some address available in the pre-state.
contract_address = pre.deploy_contract(
code=yul(
"""
{
function f(a, b) -> c {
c := add(a, b)
}
sstore(0, f(1, 2))
return(0, 32)
}
"""
),
balance=0x0BA1A9CE0BA1A9CE,
)
Specifically we deploy a contract with yul code that adds two numbers and stores the result in storage.
balance=0x0BA1A9CE0BA1A9CE,
This field is the balance: the amount of Wei that the account has. It usually doesn't matter what its value is in the case of state test contracts.
contract_address = pre.deploy_contract(
As return value of the deploy_contract
method we get the address where the contract was deployed and put it in the contract_address
variable, which will later be used in the transaction.
storage={
0x00: 0x00,
},
We could also specify a starting storage for the contract, which is done by adding a storage
parameter to the deploy_contract
method.
code=yul(
Here we define the Yul code for the contract. It is defined as a multi-line string and starts and ends with curly braces ({ <yul> }
).
When running the test filler fill
, the solidity compiler solc
will automatically translate the Yul to EVM opcode at runtime.
Note
Currently Yul and direct EVM opcode are supported in execution spec tests.
"""
{
function f(a, b) -> c {
c := add(a, b)
}
sstore(0, f(1, 2))
return(0, 32)
}
"""
Within this example test Yul code we have a function definition, and inside it we are using the Yul add
instruction. When compiled with solc
it translates the instruction directly to the ADD
opcode. For further Yul instructions see here. Notice that function is utilized with the Yul sstore
instruction, which stores the result of add(1, 2)
to the storage address 0x00
.
Generally for execution spec tests the sstore
instruction acts as a high-level assertion method to check pre to post-state changes. The test filler achieves this by verifying that the correct value is held within post-state storage, hence we can validate that the Yul code has run successfully.
sender = pre.fund_eoa(amount=0x0BA1A9CE0BA1A9CE)
In this line we specify that we require a single externally owned account (EOA) with a balance of 0x0BA1A9CE0BA1A9CE
Wei.
The returned object, which includes a private key, an address, and a nonce, is stored in the sender
variable and will later be used as the sender of the transaction.
Transactions¶
tx = Transaction(
ty=0x0,
chain_id=0x01,
sender=sender,
to=contract_address,
gas_limit=500000,
gas_price=10,
protected=False if fork in [Frontier, Homestead] else True,
)
With the pre-state built, we can add a description for the Transaction
.
sender=sender,
We use the sender variable from the pre-state to specify the sender of the transaction, which already has the necessary information to sign the transaction, and also contains the correct nonce
for the transaction.
The nonce
is a protection mechanism to prevent replay attacks, and the current rules of Ethereum require that the nonce of a transaction is equal to the number of transactions sent from the sender's address, starting from zero. This means that the first transaction sent from an address must have a nonce of zero, the second transaction must have a nonce of one, and so on.
The nonce
field of the sender
variable is automatically incremented for us by the Transaction
object when the transaction is signed, so if we were to create another transaction with the same sender, the nonce would be incremented by one yet another time.
to=contract_address,
The to
field specifies the address of the contract we want to call and, in this case, it is the address of the contract we deployed earlier.
For more information, see the static test documentation
Post State¶
post = {
contract_address: Account(
storage={
0x00: 0x03,
},
),
}
This is the post-state which is equivalent to expect
in static tests, but without the indexes. It is similar to the pre-state, except that we do not need to specify everything, only those accounts and fields we wish to test.
In this case, we look at the storage of the contract we called and add to it what we expect to see. In this example storage cell 0x00
should be 0x03
as in the pre-state we essentially stored the result of the Yul instruction add(1, 2)
.
State Test¶
state_test(env=env, pre=pre, post=post, tx=tx)
This line calls the wrapper to the StateTest
object that provides all the objects required (for example, the fork parameter) in order to fill the test, generate the test fixtures and write them to file (by default, ./fixtures/<blockchain,state>_tests/example/yul_example/test_yul.json
).
Conclusion¶
At this point you should be able to state transition tests within a single block.