If you’ve ever opened a block explorer after clicking “Write” and wondered why nothing moved until your wallet connected, you’re not alone. The first time you build and send a write transaction to an EVM network, you quickly discover it isn’t just “call a function.” It’s identity, intent, economics, encoding, delivery, and confirmation—all behaving differently across networks and contract shapes. Once that clicks, you stop copy-pasting snippets and start engineering a client you trust.
What does a write transaction actually need?
Short answer: more than you expect. Longer answer: it needs to be assembled and signed with deliberate inputs that the chain and the contract both recognize.
Identity
- Your
private_key
and a derivedaccount
(address) are mandatory. You can create an account from a private key or a mnemonic; if you use a mnemonic in Web3.py, be prepared to enable HD wallet features before callingfromMnemonic
. - Always keep the account address in checksum format. It helps catch mistakes early and keeps encoding honest.
Intent
- The target
to
address (the contract you’re calling). - The
data
payload that encodes the function name and arguments precisely. - Optional
value
in native currency when the contract expects payment (mints, native-token swaps, etc.). If you don’t spend native currency,value
should be zero.
Economics
-
chainId
appropriate for the network you’re on. nonce
to sequence your outbound transactions and avoid race conditions.-
Gas setup:
- Legacy networks:
gasPrice
. - EIP-1559 networks:
maxFeePerGas
andmaxPriorityFeePerGas
, typically derived from the latest block’sbaseFeePerGas
plus your chosen priority tip.
- Legacy networks:
Delivery
-
gas
limit, estimated viaestimateGas
and padded with a multiplier (for example,increaseGas = 1.2
) to guard against underestimation.
Confirmation
- Signed raw transaction bytes via
sendRawTransaction
. - A receipt polled via
wait_for_transaction_receipt
and a parsedstatus
(integer: 1 = success, 0 = failure).
Framework: the six-part transaction spec
This mental model is simple enough to retell and robust enough to reuse:
- Identity: keys, address, chain.
- Intent:
to
,data
, and (optional)value
. - Economics:
chainId
,nonce
, gas fee shape (legacy vs. EIP-1559). - Provisioning:
gas
limit viaestimateGas
, plus an increase factor. - Delivery: sign with
private_key
, send raw bytes. - Confirmation: wait for receipt and validate
status
.
Why bother with a client class?
Because the minute you juggle multiple accounts or networks, you’ll otherwise hand-wire RPC, private keys, chain IDs, and account creation at every call site. Don’t. Create a Client
.
A minimal Client
holds:
-
rpc
as a string, to pick the network. -
private_key
as a string, to derive youraccount
. -
w3
as anAsyncWeb3
(orWeb3
) instance, connected via an HTTP provider. -
account
as aLocalAccount
. - Optional
proxy
andheader
s as request customizations that protect you from being “obvious” when you run fleets of accounts.
Two practical notes:
- Use type annotations (
LocalAccount
,AsyncWeb3
, etc.). Your IDE will give you method discovery on the object behindaccount
, which saves time and errors. - If you need a mnemonic, call the feature-enabler before
fromMnemonic
. Without it, you’ll chase anAttributeError
that goes away only after you enable HD wallet support.
How do you encode contract calls without tripping over ABI?
When you call a write function on a smart contract, you encode intent into data
. There are two reliable paths:
- ABI-assisted encoding:
contract.encodeABI(function_name, args=(...))
- This is ideal when you have the ABI nearby.
- If the parameter types don’t match, the library will explain what it expected. That error message is your friend—use it to fix incorrect types or ordering.
- Function-builder encoding:
contract.functions.<name>(...).buildTransaction({...})
- Compose the call by name, give arguments, and let the contract wrapper finish the rest.
- You still provide transaction fields (
chainId
,nonce
,gas
, etc.) in the dict you pass intobuildTransaction
.
Two advanced realities:
- The function selector (first 4 bytes, i.e., the first 10 hex characters of
data
) depends on the function name and type order. If your ABI name doesn’t match the deployed method signature but you know the selector, you can override the selector in the finaldata
by replacing the prefix. This is niche but invaluable when the UI or contract surface differs from your ABI. - Parameter order is not a nicety—it’s a requirement. Swapping
address
anduint256
changes the selector and breaks your call.
Gas handling: legacy vs. EIP-1559, the parts that matter
You’ll see both in the wild. Understanding the differences stops a lot of guesswork.
Legacy
- Provide
gasPrice
. - Provide
gas
limit viaestimateGas
(pad it). - Many chains still run legacy (for example, BSC). If EIP-1559 params cause errors, drop back to
gasPrice
.
EIP-1559
-
maxPriorityFeePerGas
: get a reasonable suggestion via an endpoint (e.g.,w3.eth.max_priority_fee
), or set to0
if speed isn’t essential. -
baseFeePerGas
: read from the latest block; it changes with demand. -
maxFeePerGas
:baseFeePerGas + maxPriorityFeePerGas
. - Don’t set
gasPrice
here; that field belongs to legacy. Yourtxparams
include bothmaxFeePerGas
andmaxPriorityFeePerGas
instead. - Not all networks support EIP-1559. Try, fall back if rejected, and keep defaults sane.
Practical pattern: always multiply the estimated gas
by a configurable factor (e.g., 1.2
). You pay only for gas used; setting a higher limit avoids “barely insufficient” failures.
What changes between reading and writing?
Reads are easy: they don’t cost anything and they don’t need a signer. Writes do:
- They require your account and signature.
- They consume gas and may require
value
. - They must be sequenced via
nonce
and shaped with gas. Reads are stateless; writes are ledger entries.
Step-by-step: your first reusable transaction client
Use this as a checklist.
- Set up the client
- Create a
Client
that storesrpc
,private_key
,w3
,account
. Add optional
proxy
andheaders
. UseAsyncHTTPProvider
withrequest_kwargs
for proxies; include randomizedUser-Agent
headers.Create the account
From private_key
:w3.eth.account.from_key(private_key)
.From mnemonic: enable HD wallet features first, then call
fromMnemonic
.Prepare the transaction parameters
chainId
: get from the network.nonce
:w3.eth.getTransactionCount(account.address)
.from
: checksum the address.to
: checksum the target contract.-
Gas shape:
- Legacy:
gasPrice = w3.eth.gasPrice
. - EIP-1559:
maxPriorityFeePerGas
from endpoint;baseFeePerGas
from the latest block;maxFeePerGas
is their sum.
- Legacy:
Encode intent
With ABI:
contract.encodeABI("approve", args=(spender, amount))
orcontract.functions.approve(spender, amount).buildTransaction(...)
.Without full ABI: encode via known function name, types, and the argument order; override selector if necessary.
Set value
Non-zero only for calls that spend native currency (mint fees, native-token swaps).
Zero for token-to-token transfers or token-to-native swaps where native currency isn’t spent as the asset.
Estimate and pad gas
gas = w3.eth.estimateGas(txparams)
; multiply by an increase factor.Sign and send
signed = w3.eth.account.sign_transaction(txparams, private_key)
.tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction)
.Verify and handle outcomes
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=...)
.Read
status
as int;1
means success.If failure or timeout, raise a specific error to be handled upstream.
Structure your project
Keep ABIs in
data/abis
.Keep models (ABI dictionaries, token metadata) in
data/models
.Put the
Client
inclient.py
.Keep helper functions in
utils.py
(get_json
, decoding helpers).Put operational scripts in
main.py
.
Encode reality: decimals, allowance, and value
Tokens aren’t native; their math uses decimals
. Every quantity you send must be in base units.
Decimals
- Fetch decimals from the token contract (read function).
- Convert human amounts to base units: amount * 10**decimals.
Allowance
- Before a router can spend your tokens (for example, swapping USDC to MATIC), you must approve(router_address, amount).
- Check existing allowance(owner, spender) before you approve; skip redundant approvals to save gas.
Value
- Use non-zero value only when spending native currency. For instance:
- Swapping native token to a token: value equals the native amount.
- Swapping token to native or token to token: value = 0.
Gas estimation can be wrong: budget for it
It happens. Especially with popular write functions (approve, transfers) and consistently-touched routers, estimation tends to be stable. But anything dynamic or complex can swing.
- Always allow an increaseGas factor in your sendTransaction method; keep 1.0 as default and override per call (e.g., 1.2).
- Remember: you don’t pay for unused gas limit; you pay for gas consumed.
Legacy minting and modern minting: two shaped paths
You’ll encounter two common mint patterns: “payable mint” where you send a fee, and “safe mint” variants with explicit argument shapes.
Payable mint workflow (example: a simple mint function):
- Read the mint fee via a read function like fee().
- Encode data with mint (often with no arguments).
- Send transaction with to = mint_contract_address, data = encoded, and value = fee.
- Confirm via receipt status.
Safe mint workflow (example: safeMint(to) on marketplace drops):
- Compose via contract.functions.safeMint(your_address).buildTransaction(txparams), where txparams includes your chainId, nonce, and value if required.
- Sign, send, verify.
- Subtlety: some contracts expect the recipient address in a single-element list versus a tuple; be ready to try the variant the contract accepts.
Swapping on Polygon with a router
A clean router-based swap implementation boils down to calling the correct function with a precise path and deadlines.
Native to token (example: MATIC → USDC):
- Call swapExactETHForTokens(amountOutMin, path, to, deadline) on the router.
- path = [WMATIC, WETH, USDC] is common for deep-liquidity hops.
- amountOutMin calculation:
- nativeAmountHuman * priceInUSDT * (1 - slippage/100) * 10**tokenDecimals.
- Price data can be fetched via your client’s getTokenPrice.
- value equals the native amount (in base units).
- deadline is a Unix timestamp (integer); build from current time plus a tolerance window. A practical variant is scaling that number when the explorer shows different semantics.
Final Thoughts
Sending your first write transaction isn’t about memorizing one snippet; it’s about internalizing a repeatable shape you can apply anywhere: identity, intent, economics, encoding, delivery, confirmation. Once you wrap it in a Client, add EIP-1559 awareness, introduce proxies and headers, and keep your ABIs and models clean, the work shifts from “Will this even send?” to “How do we design the next flow?”—minting on a marketplace, swapping via a router, or composing an automated approve-and-swap bundle.
Key takeaways:
Build a reusable Client that hides RPC, account creation, proxies, headers, gas shaping, and verification.
Treat transactions as a six-part spec; it’s the fastest way to reason about write calls across networks.
Encode data carefully; types and order define the selector. If the selector is known, you can even override it.
Keep an increaseGas strategy and a legacy fallback for EIP-1559.
Approve only what you need. Check allowance first.
Verify outcomes by status and raise explicit errors; silent failures cost more later.
Structure your files to keep ABIs, models, helpers, and operations separate.
You’re closer to “production-grade” than it seems. Pick one flow—mint or swap—and implement it end-to-end in your client this week. Then ask yourself: where in my stack will a reusable transaction spec save me the most time next month?
Top comments (0)