Ethereum Transaction Lifecycle in Go: From Signing to "It's Really Money"
An Ethereum transaction is not finished just because we signed it or sent it to an RPC node.
There are several steps between creating a transaction and being confident that money has actually moved. Some of these steps are handled by Ethereum, but many others are our responsibility as backend developers.
In this post, I'll walk through the transaction lifecycle I worked on while upgrading my Go-based Ethereum explorer, blockchain-insight.
I'll focus on the practical problems: signing, gas estimation, nonce management, broadcasting, receipts, confirmations, and handling reorgs.
This is Part 2 of my Ethereum wallet and transaction series.
Part 1: Ethereum cryptography from a Go dev's view
1. Why: An Explorer Is Not an Exchange
Before this upgrade, blockchain-insight was mainly an Ethereum blockchain explorer.
It could read blockchain data, inspect blocks and transactions, and provide information about the network.
But reading blockchain data and handling real money are two different problems.
An explorer answers questions like:
- What happened in this block?
- Who sent this transaction?
- What was the transaction value?
- Which events were emitted by a smart contract?
An exchange needs to answer different questions:
- Did we actually send the withdrawal?
- Can we safely retry it?
- Are we using the correct nonce?
- Was the transaction included in a block?
- Did the transaction execution succeed?
- Is a deposit safe to credit to a user's balance?
For example, imagine a user requests a withdrawal of 100 USDC.
Sending a transaction to Ethereum is only one part of the process. We also need to know whether the transaction was accepted, whether it was included, whether the contract execution succeeded, and whether the result is sufficiently confirmed.
A transaction hash alone does not prove that the withdrawal was successful.
That's the difference between building an explorer and building a system that handles money.
With blockchain-insight v2.0.0, I wanted to move beyond reading blockchain data and understand the complete transaction lifecycle.
2. Build and Sign a Transaction by Hand (EIP-1559)
Before sending a transaction, we need to construct it.
For this project, I used EIP-1559 transactions (type 2).
A transaction contains several important fields:
- Nonce: The sender's transaction sequence number.
- MaxPriorityFeePerGas: The maximum tip offered to the validator.
- MaxFeePerGas: The maximum total fee per unit of gas.
- GasLimit: The maximum amount of gas the transaction can consume.
- To: The recipient address.
- Value: The amount of ETH being transferred.
- Data: Additional input data, usually used when calling a smart contract.
- ChainID: Identifies the Ethereum network.
Where do these values come from?
The nonce comes from the sender's account and our local nonce management.
The fee values come from network fee information and our fee policy.
The gas limit is estimated using the transaction's execution requirements.
The recipient, value, and data come from the withdrawal request.
For a normal ETH transfer, Data is usually empty.
For an ERC-20 transfer, however, Value is usually zero and Data contains the encoded contract function call.
Understanding the fee cap
One thing I learned while working on cmd/tools/rawtx was that the transaction's fee fields are not the same as the final gas price.
EIP-1559 separates the fee into two parts:
- Base fee: The network fee, which is burned.
- Priority fee: The tip offered to the validator.
A common fee estimation strategy is:
maxFeePerGas = 2 * baseFee + priorityFee
Why multiply the base fee by two?
Because the base fee can increase when blocks are congested. Using twice the current base fee provides some room for future increases while the transaction waits to be included.
This is a fee estimation strategy, not a mandatory Ethereum rule.
The actual amount paid is calculated using the base fee of the block containing the transaction and the available priority fee, subject to the transaction's fee cap.
In other words, maxFeePerGas is a maximum, not necessarily what we will pay.
Why build a raw transaction?
Using a high-level library or an abigen binding makes interacting with contracts easier.
For example, an ERC-20 binding can generate the calldata for a transfer function.
But it can also hide some of the lower-level details.
Building a transaction manually helped me understand:
- How the nonce is assigned.
- How calldata is constructed.
- How gas and fee fields are selected.
- How the unsigned transaction is signed.
- How the signed transaction becomes raw bytes.
A useful lesson here is that knowing how to use a binding is not the same as understanding the transaction being sent.
3. Nonce: Two Withdrawals, One Nonce
Nonce management is one of the most important parts of a withdrawal system.
Ethereum requires transactions from the same account to use sequential nonces.
For example:
- Transaction A uses nonce 10.
- Transaction B uses nonce 11.
- Transaction C uses nonce 12.
Now imagine two withdrawal requests arrive almost simultaneously.
Both workers ask Ethereum for the account's next nonce.
If both receive nonce 10, they may create two different transactions with the same nonce.
This is a problem.
The network cannot include both transactions as separate sequential transactions from the same account. One may replace the other if its fee is sufficient, or one may remain pending.
I broke this on purpose and saw it happen on Sepolia: one withdrawal went through, the other failed with replacement transaction underpriced. The full story is in this thread.
The problem with reading the nonce directly
Calling:
eth_getTransactionCount(address, "pending")
is useful because it includes transactions known to the node's pending state.
However, it is not a complete solution for concurrent workers.
Two workers can still read the same nonce before either transaction is broadcast.
That's why a withdrawal service needs its own nonce manager.
Mine is small (internal/nonce): it asks the node once, then gives out numbers from memory, under a mutex:
func (nm *NonceManager) NextNonce(ctx context.Context) (uint64, error) { nm.mu.Lock() defer nm.mu.Unlock() if !nm.initialized { nonce, err := nm.provider.PendingNonceAt(ctx, nm.addr) if err != nil { return 0, err } nm.nextNonce = nonce nm.initialized = true } nonce := nm.nextNonce nm.nextNonce++ return nonce, nil } The important point is that reserving a nonce must be atomic.
If two workers request a nonce simultaneously, they must receive different numbers. After the fix, the same two requests got nonces 7 and 8, and both were mined in the same block.
What if a transaction is signed but never sent?
This is an interesting case.
Signing a transaction does not change the Ethereum account's on-chain nonce.
Ethereum does not know about a transaction that was only signed locally.
Suppose we reserve nonce 10 and sign a transaction, but never broadcast it.
The network still considers nonce 10 available.
However, our application may have already reserved nonce 10.
If we then send a different transaction using nonce 11, it cannot be processed before nonce 10.
This can create a nonce gap and block later transactions.
That is why my nonce manager has a Reset: when a tx that took a nonce may never reach the network, the next call reads the nonce from the node again. A bigger system would track every reservation and its transaction state, not just read a number from an RPC node.
4. Broadcast Is Not Done
Once a transaction is signed, we can broadcast it to an Ethereum node.
But broadcasting and completing a transaction are different events.
A common mistake is to treat a successful RPC response as proof that the money has moved.
It isn't.
A successful broadcast usually means the node accepted the transaction for propagation or pending processing.
It does not mean the transaction has been included in a block.
Why can a broadcast error be misleading?
Imagine our application sends a signed transaction.
The Ethereum node receives it, but the network connection times out before our application receives the response.
Our application sees an error.
But did the node receive the transaction?
Possibly.
The transaction may already be in the node's mempool, even though our application never received the transaction hash response.
This means:
A broadcast error does not necessarily mean the transaction was not broadcast.
Blindly creating and signing another transaction with a new nonce could result in duplicate withdrawals.
Sign → Save → Broadcast
A safer approach is:
- Build the transaction.
- Sign it.
- Save the signed transaction and its hash.
- Broadcast the saved transaction.
Why save it first?
Because if the application crashes after signing or broadcasting, we still have the exact transaction bytes.
We can retry broadcasting the same signed transaction instead of creating a completely new transaction.
For example:
Withdrawal request | v Build transaction | v Sign transaction | v Save signed transaction | v Broadcast transaction | v Track transaction hash What about idempotency?
Imagine a client sends a withdrawal request with an idempotency key.
The request reaches our server, but the client times out and retries.
If we process the second request as a completely new withdrawal, we might send the money twice.
Instead, we should associate the idempotency key with the original withdrawal.
When the same request arrives again, we return the existing withdrawal result rather than creating another withdrawal.
The idempotency key protects the application-level operation.
The saved signed transaction protects the blockchain-level broadcast retry.
These are related, but they solve different problems. (In my code both live in internal/api/send.go.)
5. The Receipt
After broadcasting, we need to track the transaction until it is included in a block.
This is where eth_getTransactionReceipt becomes important.
A receipt contains information about the execution of a transaction.
It includes fields such as:
- Transaction hash
- Block number
- Block hash
- Gas used
- Effective gas price
- Execution status
- Logs
What does NotFound mean?
If we query for a receipt and get NotFound or null, it means the node currently has no receipt for that transaction.
It does not necessarily mean the transaction failed.
Possible explanations include:
- The transaction is still pending.
- The transaction has not reached this node.
- The transaction was dropped.
- The transaction was replaced.
- The transaction was not successfully broadcast.
A pending transaction does not have a receipt yet.
Status 1 vs Status 0
For modern Ethereum receipts:
-
Status = 1: The transaction execution succeeded. -
Status = 0: The transaction execution failed.
But what does failure mean?
Consider an ERC-20 transfer.
The transaction might be included in a block, but the smart contract could revert during execution.
In that case, the transaction has a receipt with Status = 0.
It failed, but it was still included in the blockchain.
Does a failed transaction cost gas?
Yes.
The sender still pays for the gas consumed during execution, even if the transaction reverts.
The transaction also consumes its nonce.
This is an important distinction:
A failed transaction is not the same as a transaction that was never included.
For an exchange, this means we must not mark a withdrawal as successful just because it has a transaction hash or a receipt.
We must check the receipt status.
6. The Other Side: When Is a Deposit Really Money?
Withdrawals are only one side of an exchange.
Deposits introduce another important problem: when can we safely credit a user's balance?
Imagine someone sends 100 USDC to one of our deposit addresses.
Our blockchain watcher detects the ERC-20 transfer event.
Should we immediately add 100 USDC to the user's exchange balance?
Not necessarily.
The block containing that event could be removed from the canonical chain during a reorganization.
Why wait for 12 confirmations?
A confirmation means that a block containing our transaction has additional blocks built on top of it.
In my code, a deposit in block 100 is checked when the chain head reaches block 112: head − block_number >= 12, so 12 blocks are on top of it.
Waiting for more confirmations makes a reorganization affecting the deposit less likely.
In this project, 12 confirmations are the chosen policy (CONFIRMATIONS, default 12).
This is a risk-management decision, not a universal Ethereum rule or a guarantee of absolute finality.
Why check the same block hash?
Counting confirmations is not enough.
Suppose our watcher detects a deposit in block 100.
It stores:
- Block number: 100
- Block hash:
0xABC...
Later, a reorganization replaces block 100 with another block at the same height.
The new block might have a different hash.
If we only check the block number, we could incorrectly assume the original deposit is still valid.
That's why we also check the block hash.
The block number tells us the position in the chain.
The block hash tells us which exact block we are talking about.
We should verify that the block at the original height still has the expected hash.
What happens during a reorg?
A reorganization happens when the canonical chain changes.
Imagine this sequence:
Deposit detected | v Pending deposit | v 12 confirmations | v Block hash verification | v Confirmed deposit | v Credited to user Now imagine the block containing the deposit is removed before the deposit is credited.
The deposit should no longer be treated as a valid pending confirmation.
My tracker (internal/confirm) asks the node for the transaction receipt. If the transaction is now in another block, the deposit moves there and counts its confirmations again. If it is in no block, or it failed, the deposit becomes reverted.
If the deposit was already credited, the system needs a compensating accounting operation according to its ledger and risk policy. (I haven't built this part yet: it is next on my roadmap.)
We should not simply delete the original database row.
Why?
Because financial systems need an audit trail.
Deleting the row would remove evidence that the deposit was previously detected and processed.
Instead, we should preserve the original record and update its state or create a compensating ledger entry.
Confirmed vs Credited
These two statuses represent different events.
Confirmed means the deposit has passed the required blockchain checks.
Credited means the exchange has actually added the corresponding amount to the user's internal balance.
Why separate them?
Because blockchain confirmation and internal accounting are different responsibilities.
A deposit can be confirmed while the crediting operation is still waiting to be processed.
Separating these states also makes recovery easier if a worker crashes.
Why use one database transaction?
Imagine our application does two operations:
- Marks a deposit as credited.
- Adds the deposit amount to the user's balance.
What happens if the first operation succeeds but the second fails?
We would have a deposit marked as credited without the user receiving the money.
The opposite problem is also possible: the balance increases, but the deposit still appears uncredited.
Both are dangerous.
These database changes should happen atomically.
Either both operations succeed, or neither does.
In my ledger it looks like this (inside one transaction):
-- 1. only a confirmed deposit can become credited: this must change exactly 1 row UPDATE deposits SET status = 'credited', credited_at_block = $2 WHERE id = $1 AND status = 'confirmed'; -- 2. add the amount to the balance (create the row if it is the first deposit) INSERT INTO balances (user_id, token, amount) SELECT user_id, token, amount FROM deposits WHERE id = $1 ON CONFLICT (user_id, token) DO UPDATE SET amount = balances.amount + EXCLUDED.amount; The WHERE status = 'confirmed' is what makes it safe: if two workers try to credit the same deposit, the second one waits for the row lock, then changes 0 rows, and the code stops.
The exact implementation can vary, but the accounting invariant must remain the same.
7. Three Things I'd Tell Myself at the Start
If I were starting this project again, these are three things I would remind myself of:
1. A transaction hash is not proof of success.
Signing, broadcasting, inclusion, execution success, and confirmation are different stages. Always track them separately.
2. Nonce management is an application-level responsibility.
Reading the nonce from Ethereum is not enough when multiple workers can send transactions from the same account.
3. Financial operations need to be recoverable.
Applications crash. RPC requests time out. Transactions get replaced. Blocks get reorganized.
A good backend should be able to recover without sending money twice or losing track of an operation.
Building an Ethereum transaction service taught me that interacting with a blockchain is not just about calling RPC methods.
It is about designing a reliable system around an unreliable environment.
And that is what makes the difference between sending a transaction and building software that can safely handle money.
Source code: blockchain-insight on GitHub · v2.0.0


