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}")
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
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}")
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.
-
from
: The sender's address. OurClient
class will automatically fill this withself.account.address
. -
to
: The recipient's address. This could be another wallet or, more commonly in automation, a smart contract address. -
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, thevalue
will be0
, as you are not sending the native currency. If, however, you're swapping ETH for USDT, thevalue
will be the amount of ETH you're exchanging. For tokenapprove
transactions,value
is always0
. -
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 anonce
of 10, the next one must have anonce
of 11. It can be retrieved usingw3.eth.get_transaction_count(self.account.address)
. -
chainId
: The network identifier (1 for Ethereum, 56 for BSC, etc.). This prevents a transaction from being replayed on another network. It is retrieved viaw3.eth.chain_id
. -
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 usingw3.eth.estimate_gas(tx_params)
. -
gasPrice
(for Legacy transactions) ormaxFeePerGas
/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. -
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 callingapprove(spender, amount)
, thedata
field will contain the signature of theapprove
function and the encoded values ofspender
andamount
.
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
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
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 ...
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() ...
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:
-
Encapsulation is the key to scalability. The
Client
class transforms a chaos of variables into an organized and reusable structure. -
A transaction is a dictionary. Understanding each key (
to
,value
,data
, etc.) gives you complete control over your actions on the blockchain. -
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. - 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)