A lot of Ethereum deposit and payout code still carries assumptions from when gas was the dominant cost: hardcoded fee constants, minimums sized for twenty-dollar transfers, defaults that steer users away from mainnet. Through 2026, standard gas has mostly sat well under one gwei. An ERC-20 transfer costs cents.
That doesn't just make old code expensive. It makes some of it wrong. Here's what to revisit if you accept ETH or ERC-20 deposits.
1. Stop hardcoding gas prices and minimums
If a constant like this is still in your codebase, it's overcharging by two orders of magnitude:
# Written for a different fee era
PAYOUT_GAS_PRICE_GWEI = 40
MIN_DEPOSIT_USDT = 50
Since the London upgrade, fees are EIP-1559: a protocol-set base fee plus a priority tip. Estimate both from live data at the moment of payout:
def estimate_fees(w3, priority_percentile=50):
hist = w3.eth.fee_history(10, "latest", [priority_percentile])
base_fee = hist["baseFeePerGas"][-1] # next block's base fee
tips = [r[0] for r in hist["reward"]]
tip = sorted(tips)[len(tips) // 2]
# Allow the base fee to rise for a few blocks before the tx lands
max_fee = base_fee * 2 + tip
return {"maxFeePerGas": max_fee, "maxPriorityFeePerGas": tip}
Then derive minimums from the live estimate instead of a fixed value. A minimum sized for 40 gwei turns away users your economics could serve profitably today, and a minimum sized for 0.2 gwei will be wrong the next time the network gets busy. Recompute per quote.
2. Detect internal transactions, not just top-level transfers
The simplest deposit watcher checks each block for transactions whose to is a deposit address and whose value is non-zero. That misses ETH sent from smart contract wallets.
A Safe multisig, for example, executes a transfer through a contract call. On-chain, the ETH movement is an internal call, not a top-level transaction to your address. A naive watcher never sees it.
You have two reliable options:
-
Trace calls. Use
debug_traceTransactionortrace_block(provider-dependent) and look for value transfers to your addresses at any call depth. -
Watch balances. Poll
eth_getBalancefor deposit addresses at each block, and reconcile any increase against known transactions. Cruder, but it catches everything.
EIP-7702, introduced in the Pectra upgrade, lets ordinary accounts delegate to contract code. As adoption grows, the share of ETH deposits arriving through contract execution rather than plain transfers will grow with it. Plan for internal transfers to be normal.
ERC-20 deposits don't have this problem in the same way, because they emit a Transfer event regardless of who calls the token contract. Watch logs for those.
3. Use finality, not just confirmations
Ethereum produces a block every 12 seconds. Proof-of-stake adds a second signal: finality. A block becomes finalized after two epochs, roughly 13 minutes, at which point reverting it would require a large share of staked ETH to be destroyed.
Many services still use a confirmation count carried over from proof-of-work habits. You can do better with the block tags the JSON-RPC API exposes:
latest = w3.eth.get_block("latest")
safe = w3.eth.get_block("safe") # justified, very unlikely to reorg
finalized = w3.eth.get_block("finalized") # economically final
def is_credited(deposit_block, amount_usd):
threshold = safe if amount_usd < 10_000 else finalized
return deposit_block <= threshold["number"]
safe usually trails the head by a few minutes; finalized by around 13. Tiering by amount gives most users a fast credit and keeps large deposits on economic finality.
4. Treat L2 ETH as a different asset
ETH on mainnet, Arbitrum, Base and Optimism all use identical 0x addresses. A deposit address you generated for mainnet is, byte for byte, a valid address on every one of them.
Users will send L2 ETH to a mainnet deposit address. Your mainnet watcher won't see it. Two things help:
-
Model assets as
(chain_id, asset)pairs everywhere, so "ETH on Base" is never conflated with "ETH on mainnet" in quotes, orders or UI. - Optionally watch your deposit addresses on the major L2s too. You won't credit those deposits automatically, but you can flag them for support instead of having a user report "lost" funds days later.
5. Warn about the empty-gas-tank problem
A user swapping their entire ETH balance into USDT on Ethereum ends up with tokens they can't move, because they have no ETH for gas.
If your interface offers a "max" button for ETH, consider a soft default that leaves a small reserve, with an explicit override:
const RESERVE_WEI = estimateReserve(liveFees, 5); // ~5 future transfers
const maxSendable = balance - gasForThisTx - RESERVE_WEI;
At current fee levels the reserve is tiny. The point is that it isn't zero.
6. Never ask for approvals on a deposit flow
A deposit-address model is a plain transfer. It needs no approve, no permit, no signed message.
If your product uses deposit addresses, make that explicit in your UI copy. Users have learned that legitimate swaps sometimes ask for approvals (router-based DEX swaps do). A clear statement that yours never will gives them a way to spot clones impersonating you.
Checklist
- Fees estimated live with EIP-1559 fields; no hardcoded gwei
- Minimums derived per quote from live fees
- Deposit detection covers internal transactions
- Crediting uses
safe/finalizedtags, tiered by amount - Assets keyed by chain ID; L2 deposits to mainnet addresses flagged
- "Max" leaves a gas reserve by default
- Deposit flow never requests approvals or signatures
Disclosure: I work on SwapCore (swapcore.exchange), a non-custodial instant exchange. Several of these came from revisiting our own assumptions after Ethereum fees fell.
Top comments (0)