DEV Community

Cover image for A Developer’s Deep Dive into EVM Transactions with Python
OnlineProxy
OnlineProxy

Posted on

A Developer’s Deep Dive into EVM Transactions with Python

You’ve done it a thousand times. You connect your wallet, review the details, and click "Approve" or "Swap." A few seconds later, a confirmation appears. It’s a seamless experience, designed to be intuitive. But as a developer, you know that beneath this placid surface lies a torrent of complex, cryptographic machinery.

What really happens when you authorize a contract to spend your tokens? How is that intention—that click—translated into an immutable record on a global ledger? The journey from user interface to blockchain execution is a fascinating one, and mastering it programmatically is the line that separates a Web3 user from a Web3 builder.

This isn't about simply calling a pre-packaged SDK function. We're going to deconstruct the entire process, from cryptographic authentication to transaction verification. We will build a robust, reusable Python client capable of crafting, signing, and broadcasting raw transactions to any EVM-compatible network. Forget the black boxes; it’s time to look under the hood.

How Does Your Script Prove It's You?

Interacting with a blockchain involves two distinct types of operations: reading and writing. When you check a token balance or read a contract’s state, you are performing a read operation. It’s passive, requires no authentication, and costs nothing but a call to a node.

However, the moment you wish to change the state—to send a token, mint an NFT, or execute a swap—you enter the world of write operations. These actions require you to spend gas and, more importantly, prove you have the authority to do so. This proof isn't a username and password; it's a cryptographic signature generated from your private key.

In the Python ecosystem, the web3.py library provides the tools to manage this identity. The first step is to instantiate an "account" object from your private key.

from web3 import Web3
from eth_account.signers.local import LocalAccount # We'll see why this is important
import config # Your config file with private key

# Connect to an EVM network via an RPC endpoint
w3 = Web3(Web3.HTTPProvider(config.ETHEREUM_RPC_URL))

# Create an account object from the private key
private_key: str = config.PRIVATE_KEY
account: LocalAccount = w3.eth.account.from_key(private_key)

# The public address can now be accessed
print(f"Account Address: {account.address}")
Enter fullscreen mode Exit fullscreen mode

The from_key method is our bridge from a simple string (the private key) to a powerful object capable of cryptographic operations.

A crucial, often-overlooked detail here is the type annotation: : LocalAccount. Why is this not just good practice, but essential for serious development? Without it, your IDE has no idea what methods or attributes the account object holds. By explicitly importing LocalAccount and annotating the variable, you unlock autocompletion, static analysis, and a clearer understanding of the object's capabilities (account.address, account.key, account.sign_transaction, etc.). This is a senior developer's habit: making the code self-documenting and easier to reason about.

The Client-Centric Framework: Your On-Chain Command Center

As you start building more complex logic, simply having a w3 object and an account object floating in your main script becomes unwieldy. What if you want to work with multiple accounts? Or interact with different networks? The code quickly devolves into a scattered mess of variables.

To bring order to this chaos, we can adopt an object-oriented approach by creating a Client class. Think of this class as your own personal, programmable MetaMask. It encapsulates all the necessary components for a single identity on a single network into one cohesive unit.

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

class Client:
    private_key: str
    rpc: str
    proxy: str | None
    w3: Web3
    account: LocalAccount

    def __init__(self, private_key: str, rpc: str, proxy: str | None = None):
        self.private_key = private_key
        self.rpc = rpc
        self.proxy = proxy

        # Add proxy and random headers for operational security
        request_kwargs = {
            "headers": {"User-Agent": UserAgent().random},
            "proxy": self.proxy
        }

        self.w3 = Web3(
            Web3.AsyncHTTPProvider(self.rpc, request_kwargs=request_kwargs),
            modules={"eth": (AsyncEth,)},
            middlewares=[],
        )
        self.account = self.w3.eth.account.from_key(self.private_key)
Enter fullscreen mode Exit fullscreen mode

This Client class does several important things:

  1. Encapsulation: It bundles the private key, RPC endpoint, web3 instance, and account object together. All interactions for a specific wallet now flow through this client.

  2. Scalability: You can now easily instantiate multiple Client objects for different wallets or even different networks, enabling complex multi-account automation.

  3. Production Readiness: We've preemptively added support for a proxy and a randomized User-Agent. When running automated scripts, sending all requests from a single IP address is a red flag. This simple addition makes your client less "palevnyy" (conspicuous) and more resilient to IP-based rate limiting or blocking.
    With this framework, our main logic becomes cleaner. Instead of managing loose variables, we simply create a client and use it.

# main.py
client = Client(private_key=config.PRIVATE_KEY, rpc=config.ETHEREUM_RPC_URL)
print(f"Client Address: {client.account.address}")
Enter fullscreen mode Exit fullscreen mode

What's Really Inside a Transaction Payload?

Now we get to the core: building and sending a transaction. We will add a send_transaction method to our Client class. This method will be responsible for assembling all the required parameters, signing the payload, and broadcasting it to the network.

A transaction is not just a destination and an amount. It's a structured dictionary of parameters that collectively define the action to be taken. Let's dissect them:

  • to: The address of the recipient. For a simple ETH transfer, it's a wallet. For a contract interaction, it's the contract's address.

  • from: The address of the sender, which will be our client.account.address.

  • value: The amount of the network's native currency (ETH, MATIC, BNB) to send with the transaction. This is crucial. For an approve call, value is 0. For swapping 1 ETH for a token, value would be (1 * 10^{18}). For swapping a token for ETH, value is again 0, because you aren't sending the native currency.

  • nonce: The "number used once." This is a transaction counter for your account. If your account's last transaction had a nonce of 10, the next one must have a nonce of 11. This prevents replay attacks and ensures transactions are processed in a specific order. We fetch this dynamically using w3.eth.get_transaction_count(self.account.address).

  • chainID: An identifier for the specific blockchain (e.g., 1 for Ethereum Mainnet, 137 for Polygon). This prevents a transaction signed for one network from being maliciously re-broadcast on another.

  • data: This is the heart of a contract interaction. It specifies which function to call and with what arguments, all encoded into a hexadecimal string. For a simple ETH transfer, this field is empty. For an approve call, it contains the encoded signature of the approve function plus the spender's address and the amount.

  • gas: The maximum amount of gas units you are willing to consume for this transaction. We can get a good starting point using w3.eth.estimate_gas(tx_params), which asks the node to simulate the transaction and report how much gas it would use. It's a best practice to add a safety margin (e.g., multiply by 1.2) as network conditions can change between estimation and execution. You only pay for the gas you actually use.

  • gasPrice: The price you are willing to pay per unit of gas. This is the "legacy" model for transaction fees. The higher the gasPrice, the faster miners are incentivized to include your transaction.

Why Is gasPrice Obsolete? Understanding EIP-1559

The legacy gasPrice model operated like a simple auction, leading to fee volatility and inefficient bidding. Ethereum's EIP-1559 introduced a more sophisticated model that is now standard on most modern EVM chains. Instead of a single gasPrice, it uses two components:

  • maxPriorityFeePerGas: This is the "tip" you pay directly to the validator/miner to incentivize them to include your transaction quickly. For non-urgent transactions, this can even be set to 0.

  • maxFeePerGas: This is the absolute maximum you are willing to pay per gas unit. It is the sum of the maxPriorityFeePerGas and a network-wide baseFee. The baseFee is determined by protocol based on network congestion and is burned, not paid to the validator.

A modern send_transaction method should support both legacy and EIP-1559-style transactions.

Here's an improved method for our Client class that incorporates EIP-1559 logic:

# Inside the Client class
async def send_transaction(
    self,
    to: str,
    data: str | None = None,
    value: int | None = None,
    is_eip1559: bool = True,
    increase_gas: float = 1.2
) -> str:
    # 1. Base Transaction Parameters
    tx_params = {
        "chainId": await self.w3.eth.chain_id,
        "nonce": await self.w3.eth.get_transaction_count(self.account.address),
        "from": self.account.address,
        "to": to,
    }
    if data:
        tx_params["data"] = data
    if value:
        tx_params["value"] = value

    # 2. Fee Calculation (EIP-1559 vs Legacy)
    if is_eip1559:
        latest_block = await self.w3.eth.get_block("latest")
        base_fee = latest_block.get("baseFeePerGas", 0)
        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:
        tx_params["gasPrice"] = await self.w3.eth.gas_price

    # 3. Gas Estimation
    gas_estimate = await self.w3.eth.estimate_gas(tx_params)
    tx_params["gas"] = int(gas_estimate * increase_gas)

    # 4. Signing and Sending
    signed_tx = self.w3.eth.account.sign_transaction(tx_params, self.private_key)
    tx_hash_bytes = await self.w3.eth.send_raw_transaction(signed_tx.rawTransaction)

    tx_hash = tx_hash_bytes.hex()
    print(f"Transaction sent with hash: {tx_hash}")

    # 5. Verification (wait for receipt)
    receipt = await self.w3.eth.wait_for_transaction_receipt(tx_hash)
    if receipt.status == 1:
        print("Transaction was successful!")
        return tx_hash
    else:
        raise Exception(f"Transaction failed! Receipt: {receipt}")
Enter fullscreen mode Exit fullscreen mode

This method is a complete workflow: it assembles parameters, intelligently handles fees, estimates gas, signs with our identity, broadcasts, and waits for confirmation.

Your First Programmatic approve: A Step-by-Step Guide

Let's put our client to work with a real-world example: approving the Uniswap router to spend our USDT. This is the prerequisite for any swap involving that token.

Step 1: Preparation (Address and ABI)
We need the address of the token contract we're interacting with (USDT) and its Application Binary Interface (ABI). The ABI is a JSON file that defines the contract's functions and is available on block explorers like Etherscan.

# In your main script
USDT_ADDRESS = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
UNISWAP_ROUTER_ADDRESS = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"
# Assume TOKEN_ABI is a variable holding the JSON content of the token's ABI
Enter fullscreen mode Exit fullscreen mode

Step 2: Pre-flight Check (Check Allowance)
Before sending an approve transaction, it’s good practice to first check the current allowance. If you've already granted sufficient approval, sending another transaction is a waste of gas. This is a simple read call.

# Create a contract object
token_contract = client.w3.eth.contract(address=USDT_ADDRESS, abi=TOKEN_ABI)

# Check current allowance
approved_amount = await token_contract.functions.allowance(
    client.account.address,
    UNISWAP_ROUTER_ADDRESS
).call()

if approved_amount >= amount_to_approve:
    print("Allowance is already sufficient. No transaction needed.")
else:
    # Proceed to approve
    ...
Enter fullscreen mode Exit fullscreen mode

Step 3: Encoding the Call (data Payload)
This is the magic step. We need to create the data payload for our approve call. web3.py makes this easy with the encode_abi method.

# Define the amount to approve (e.g., 100 USDT)
# Remember to account for the token's decimals (USDT has 6)
amount_to_approve_wei = 100 * (10**6)

# Encode the function call into the 'data' field
transaction_data = token_contract.encode_abi(
    fn_name="approve",
    args=[UNISWAP_ROUTER_ADDRESS, amount_to_approve_wei]
)
# transaction_data is now a hex string like "0x095ea7b3..."
Enter fullscreen mode Exit fullscreen mode

encode_abi takes the function name and its arguments and produces the exact hexadecimal string that the EVM needs to understand your intent.

Step 4: Execution & Verification
With the data payload ready, we can now use our client's powerful send_transaction method.

try:
    tx_hash = await client.send_transaction(
        to=USDT_ADDRESS,  # We are calling the USDT contract
        data=transaction_data,
        value=0          # No native currency is being sent
    )
    print(f"Successfully sent 'approve' transaction: {tx_hash}")
except Exception as e:
    print(f"An error occurred: {e}")
Enter fullscreen mode Exit fullscreen mode

And that's it. You've programmatically constructed, signed, and verified a state-changing transaction on the blockchain. You've replicated the "Approve" button, but with complete control and transparency.

Fortifying Your Client: Alternatives and Production Strategies

The encode_abi and send_transaction combination is powerful, but it's not the only way. Some contracts or scenarios might benefit from an alternative pattern using build_transaction.

# Alternative way to build the transaction dictionary
tx_params = await token_contract.functions.approve(
    UNISWAP_ROUTER_ADDRESS,
    amount_to_approve_wei
).build_transaction({
    'chainId': await client.w3.eth.chain_id,
    'from': client.account.address,
    'nonce': await client.w3.eth.get_transaction_count(client.account.address)
    # web3.py will fill in gas and fee details
})

# Then sign and send tx_params as before
Enter fullscreen mode Exit fullscreen mode

This method can be more direct but offers slightly less granular control than building the dictionary manually. Knowing both methods gives you flexibility. Furthermore, structuring your project is vital. Don't keep your Client class, ABIs, and main script in one file. A good starting structure is:

  • main.py: Your primary execution script.

  • client.py: Contains your Client class.

  • data/abi/: A directory to store your JSON ABI files.

  • config.py: For sensitive data like private keys and RPC URLs.

This separation of concerns is fundamental for moving from a simple script to a maintainable and scalable application.

Final Thoughts

We've journeyed from the simple click of a button to the granular bytes of a raw EVM transaction. We established a secure identity with a private key, built a robust and extensible Client class to act as our command center, dissected the anatomy of a transaction payload, and navigated both legacy and modern fee structures.

By programmatic control, you transform the blockchain from a place you visit into a substrate you can build upon. The logic that checks allowance before approving is a simple example of the intelligence you can now embed in your interactions—saving gas, preventing errors, and executing strategies far beyond the scope of a manual click.

You now have the blueprint. You understand the "why" behind every parameter and the "how" of every step. The real question is, what will you build with it?

Top comments (0)