DEV Community

Cover image for A Deep Dive into EVM Transactions: Sending, Verifying, and Automating with Python and Web3.py
OnlineProxy
OnlineProxy

Posted on

A Deep Dive into EVM Transactions: Sending, Verifying, and Automating with Python and Web3.py

You've already learned how to read data from the blockchain. You can query a wallet balance, find a token's decimals, or even call view functions on smart contracts. You've become a skilled observer. But at some point, you inevitably hit a wall. How do you move from passive reading to active doing? How do you send a token, mint an NFT, or execute a swap?

This transition—the leap from observer to participant—is intimidating for many. Suddenly, new concepts appear: private keys, gas, nonce, and transaction signing. The simple "Send" button in MetaMask hides a complex and precise mechanism. To understand and replicate this mechanism in code is to gain true power over automation in Web3.

This article is your guide to the world of write transactions. We won't just look at code. We'll dissect every detail, build a robust and scalable tool for interacting with EVM networks, and turn a complex sequence of actions into an elegant and manageable process. You will learn how to go from scattered scripts to creating your own framework, your very own reliable "software MetaMask."

What's Needed to Not Just Read, But Change the Blockchain?

Calling read functions requires nothing more than a connection to a node (RPC). The blockchain generously shares its state with everyone. But as soon as you want to change that state—to send a transaction that does something—you need to prove you have the right to do so. This right is granted by ownership of a private key.

This is precisely why any write operation in Etherscan or BscScan is inactive until you connect a wallet. A transaction isn't sent by an anonymous observer but from a specific address, and it's signed by the corresponding private key.

In web3.py, this "address + private key" pair is represented by the LocalAccount object. Creating it is the first and most crucial step.

from web3 import Web3
from eth_account.signers.local import LocalAccount # Essential for type hinting
import config # A file where your private key is stored

# 1. Connect to the network
rpc_url = "https://eth.llamarpc.com"
w3 = Web3(Web3.HTTPProvider(rpc_url))

# 2. Retrieve your private key (never hardcode it!)
private_key = config.PRIVATE_KEY

# 3. Create the account object
# The type hint `account: LocalAccount` is critical for IDE support
account: LocalAccount = w3.eth.account.from_key(private_key)

# 4. Get the address
address = account.address
print(f"Wallet address: {address}")
Enter fullscreen mode Exit fullscreen mode

Notice the type hint : LocalAccount. Without it, your development environment (like PyCharm) won't know what attributes and methods the account object has. To figure out where to get this type from, simply look at the source code of web3.py (Ctrl+Click on from_key). You'll see that the method returns exactly LocalAccount, and you'll find the import path right there. This small trick will save you hours of debugging and documentation searching.

The Constructor for Interaction: Creating the Client Class

Working with scattered variables (w3, account, private_key) is fine for a simple script. But what if you need to work with ten or a hundred accounts? And what if they operate on different networks (Ethereum, Polygon, Arbitrum)? Copy-pasting code for each account is a direct path to chaos.

Object-Oriented Programming (OOP) principles come to the rescue. We can encapsulate all the logic related to a single wallet into one class. Let's call it Client.

Each instance of Client will represent one wallet operating on a specific network.

from web3 import Web3
from web3.contract.async_contract import AsyncContract
from eth_account.signers.local import LocalAccount
from eth_typing import ChecksumAddress
from hexbytes import HexBytes

class Client:
    # Type hints for class attributes
    private_key: str
    rpc: str
    w3: Web3
    account: LocalAccount

    def __init__(self, private_key: str, rpc: str):
        self.private_key = private_key
        self.rpc = rpc
        self.w3 = Web3(Web3.HTTPProvider(rpc))
        self.account: LocalAccount = self.w3.eth.account.from_key(self.private_key)

    # ... methods for sending and verifying transactions will go here
Enter fullscreen mode Exit fullscreen mode

Now, instead of a set of variables, we have a single, understandable object.

# Old approach
# w3 = Web3(...)
# account = w3.eth.account.from_key(...)
# address = account.address

# New approach with the class
client = Client(private_key=config.PRIVATE_KEY, rpc="https://eth.llamarpc.com")
address = client.account.address
print(f"Address from Client object: {address}")
Enter fullscreen mode Exit fullscreen mode

This isn't just syntactic sugar. It's a fundamental shift in approach that allows for building complex, multi-component systems where each Client is an autonomous and independent agent.

Anatomy of a Transaction: The Key Parameters of Your Action

Before we write the method for sending a transaction, let's break it down into its components. Every write transaction is essentially a dictionary with a set of keys that we send to the network.

  1. from: The sender's address. Our Client class will automatically fill this with self.account.address.
  2. to: The recipient's address. This could be another wallet or, more commonly in automation, a smart contract address.
  3. value: The amount of the network's native currency (ETH, MATIC, BNB) you are sending with the transaction. Critically important point: if you're swapping the USDT token for ETH, the value will be 0, as you are not sending the native currency. If, however, you're swapping ETH for USDT, the value will be the amount of ETH you're exchanging. For token approve transactions, value is always 0.
  4. nonce: The transaction's sequence number for your account. It's a counter that prevents transaction replay attacks and determines their execution order. If the last transaction had a nonce of 10, the next one must have a nonce of 11. It can be retrieved using w3.eth.get_transaction_count(self.account.address).
  5. chainId: The network identifier (1 for Ethereum, 56 for BSC, etc.). This prevents a transaction from being replayed on another network. It is retrieved via w3.eth.chain_id.
  6. gas: The maximum number of "computational units" you are willing to spend to execute the transaction. If the transaction requires more gas, it will fail. If it requires less, the unused gas is returned. It's calculated using w3.eth.estimate_gas(tx_params).
  7. gasPrice (for Legacy transactions) or maxFeePerGas / maxPriorityFeePerGas (for EIP-1559): The price you are willing to pay for each unit of gas. This determines how quickly miners/validators will include your transaction in a block.
  8. data: The most interesting part. This is the encoded data for a smart contract function call. If you are simply transferring ETH, this field can be empty. But if you are calling approve(spender, amount), the data field will contain the signature of the approve function and the encoded values of spender and amount.

Now that we know all the ingredients, we can assemble them into a unified method within our Client class.

Step-by-Step Workflow: From Idea to Block Confirmation

Let's implement two key methods in our Client class: send_transaction for sending and verify_tx for waiting and checking the result.

Step 1: Building and Sending the Transaction (send_transaction)
This method will take the essential parameters (to, data, value) and automatically calculate everything else.

# Inside the Client class

async def send_transaction(
    self,
    to: ChecksumAddress,
    data: str | None = None,
    value: int | None = 0
) -> HexBytes:
    # 1. Assemble the base dictionary of parameters
    tx_params = {
        'from': self.account.address,
        'to': to,
        'value': value,
        'nonce': await self.w3.eth.get_transaction_count(self.account.address),
        'chainId': await self.w3.eth.chain_id,
        'gasPrice': await self.w3.eth.gas_price # Example for a Legacy transaction
    }

    if data:
        tx_params['data'] = data

    # 2. Estimate gas
    try:
        gas_estimate = await self.w3.eth.estimate_gas(tx_params)
        tx_params['gas'] = int(gas_estimate * 1.2) # Add a 20% buffer for reliability
    except Exception as e:
        print(f"Gas estimation error: {e}")
        raise

    # 3. Sign the transaction
    signed_tx = self.w3.eth.account.sign_transaction(tx_params, self.private_key)

    # 4. Send it to the network
    tx_hash = await self.w3.eth.send_raw_transaction(signed_tx.rawTransaction)

    return tx_hash
Enter fullscreen mode Exit fullscreen mode

Step 2: Verifying the Transaction (verify_tx)
Sending a transaction is only half the battle. It lands in the mempool and waits its turn. We need a way to wait for its inclusion in a block and check its status (success or failed).

# Inside the Client class

async def verify_tx(self, tx_hash: HexBytes, timeout: int = 200) -> str:
    try:
        receipt = await self.w3.eth.wait_for_transaction_receipt(tx_hash, timeout)

        if receipt['status'] == 1:
            # Transaction was successful
            return tx_hash.hex()
        else:
            # Transaction failed
            raise Exception(f"Transaction {tx_hash.hex()} failed.")

    except Exception as e:
        print(f"Error during transaction verification: {e}")
        raise
Enter fullscreen mode Exit fullscreen mode

The wait_for_transaction_receipt method "freezes" the code's execution until the transaction is processed (or until the timeout expires). We then check the status field in the resulting receipt: 1 means success, 0 means failure.

The Evolution of Gas Price: Legacy vs. EIP-1559

Until recently, all EVM networks used a simple gasPrice model. You specified one price, and that was your bid in the auction for block space.

Modern networks (including Ethereum after The Merge and many L2 solutions) have transitioned to the EIP-1559 standard. It splits the gas fee into two parts:

  • baseFeePerGas: A base fee that gets burned. It's determined by network congestion.

  • maxPriorityFeePerGas: A "tip" for the validator to include your transaction faster.

In web3.py, this is reflected by replacing the single gasPrice key with two new ones:

  • maxFeePerGas: The maximum total fee you're willing to pay (baseFee + priorityFee).

  • maxPriorityFeePerGas: Your "tip."

Let's improve our send_transaction method to add support for EIP-1559:

# Improved send_transaction method inside Client
# (showing only the fee selection logic)

async def send_transaction(self, ..., is_eip1559: bool = True):
    # ... assemble base tx_params (without gasPrice) ...

    if is_eip1559:
        # Logic for EIP-1559
        last_block = await self.w3.eth.get_block('latest')
        base_fee = last_block['baseFeePerGas']
        max_priority_fee = await self.w3.eth.max_priority_fee

        tx_params['maxPriorityFeePerGas'] = max_priority_fee
        tx_params['maxFeePerGas'] = base_fee + max_priority_fee
    else:
        # Logic for Legacy
        tx_params['gasPrice'] = await self.w3.eth.gas_price

    # ... gas estimation, signing, and sending ...
Enter fullscreen mode Exit fullscreen mode

This flexibility allows your Client to work seamlessly with both older networks like BSC and modern ones like Ethereum or Polygon.

Practical Application: Sending an approve Transaction

Theory is important, but let's apply our knowledge in practice. The most common operation before a swap is an approve. You give permission (an approval) to a decentralized exchange's contract to spend a certain amount of your tokens.

To do this, we need to call the approve(address spender, uint256 amount) function on the token's contract. How do we turn this call into the data field for our transaction? With the encode_abi method.

import json

# ... your main code ...

async def main():
    # 1. Setup
    USDT_ADDRESS = "0x..." # Address of USDT on the desired network
    UNISWAP_ROUTER_ADDRESS = "0x..." # Address of the Uniswap router

    with open('token_abi.json') as f: # A minimal ABI with only the functions you need
        TOKEN_ABI = json.load(f)

    # 2. Create the client and contract
    client = Client(private_key=config.PRIVATE_KEY, rpc="...")
    token_contract = client.w3.eth.contract(address=USDT_ADDRESS, abi=TOKEN_ABI)

    # 3. Prepare data for the function call
    # Amount to approve (in "atomic" units, considering decimals)
    amount_to_approve = 1000 * (10**6) # 1000 USDT if decimals=6

    # This is where the encoding magic happens!
    tx_data = token_contract.encode_abi(
        fn_name="approve",
        args=[
            UNISWAP_ROUTER_ADDRESS,
            amount_to_approve
        ]
    )

    print(f"Encoded data (the 'data' field): {tx_data}")

    # 4. Send and verify the transaction
    try:
        print("Sending approve transaction...")
        tx_hash_bytes = await client.send_transaction(
            to=USDT_ADDRESS, # The transaction is sent to the token contract!
            data=tx_data,
            value=0 # Value = 0, as we are not sending native currency
        )

        final_tx_hash = await client.verify_tx(tx_hash_bytes)
        print(f"Transaction successfully confirmed! Hash: {final_tx_hash}")

    except Exception as e:
        print(f"An error occurred: {e}")

# ... run main() ...
Enter fullscreen mode Exit fullscreen mode

The encode_abi method takes the contract's ABI, finds the approve function, and encodes its name along with the provided arguments into a hexadecimal string. This string is exactly what we put into the data field of our transaction. When the blockchain receives a transaction to the USDT_ADDRESS with this data, it understands that it needs to execute the approve function.

Final Thoughts

We've journeyed from a simple script to creating a robust, object-oriented Client for EVM interactions. Now you don't just know what nonce or gas are—you understand their role and know how to manage them in code.

Key Takeaways:

  1. Encapsulation is the key to scalability. The Client class transforms a chaos of variables into an organized and reusable structure.
  2. A transaction is a dictionary. Understanding each key (to, value, data, etc.) gives you complete control over your actions on the blockchain.
  3. encode_abi is your translator. This method is the bridge between a human-readable function call (approve(...)) and the machine code that the EVM understands.
  4. Sending and verifying are two inseparable steps. Simply sending a transaction isn't enough. You must always wait for its confirmation and check its status.

You now possess not just a set of commands, but a complete framework for building bots, automating DeFi strategies, batch-minting NFTs, and much more. You are no longer a bystander; you are an architect, capable of building and acting in the world of decentralized applications. What will be the first program you build that doesn't just read from, but changes, the blockchain?

Top comments (0)