Web3.py Patterns: Decoding Signed Transactions

There's a small set of use cases where you might need to decode a signed transaction which has not yet been included in a block. For example, MEV protocols work with bundles of signed transactions separate from the main transaction pool. If that series of words means nothing to you, there's a reasonably good chance that you don't need the contents of this blog post and are instead interested in fetching mined transaction data. So, let's start there.

Fetching Mined Transactions

If you're interested in fetching transaction data from the Ethereum blockchain, a straightforward API exists for that. Note that these are transactions that have been broadcast to the network and already successfully mined into a block.

from web3 import Web3, HTTPProvider

w3 = Web3(HTTPProvider('...'))

w3.eth.get_transaction('0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060')
# AttributeDict({
#   'hash': HexBytes('0x5c504...'), 
#   'blockHash': HexBytes('0x4e3a3...'), 
#   'blockNumber': 46147, 
#   'from': '0xA1E43...', 
#   'gas': 21000, 
#   'gasPrice': 50000000000000, 
#   'input': '0x', 
#   'nonce': 0, 
#   'r': HexBytes('0x88ff6...'),
#   's': HexBytes('0x45e0a...'),
#   'to': '0x5DF9B...', 
#   'transactionIndex': 0, 
#   'type': '0x0', 
#   'v': 28, 
#   'value': 31337
# })
The first-ever transaction on Ethereum Mainnet?

Decoding Signed Transactions

At the time of writing, a dedicated API does not exist for decoding unmined signed transactions in Web3.py, but the functionality can be built from utilities found in the py-evm and eth-utils libraries.

Under the hood, the logic for decoding transactions now needs to account for "typed transactions," which were introduced to Ethereum in the Berlin network upgrade. Legacy transactions continue to be supported after the upgrades, but typed transactions have a dedicated range of values for the first byte of the transaction hash. EIP-1559 transactions, for example, have transaction type of 0x02, followed by the rlp-encoded transaction body: 0x02 || rlp([chain_id, nonce, amount, data, ...]).

By definition, each typed transaction has a unique data payload that must be encoded and decoded. These mappings are collectively known as sedes, short for serialization/deserialization. Luckily for us, py-evm hides these implementation details within a TransactionBuilder class.

Putting it all together – the code below converts the transaction hash to bytes, then decodes the payload with the latest TransactionBuilder from py-evm.

from eth.vm.forks.arrow_glacier.transactions import ArrowGlacierTransactionBuilder as TransactionBuilder
from eth_utils import (
  encode_hex,
  to_bytes,
)

# 1) the signed transaction to decode:
original_hexstr = '0x02f86b010...'

# 2) convert the hex string to bytes:
signed_tx_as_bytes = to_bytes(hexstr=original_hexstr)

# 3) deserialize the transaction using the latest transaction builder:
decoded_tx = TransactionBuilder().decode(signed_tx_as_bytes)
print(decoded_tx.__dict__)
# {'type_id': 2, '_inner': DynamicFeeTransaction(chain_id=1, nonce=4, max_priority_fee_per_gas=2500000000, max_fee_per_gas=118977454018, gas=45000, to=b'\xe9\xcb\...', value=0, data=b'', access_list=(), y_parity=1, r=23532..., s=28205...)}

# 4) the (human-readable) sender's address:
sender = encode_hex(decoded_tx.sender)
print(sender)
# 0xe9cb1f...