DEV Community

Cover image for From Clicks to Code: Building a Production‑Ready EVM Transaction Client (Approve, Mint, Swap, EIP‑1559)
OnlineProxy
OnlineProxy

Posted on

From Clicks to Code: Building a Production‑Ready EVM Transaction Client (Approve, Mint, Swap, EIP‑1559)

Your first successful EVM transaction isn’t the hard part. Making it reliable, repeatable, and production‑ready is. If you’ve ever clicked “Write” in a block explorer only to be stopped by “Connect wallet,” you already know the real work starts the moment you try to automate it.

This is a field guide to sending robust write transactions from Python: approve, mint, swap, legacy vs EIP‑1559, receipt verification, calldata encoding, gas estimation, and the unglamorous but vital pieces like proxies, headers, and sane timeouts. You’ll walk away with a client you can reuse across networks and accounts, and a set of patterns that scale from one wallet to hundreds.

Why does a write call require a wallet?

  • Read calls are free: they query node state and never touch consensus.
  • Write calls cost money: they alter state and must be signed by a keypair.
  • Because write calls originate from a wallet, your code needs an account object you control.
    Two reliable ways to build that account:

  • from_key: the direct, boring, dependable method. You pass a private key, get a LocalAccount. Access address via account.address, key bytes via account.key (hexify with account.key.hex()).

  • from_mnemonic: also supported. It requires initializing an extra hook before usage. It’s handy only if you truly don’t have a private key; the private key route is more native and ergonomic.

Pro tip for maintainability: annotate your account variable as a LocalAccount. IDEs can’t infer it automatically, and the annotation unlocks autocompletion for .address, .key, and helper methods. Yes, it’s “just” typing, but it prevents subtle mistakes you’ll otherwise catch only at runtime.

What belongs in a minimal Web3 client?

The temptation is to paste the same dozen lines for each wallet and network. Don’t. Wrap them into a Client class that:

  • Accepts rpc: str and private_key: str in the constructor.
  • Creates one AsyncWeb3 instance per RPC.
  • Builds a LocalAccount once and reuses it.
  • Exposes a couple of high‑level methods: send_transaction(...) and verify_tx(...).

Type‑annotate the fields:

  • self.private_key: str
  • self.rpc: str
  • self.web3: AsyncWeb3
  • self.account: LocalAccount

You’ll get the best of both worlds: a clean public surface, and enough low‑level access to debug or extend the behavior when you need it.

How do you build and sign a raw transaction correctly?

Every successful transaction shares the same skeleton. Populate it explicitly and you’ll avoid 90% of “mystery” failures.

Required fields:

  • chainId: which network you’re on.
  • nonce: the next transaction index for your address, from web3.eth.get_transaction_count(self.account.address). It prevents races and ensures ordering.
  • from: your ChecksumAddress.
  • to: the ChecksumAddress you’re calling (a contract or EOA).
  • gas (limit): use estimate_gas(tx_params) and then multiply by a safety factor (e.g., 1.2). You don’t pay for unused gas, but underestimating is a self‑inflicted failure.
  • Gas price mode:
    • Legacy: include gasPrice.
    • EIP‑1559: include maxFeePerGas and maxPriorityFeePerGas instead of gasPrice.
  • data: calldata as a hex string (leave blank if you’re just transferring ETH).
  • value: only for spending the network’s native coin. The send/sign sequence:
  1. Build tx_params as a dict with the fields above.
  2. signed = self.web3.eth.account.sign_transaction(tx_params, self.private_key)
  3. tx_hash = await self.web3.eth.send_raw_transaction(signed.rawTransaction)

Return tx_hash as HexBytes and convert to .hex() for logs or links.

How do you know it actually landed?

Use wait_for_transaction_receipt(tx_hash, timeout=...). It returns a receipt with:

  • status as an int: 1 for success, 0 for failure.
  • type: 0 for legacy, 2 for EIP‑1559.
  • Rich context (gasUsed, logs, transactionIndex, etc.).
    A simple, robust pattern:

  • Wait for the receipt.

  • If status == 1, return tx_hash.hex().

  • Else, raise a Web3 exception that includes the hash for quick tracking.

When do you set value?

This sounds basic, yet it’s the source of many broken mints and swaps:

  • Set value when—and only when—you’re spending the native coin (ETH, BNB, MATIC).
  • Examples:
    • Swapping MATIC → USDC: set value to the MATIC amount (in Wei). You are paying with native coin.
    • Swapping USDC → MATIC: value = 0. The USDC is debited from the token contract; you’re not attaching native value.
    • Approving ERC‑20 spending: value = 0. It’s a permission action, not a payment.
  • When in doubt, perform the action once manually in the explorer and read the exact params. data and value are the two fields you typically need to copy; the rest is computed.

How do you encode calldata without the full ABI?

You don’t need the entire ABI for a target contract; you only need the definition of the function(s) you call. Construct a minimal ABI on the fly and encodeABI will do the rest.

Example: token approval. Minimal ABI needs:

  • approve(address spender, uint256 amount) with stateMutability: nonpayable.
  • Optionally, allowance(address owner, address spender) (to avoid unnecessary approves).
  • decimals(), balanceOf(address) for correct units.
    Two critical truths about encoding:

  • The 4‑byte selector (first 10 hex chars incl. 0x) depends on function name, parameter types, and their order. Change any of those and the selector changes.

  • If you absolutely must (e.g., function name unknown), you can override the selector by replacing the first 10 chars of the encoded data with the selector observed in the explorer. But in normal operation, keep the ABI accurate and let the library compute it.

A safe “approve” flow you can reuse
Approving everything all the time is how you generate needless on‑chain noise (and sometimes risk). A simple discipline saves money and mistakes:

  1. Get decimals and compute smallest units correctly. Never approve floats; always approve integers in token units.
  2. Read your current balanceOf(address) and allowance(owner, spender).
  3. If allowance < desired_amount, then approve; otherwise skip. Log “already approved” and move on.

Two honest watch‑outs:

  • account.key is bytes. Hexify it if you need a string, but never log it in real code.
  • Don’t use human units in the contract call. Use units multiplied by 10**decimals.

How do you mint safely? Two patterns you’ll see
Mint UX often hides the on‑chain structure, but in code it all reduces to:

  • Read the required fee or mintPrice using a read call. This is the native value you must attach.
  • Build a write call with either:
    • encodeABI + send_transaction(to=contract, data, value), or
    • contract.functions.fnName(...).buildTransaction(tx_params_with_value) followed by manual signing.

Two implementation quirks to remember from real mints:

  • Some mint functions use no arguments (mint()), others take your address (saveMint(address)).
  • In rare cases, passing arguments requires a list instead of a tuple. If the call fails with a type shape error, try the alternative—do not fight your ABI forever.

Legacy vs EIP‑1559: what changes in tx params?

In code you’ll handle both. The difference is specific and mechanical:

  • Legacy: gasPrice only.
  • EIP‑1559: maxFeePerGas and maxPriorityFeePerGas.

Where the numbers come from:

  • Priority fee: await web3.eth.max_priority_fee gives you a sane default; override if needed (even 0 is valid if you don’t care about speed).
  • Base fee: read the latest block dict and take baseFeePerGas.
  • Max fee: baseFeePerGas + maxPriorityFeePerGas.
  • Some networks don’t support 1559 (e.g., your call might fail on BSC). Add a boolean switch (e.g., eip_1559: bool = True) to pick the correct mode at runtime.

What about proxies, headers, and “why is maxPriorityFee failing?”

A clean client includes infrastructure plumbing:

  • Provider headers and proxies: when creating AsyncHTTPProvider, pass request_kwargs with:

    • proxies: your http://user:pass@ip:port, ensure the scheme is present.
    • headers: inject a randomized User-Agent using fake_useragent. It’s cheap entropy that reduces fingerprinting. POA middleware: some networks require injecting async_geth_poa_middleware so fee endpoints and headers behave as expected. If a fee call suddenly fails on a side chain, this is often why. How do you verify a transaction deterministically? A minimal verify_tx method:
  • Accepts tx_hash: HexBytes and timeout_s: int.

  • Calls wait_for_transaction_receipt.

  • If status == 1, returns tx_hash.hex().

  • Otherwise raises a Web3 exception that includes the hash and exposes the underlying error.

It’s simple, but it pushes errors to the surface quickly and lets you build retry and alerting around it.

How do you price tokens without deploying an oracle?

Use the exchange you’re already using to swap: fetch a spot price through a REST call and compute amountOutMin locally.

A pragmatic method:

  • Async GET to Binance depth: https://api.binance.com/api/v3/depth?limit=5&symbol={SYMBOL}USDT
  • Parse asks[0][0] as the price.
  • Special‑case wrapped symbols: map WETH → ETH, WBTC → BTC. F- or stables (USDT, USDC, DAI, etc.), return 1.0.

Two rules that prevent foot‑guns:

  • Implement a small retry loop (e.g., 5 attempts) with a tiny delay. Then raise ValueError. Infinite loops are a time bomb.
  • Uppercase the symbol; normalize inputs so eth, Eth, and ETH all behave the same way.

How do you compute a safe amountOutMin?

The formula for a basic slippage guard is straightforward and good enough for retail sizes:

  • Native→token:
    • amountOutMin = amount_native * native_price_usdt * (1 - slippage/100) * 10**token_decimals
  • Token→native:
    • amountOutMin = amount_token * token_price_usdt * (1 - slippage/100) * 10**native_decimals Then round or floor to an integer. The goal isn’t penny‑perfect pricing; it’s to prevent terrible fills if the pool moves between signing and inclusion.

How do you build QuickSwap swaps end to end?

Two calls cover 95% of what you need:

  1. Native → token (swapExactETHForTokens)
  2. Build the path: [WRAPPED_NATIVE, WETH, TARGET_TOKEN] if a two‑hop path is needed (check the explorer logs for the route used).
  3. Compute amountOutMin as above.
  4. Set value to the native input amount, in Wei.
  5. Deadline: int(time.time()) + 20*60. If the explorer shows doubled numbers, multiply by 2 to match the contract expectations.
  6. Build with contract.functions.swapExactETHForTokens(...).buildTransaction(...), sign, send, verify.

  7. Token → native (swapExactTokensForETH)

  8. Approve first on the token contract: approve(router_address, amount_in). Wait for success.

  9. Path is reversed: [TOKEN, WETH, WRAPPED_NATIVE].

  10. value = 0.

  11. Same amountOutMin and deadline rules.

  12. Build, sign, send, verify.

Tiny but expensive mistake to avoid: approval is in smallest units. If USDC.decimals == 6, then approving “1.5 USDC” means passing 1.5 * 10**6 (as an integer) to approve.

What’s a retellable framework for on‑chain write calls?

Use 4C to keep yourself honest:

  • Client: a single object with web3, account, RPC, headers/proxy, and a toggle for EIP‑1559.
  • Contract: a ChecksumAddress and a minimal ABI scoped to the function(s) you need.
  • Calldata: exact function name, strict argument types/order, and the encoded blob via encodeABI.
  • Confirmation: a signed send followed by wait_for_transaction_receipt with a clear success/failure contract.

And here’s a second framework that helps avoid unit mistakes: AAA

  • ABI: verify mutability (view, nonpayable, payable), correct return types, and input names only to the extent they help you.
  • Address: checksum every address (to, from, spender, owner).
  • Amount: always in smallest units (value in Wei, token amounts * 10**decimals).

A step‑by‑step checklist for beginners

Use this as your first automation run—from “hello chain” to a clean approve/mint/swap.

  1. Wire up your client
  2. Create a Client(rpc, private_key).
  3. Inside: build AsyncWeb3, inject POA middleware if needed, configure request_kwargs with headers and proxies (randomize User-Agent).
  4. Derive LocalAccount via from_key. Confirm client.account.address returns your wallet.

  5. Send your first “approve”

  6. Build a minimal token ABI with decimals, balanceOf, allowance, approve.

  7. Read decimals, balance and compute the integer amount you plan to approve.

  8. Read allowance(owner, spender). If it’s sufficient, skip; else proceed.

  9. data = token_contract.encodeABI(fn_name='approve', args=(spender, amount))

  10. tx_hash = await client.send_transaction(to=token_address, data=data, value=0)

  11. await client.verify_tx(tx_hash, timeout_s=200)

  12. Mint an NFT

  13. Read fee/mintPrice via a read call on the minting contract.

  14. If the method is mint(), no args; if it’s saveMint(address), pass your address (list vs tuple if the ABI complains).

  15. Build data with encodeABI (or buildTransaction on the contract function).

  16. value = mint_price (in Wei).

  17. Send, then verify_tx.

  18. Swap native → token

  19. Load the QuickSwap router ABI and address for your chain.

  20. Compute amountOutMin using a token price (via your Binance depth helper) and slippage.

  21. Build the path [WRAPPED_NATIVE, WETH, TARGET_TOKEN] if needed.

  22. Deadline int(time.time()) + 1200 (tweak by *2 if required by the contract).

  23. Build/sign/send a swapExactETHForTokens, attach the native value.

  24. Verify receipt.

  25. Swap token → native

  26. Approve the router for amount_in.

  27. Reverse the path to [TOKEN, WETH, WRAPPED_NATIVE].

  28. Compute amountOutMin, set value = 0.

  29. Build/sign/send swapExactTokensForETH.

  30. Verify.

  31. Toggle EIP‑1559 when supported

  32. If eip_1559=True: compute maxPriorityFeePerGasand baseFeePerGas, set maxFeePerGas = base + priority.

  33. Else (legacy): set gasPrice.

  34. Refactor into a tidy structure

  35. Put ABIs in data/abis/.

  36. Put token ABIs or simple models in data/models/.

  37. Keep Client in client.py.

  38. Keep glue/flows in main.py.

  39. Put helpers like get_json(path) in utils.py.

  40. Fail fast, don’t stall

  41. Add reasonable timeouts to receipt waits.

  42. Implement finite retries (not infinity) for HTTP calls like price fetches, then raise an error you can see and handle.

  43. Log tx_hash.hex() on both success and failure paths, so you can jump to the explorer instantly.

Non‑trivial edge cases you only learn by shipping

  • Gas estimation is usually right but sometimes underestimates. Multiply by a safety factor (e.g., 1.2). You won’t pay for unused gas.
  • Don’t trust your intuition about value. Verify on the explorer once, then encode exactly the same fields in your automation.
  • Function selectors are brittle by design. Keep your ABI definitions minimal but correct, and always pass arguments in the expected order and type.
  • Some calls insist on argument containers (tuple vs list). If you hit a confusing type error, try the other one—this quirk exists in the wild.
  • Price protection matters more than you think. As soon as you move past toy sizes, a sane amountOutMin is the difference between “works” and “ouch.”

Final Thoughts

You now have the pieces to automate the three write actions you’ll perform most in EVM land: approve, mint, and swap. The difference between a fragile script and a dependable client comes from a few habits:

  • Treat your client as your “code‑Metamask.” One object owns RPC, account, EIP‑1559 toggle, headers, proxies, and a consistent way to sign/send/verify.
  • Keep ABIs minimal, precise, and local to the functions you actually call.
  • Make units explicit: Wei for value, token amounts times 10**decimals. Never pass floats.
  • Confirm receipts, raise on failures, and include the hash in every message.
  • Prefer a safety margin on gas and a slippage‑aware amountOutMin. Cheap insurance beats painful surprises.

The best time to turn these into muscle memory is now. Build your Client, ship your first approve, then mint and swap with confidence. Once you see your own hash settle in the explorer without manual clicking, you’ll realize: you’re no longer “using” the chain—you’re talking to it on your own terms.

Top comments (0)