Ethereum Transaction Encoding
Understand encoding mechanism in Ethereum
Encoding
The transaction is broadcast to a node on the Ethereum network - who then in turn broadcast it to their peers (this process is detailed later in this article). To ensure that transactions are propagated across the network without failure, forms of encoding are utilised to reduce the sizes of payloads and to prevent common forms of error (especially those caused by faulty or poor network connections). There are two commonly described forms of such encoding used in Ethereum to varying degrees: RLP and SSZ.
RLP
As defined in the Ethereum Yellow Paper, Recursive Length Prefix (RLP) is a serialisation method used to encode “arbitrarily structured binary data”. It was created specifically for Ethereum, to guarantee that two important characteristics were provided by the serialisation method. These characteristics are “simplicity to implement” and “guaranteed absolute byte-perfect consistency”. RLP provides simplicity by only defining data types of bytes and arrays and leaving higher level protocols to define other data types. RLP is deterministic, by having explicit ordering of items within arrays, to ensure that input data will always result in the same encoding, important for the consistency of hashes.
An example of an RLP encoded legacy transaction.
With knowledge of EIP-2718, the first thing that can be inferred from the transaction above is that this is a legacy transaction, as the first byte 0xf8 is not in the range 0x00 to 0xbf and instead is in the range of 0xc0 to 0xfe. This range is reserved to specify legacy transactions, whereas the remaining range is reserved for typed transactions. Referring to the following list, each byte in hexadecimal form can be decoded to assess the purpose it serves and what the message as a whole might mean.
[0x00, 0x7f]
A byte whose value is in this range becomes its own RLP encoding.
Example: A nonce that is equal to or less than 127 (0x7f). In the example transaction, the 0x13 near the beginning decoded is simply 0x13, or 19 in decimal.
[0x80, 0xb7]
A string (array of bytes) between 0 and 55 bytes long is:
0x80 + length(string) || string
Example: The decimal gas price value, 50000000000, RLP encoded.
- 50000000000 in hexadecimal is 0B A4 3B 74 00
- 0x80 + 0x05 = 0x85 || 0ba43b7400
- Therefore the RLP encoding is 0x850ba43b7400
This is the RLP encoding of the gas price seen in the example transaction.
[0xb8, 0xbf]
A string (array of bytes) greater than 55 bytes long is:
0xb7 + length(length(string)) || length(string) || string
Example: A 400 byte long string would have the prefix 0xb90190. It has a length of 190 in hex, which itself is 2 bytes long. 0xb7 + 2 || 0190 || (400 byte long string)
[0xb8, 0xbf]
A string (array of bytes) greater than 55 bytes long is:
0xb7 + length(length(string)) || length(string) || string
Example: A 400 byte long string would have the prefix 0xb90190. It has a length of 190 in hex, which itself is 2 bytes long. 0xb7 + 2 || 0190 || (400 byte long string)
[0xc0, 0xf7]
An array of strings (list) between 0 and 55 bytes long is:
0xc0 + length(RLP(list)) || RLP(list)
Example: An array that is 10 bytes long (a in hex) would have the prefix 0xc0 + a = 0xca
[0xf8, 0xff]
An array of strings (list) greater than 55 bytes long is:
0xf7 + length(length(RLP(list))) || length(RLP(list)) || RLP(list)
Example: Starting to decode the example transaction with this in mind, you can see that 0xf8 - 0xf7 = 0x01 = 1 byte. The next byte 6b is therefore the length of the list, 6b = 107 bytes.
More info: https://ethereum.org/en/developers/docs/data-structures-and-encoding/rlp/
SSZ
In contrast, SSZ is a more recent adaptation of RLP, generally seen by developers across Ethereum as an improvement. In the consensus layer, all data types are serialized into bytes using a standard format called SimpleSeralize (SSZ).
Compared to the former RLP serialisation format used in the execution layer, SSZ provides two important functions for ease of transmission and storage of data: including encoding/decoding and merkelisation. Additionally, it introduces types for different kinds of data structures. Based on years of experience gained from engineering the Ethereum protocol, developers incorporated the following design goals in SSZ to improve its utility and effectiveness:
- Simple: All kinds of commonly used primitive data types can be serialised.
- Bijective: The result of encoding the same value of a single type can only have one representation. Likewise, two different values of different types cannot have the same representation.
- Compact: For the serialisation of types that are fixed or dynamic in length, the resulting size is relatively compact. The merkelisation of data structures is more efficient than a typical Merkle-Patricia Tree due to a lower branching factor (i.e. more than two branches per a node as is typically seen).
- Merkle-first: A value of any type has a sound generalized Merkle-root, enabling the creation of flexible proofs that are small in size and avoid merkelisation complexity.
- Efficient to traverse: By having four-byte offsets, traversal through the fields of the encoded data structure becomes relatively quick.
It can be seen that the simple and bijective properties of SSZ encoding is similar to those of RLP encoding. The primary differences are found to be the way that efficient Merkle Trees are used to store the encoding of any data, meaning significantly less storage is required (at the cost of slightly more computation to be performed).
The SSZ specification defines two kinds of data types, such as basic and composite:
Basic
- Unsigned integers: Integers of bytes, where
- Boolean values: True or false.
Composite
- Container: An ordered heterogeneous collection of values. E.g. a class containing arbitrary number of properties.
- Vector: An ordered fixed-length homogenous collection with values. E.g. an array of elements.
- List: An ordered variable-length homogenous collection limited to values. E.g. a list of elements.
- Bit-vector: An ordered fixed-length collection of boolean values, with bits. E.g. an array of bit values.
- Bitlist: An ordered variable-length collection of boolean values, limited to bits. E.g. a list of bit values.
- Union: Contains one of the given subtypes e.g. Union[none, uint64, uint32].
To highlight the distinction between these types, suppose there is a vector type of an array containing boolean values, and there is a bit-vector type containing values. While these two types are similar, they will have different representations when serialised. The reason is that each type undergoes a different process for serialising into bytes, hence, conforming to the design goal of being bijective.
In the next article, we will learn about how Ethereum uses this encoding in the journey of a transaction. See you there!