DEV Community

Cover image for A Senior Engineer's Guide to Sending EVM Transactions in Python
OnlineProxy
OnlineProxy

Posted on

A Senior Engineer's Guide to Sending EVM Transactions in Python

You’ve mastered the art of reading from the blockchain. With a few lines of Python and web3.py, you can query balances, check token supplies, and call any view or pure function on a smart contract. You’re an observer, a data scientist of the decentralized world. But eventually, the itch comes: the desire to change the state, to participate, to write.

This is where the game changes. Moving from a passive call() to an active transact() is like graduating from reading a blueprint to constructing the building. It introduces new concepts: identity, cost, and confirmation. You’re no longer just asking questions; you’re submitting proposals to a global computer, and those proposals have real consequences.

This article is your guide to making that leap. We won’t just send a transaction; we’ll construct a professional-grade Python framework to do it robustly, scalably, and securely. Forget simplistic scripts. We’re building an engineering-first approach to EVM interaction that you can use as the foundation for any bot, DeFi application, or on-chain tool.

What's the Real Difference Between Reading and Writing to a Contract?

When you call a view function, you’re essentially querying a node’s local copy of the blockchain state. It’s a free, read-only operation. The node looks up the answer and gives it to you.

Writing is fundamentally different. A write operation, or a transaction, must be included in a new block and validated by the entire network. This requires three critical components that read calls don't:

  1. Identity: The transaction must originate from a specific account, proven by a cryptographic signature. This requires a private key.
  2. Cost (Gas): You must pay for the computational resources your transaction consumes. This payment, made in the network's native currency (e.g., ETH, MATIC, BNB), is called gas.
  3. State Change: A successful transaction permanently alters the blockchain's state. Because of this, you can't just call a write function from a blockchain explorer UI without first connecting a wallet. The wallet provides the identity (private key) and the means to pay the gas fee. Our code needs to do the same.

How Do You Securely Manage Your Identity in Code?

Your on-chain identity is your private key. In web3.py, we don’t pass this key around loosely. Instead, we create an account object that securely encapsulates it.

The process is straightforward. Using the web3 instance (w3), you can create an account from its private key:

from web3 import Web3
from eth_account.signers.local import LocalAccount

# Your connection to the blockchain node
RPC_URL = "https://mainnet.infura.io/v3/your-infura-id"
w3 = Web3(Web3.HTTPProvider(RPC_URL))

# Your private key (load this securely, e.g., from an env file)
PRIVATE_KEY = "0x..." 

# Create the account object
account: LocalAccount = w3.eth.account.from_key(PRIVATE_KEY)

# Now you can access the public address
print(f"My address: {account.address}")
Enter fullscreen mode Exit fullscreen mode

A Note on Type Annotations: A crucial detail here is the type annotation: account: LocalAccount. Why is this so important? Without it, your IDE (like PyCharm or VS Code) has no idea what methods or attributes the account object has. By explicitly importing and annotating LocalAccount, you unlock autocompletion, making your development process faster and less error-prone. You can immediately see available properties like .address and .key. This isn't just a convenience; it's a mark of professional code.

The Architect's Blueprint: A Robust Python Client Class

As you build more complex applications, you'll quickly outgrow simple scripts. What if you need to manage multiple wallets? Or work across different networks? Or route your requests through a proxy to avoid being flagged as a bot?

This is where object-oriented programming becomes indispensable. We can encapsulate all the logic for a single wallet's interaction into a Client class. This class will act as our "software wallet," holding the account, the network connection, and all the methods for interacting with the blockchain.

Here is the skeleton of a powerful Client class:

from web3 import Web3
from web3.eth import AsyncEth
from eth_account.signers.local import LocalAccount
from fake_useragent import UserAgent

class Client:
    def __init__(self, private_key: str, rpc_url: str, proxy: str | None = None):
        self.private_key = private_key
        self.rpc_url = rpc_url
        self.proxy = proxy

        # --- Setup Proxies and Headers for stealth ---
        request_kwargs = {}
        if self.proxy:
            # Ensure proxy format is correct (e.g., http://user:pass@host:port)
            if 'http' not in self.proxy:
                self.proxy = f"http://{self.proxy}"
            request_kwargs['proxy'] = self.proxy

        request_kwargs['headers'] = {'User-Agent': UserAgent().random}

        # --- Create a web3 instance with our settings ---
        self.w3 = Web3(
            Web3.AsyncHTTPProvider(self.rpc_url, request_kwargs=request_kwargs),
            modules={'eth': (AsyncEth,)},
            middlewares=[]
        )

        # --- Create the encapsulated account object ---
        self.account: LocalAccount = self.w3.eth.account.from_key(self.private_key)
        self.address = self.account.address

    # Transaction methods will go here...
Enter fullscreen mode Exit fullscreen mode

With this structure, creating a client for a specific wallet and network is clean and simple:

client = Client(private_key=MY_PRIVATE_KEY, rpc_url=POLYGON_RPC)

This approach is inherently scalable. Managing a hundred wallets is now as simple as creating a hundred Client objects, each with its own state.

The Transaction Lifecycle: From Creation to Confirmation

Now for the core of our Client: sending a transaction. This is a multi-step process involving building the transaction, signing it, sending it, and waiting for confirmation. Let's build the methods for this inside our Client class.

What Are the Core Components of an EVM Transaction?
A transaction is essentially a dictionary of parameters. Let's create a send_transaction method that builds this dictionary, signs it, and sends it.

# Inside the Client class

async def send_transaction(
    self,
    to: str,
    data: str | None = None,
    value: int | None = None,
    # ... more params later
) -> HexBytes:

    tx_params = {
        'chainId': await self.w3.eth.chain_id,
        'nonce': await self.w3.eth.get_transaction_count(self.address),
        'from': self.address,
        'to': self.w3.to_checksum_address(to),
    }

    if data:
        tx_params['data'] = data

    if value:
        tx_params['value'] = value

    # Gas calculation comes next...
Enter fullscreen mode Exit fullscreen mode

Let's dissect these non-negotiable parameters:

  • chainId: The unique identifier for the blockchain network (e.g., 1 for Ethereum, 137 for Polygon). This prevents replay attacks across different chains.

  • nonce: The transaction count for your account. This is a critical field. Every transaction from your address must have a unique, sequential nonce. If your last transaction had nonce 10, the next one must have nonce 11. This sequencing prevents duplicates and ensures transactions are processed in the order you send them.

  • from: Your wallet address.

  • to: The recipient address. This can be another wallet or, more commonly, a smart contract address.

  • data: This is the payload of your transaction. For a simple transfer of the native coin, this can be empty. But when you call a function on a smart contract, the data field contains the encoded function signature and its arguments. This is how the EVM knows which function you want to execute.

  • value: The amount of the network's native currency you want to send with the transaction. This is used for paying for an NFT mint, swapping ETH for a token, or simply sending ETH to a friend. Crucially, this is NOT used for transferring ERC-20 tokens. ERC-20 transfers are handled by a function call encoded in the data field (transfer(address, uint256)).

How Does EIP-1559 Change the Way We Pay for Gas?

Before EIP-1559, you specified a single gasPrice. This was a bid in a simple auction; higher bids got included faster. Most modern networks now use the EIP-1559 model, which is more sophisticated. It splits the gas fee into two parts:

  1. maxPriorityFeePerGas: A "tip" you pay directly to the validator to incentivize them to include your transaction quickly.
  2. maxFeePerGas: The absolute maximum you are willing to pay per unit of gas, which includes both the tip and a variable baseFee set by the network protocol itself. Your Client should support both legacy and EIP-1559 transactions. Let's enhance our send_transaction method:
# Inside send_transaction, after setting up the initial tx_params

if is_eip1559_supported: # A flag to control which gas model to use
    latest_block = await self.w3.eth.get_block('latest')
    base_fee = latest_block['baseFeePerGas']
    max_priority_fee = await self.w3.eth.max_priority_fee

    # maxFeePerGas is the sum of the base fee and your tip
    max_fee = base_fee + max_priority_fee

    tx_params['maxFeePerGas'] = max_fee
    tx_params['maxPriorityFeePerGas'] = max_priority_fee
else:
    tx_params['gasPrice'] = await self.w3.eth.gas_price

# Estimate the gas limit needed for the transaction
tx_params['gas'] = int(await self.w3.eth.estimate_gas(tx_params) * 1.2) # Add 20% buffer

# Sign and send
signed_tx = self.w3.eth.account.sign_transaction(tx_params, self.private_key)
tx_hash = await self.w3.eth.send_raw_transaction(signed_tx.rawTransaction)

return tx_hash
Enter fullscreen mode Exit fullscreen mode

With the full tx_params dictionary assembled, the final steps are clear:

  1. Estimate Gas: We ask the node to estimate how much gas the transaction will consume and add a small buffer for safety.
  2. Sign: We use our encapsulated account object to sign the transaction parameters. This produces a cryptographic proof that we authorized this exact transaction.
  3. Send: We broadcast the signed, "raw" transaction to the network. The network returns a transaction hash almost immediately. This hash is not a confirmation; it's just a receipt. Now, we wait.

How Do You Know if Your Transaction Actually Succeeded?

The transaction is now in the mempool, waiting for a validator to include it in a block. This can take seconds or minutes. Our code needs to wait and then verify the outcome. Let's create a verify_transaction method.

# Inside the Client class
from web3.exceptions import TimeExhausted

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

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

    except TimeExhausted:
        raise Exception(f"Transaction timed out. Hash: {tx_hash.hex()}")
Enter fullscreen mode Exit fullscreen mode

This method uses wait_for_transaction_receipt, which polls the network until the transaction is mined. The returned receipt contains a status field: 1 means success, 0 means failure. By checking this, we can definitively know the result and handle errors gracefully.

Your First Write Call: A Guided approve Transaction

Let's put it all together with a real-world example: approving a DEX (like Uniswap) to spend your USDT. This is a prerequisite for any token swap.

Step 1: Define Constants and Get the Contract ABI

You need the address of the token you're approving (USDT) and the address of the spender (the Uniswap router). You also need the token's ABI, specifically for the approve and allowance functions. An ABI is a JSON file that describes a contract's functions. For standard ERC-20 tokens, this is easy to find on an explorer like Etherscan.

# In your main script file
USDT_ADDRESS = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
UNISWAP_ROUTER_ADDRESS = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"

# A minimal ABI for what we need
TOKEN_ABI = """
[
    {"constant":true,"inputs":[{"name":"_owner","type":"address"},{"name":"_spender","type":"address"}],"name":"allowance","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},
    {"constant":false,"inputs":[{"name":"_spender","type":"address"},{"name":"_value","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"}
]
"""
Enter fullscreen mode Exit fullscreen mode

Step 2: Initialize Client and Contract
Instantiate your Client and create a contract object using the token's address and ABI.

client = Client(private_key=MY_PRIVATE_KEY, rpc_url=ETH_MAINNET_RPC)
usdt_contract = client.w3.eth.contract(address=USDT_ADDRESS, abi=TOKEN_ABI)
Enter fullscreen mode Exit fullscreen mode

Step 3: Pre-flight Check (Best Practice)
Why send a transaction if the approval is already sufficient? We can use a read call to check the current allowance.

amount_to_approve = 1000 * (10**6) # 1000 USDT (USDT has 6 decimals)

current_allowance = await usdt_contract.functions.allowance(
    client.address, 
    UNISWAP_ROUTER_ADDRESS
).call()

if current_allowance >= amount_to_approve:
    print("Approval already sufficient.")
else:
    # Proceed to approve
    ...
Enter fullscreen mode Exit fullscreen mode

Step 4: Build, Send, and Verify
If an approval is needed, we encode the function call into the data field and use our Client's methods.

    # Inside the 'else' block from Step 3
    print("Approval needed. Sending transaction...")

    # 1. Encode the function call to get the `data` payload
    tx_data = usdt_contract.encode_abi(
        fn_name='approve',
        args=(UNISWAP_ROUTER_ADDRESS, amount_to_approve)
    )

    # 2. Use our client to send the transaction
    # Note: `value` is 0 because we aren't sending ETH
    tx_hash = await client.send_transaction(
        to=USDT_ADDRESS, 
        data=tx_data,
        value=0
    )

    # 3. Use our client to verify the outcome
    await client.verify_transaction(tx_hash)
Enter fullscreen mode Exit fullscreen mode

And that’s it. You have successfully built a modular, robust system and used it to change the state of the world's largest smart contract platform.

Final Thoughts

Moving from reading to writing on the blockchain is a significant step. It requires a deeper understanding of the EVM's mechanics, but it also unlocks the full potential of this technology. By adopting an engineering-focused, object-oriented approach from the start, you're not just learning to send a transaction—you're learning to build scalable, maintainable, and professional-grade decentralized applications.

The key takeaways are:

Encapsulate State: Use a Client class to manage each wallet's state (private key, RPC, proxy). This is the foundation for scalability.
Master the Parameters: Every field in a transaction, especially nonce and data, has a critical purpose. Understand them deeply.
Embrace EIP-1559: Use the modern gas model for more predictable transaction fees on supported networks.
Verify Everything: Sending a transaction is only half the job. Always wait for the receipt and check its status to confirm the outcome.
You now have the blueprint. The passive observer has become an active participant. Go build something that changes the state of the world.

Top comments (0)