T14 · Web3 Frontend
What should the interface show while a transaction waits for confirmation?
- Capabilities practiced
- Builder
- Hands on
- Implement a complete transaction state machine: awaiting signature, broadcast, pending confirmation, succeeded, failed, replaced.
- AI Lab
- Have AI enumerate transaction failure error codes and write plain-language messages. Verify yourself that none of the wording misleads users.
A Real-World Problem
A user clicks “Swap.”
The wallet opens, they confirm, the pop-up closes, and the page shows a spinner labeled “Processing.”
Then come fifteen seconds. For someone used to apps, that is a long time: enough to wonder whether it is stuck, switch away and back, or click the button again.
During those seconds, your interface may know very little:
- Did the user sign? They may have rejected the wallet request while the page keeps spinning.
- The transaction was broadcast, but did it enter the mempool? Could a low fee leave it stuck?
- Did it enter a block? Does that mean it succeeded?
- The user clicked “Speed up” in the wallet. Is the hash you are tracking still useful?
Answer any of these incorrectly and the interface contradicts reality. The costliest mistake is showing success when the transaction failed.
This is more than a UI detail. A Web2 request typically succeeds or fails within milliseconds. An onchain transaction has several outcomes, intermediate states lasting seconds or minutes, and can even move backward between states. A conventional Web2 page model gets this wrong.
Thought Experiment
Treat a transaction as registered mail, not an HTTP request.
You drop a letter in the mailbox. From then on:
- You have a tracking number. Without it, you cannot look anything up.
- The letter waits at a sorting center. Higher postage moves it ahead; too little may leave it waiting for days.
- The letter is loaded on a vehicle. The vehicle is still traveling and may be delayed or turned back.
- The vehicle arrives. Only then do you learn the result: delivered, or delivery failed and it was returned.
The last point is critical and counterintuitive: arriving at the destination is not the same as successful delivery.
Onchain, inclusion in a block means miners or validators executed the transaction, not that execution succeeded. A transaction that errors during execution is still included and still consumes a fee. Its receipt records failure.
T1 introduced this once. It deserves repetition because it causes one of the most common frontend bugs:
To know whether a transaction succeeded, inspect the receipt’s
status, not merely whether it appeared in a block.
There is one thing registered mail cannot do: replace the letter while keeping its number. Onchain, only one transaction with a given nonce can ultimately land. “Speed up” and “Cancel” send a new transaction with the same nonce. The old hash may never produce a receipt.
An interface that waits only for that hash can spin forever.
Your Decision
How do you design the interface for this wait?
What Happens
The distinction is simple: the first three options draw screens; the fourth defines a model.
With screens alone, each new exception adds another if statement. After a few exceptions, no one can describe all possible outcomes. A state machine gives every exceptional situation a defined place.
Lay out the six transaction states and outcomes:
| State | Meaning | What the interface should do | Terminal? |
|---|---|---|---|
| Awaiting signature | Wallet request sent; waiting for user | Prompt wallet confirmation; disable button | No |
| Broadcast | Hash received; transaction in mempool | Show hash and explorer link; allow speedup | No |
| Pending confirmation | Included in a block; too few confirmations | Show confirmation progress | No |
| Successful | Receipt status indicates success; enough confirmations | Show result and refresh data | Yes |
| Failed | Receipt status indicates failure | Explain cause and next step | Yes |
| Replaced | Another transaction with same nonce landed | Track new hash or explain cancellation | Yes |
Two easily missed rules follow:
User rejection is not failure. Return quietly to idle, without a red error. Treating refusal as a mistake makes users feel wrong for changing their minds.
“Success” can move backward. Chain heads can reorganize (the final example in T1). If you show success before enough confirmations, a reorg can make that success false. Success is not truly terminal until your confirmation threshold is met.
Build a Model
The happy path is linear:
- Idle
- AwaitingSignature
- Broadcast
- Pending
- Confirmed
Write the states as types. The language matters less than the fact that different states carry different data. That is why a union works better than boolean flags:
type TxState =
| { kind: 'idle' }
| { kind: 'awaitingSignature'; intent: TxIntent }
| { kind: 'broadcast'; hash: string; nonce: number; submittedAt: number }
| { kind: 'pending'; hash: string; nonce: number; blockNumber: number; confirmations: number }
| { kind: 'confirmed'; hash: string; blockNumber: number; confirmations: number }
| { kind: 'failed'; hash?: string; reason: FailureReason }
| { kind: 'replaced'; originalHash: string; replacementHash: string; by: 'speedup' | 'cancel' | 'unknown' };
type FailureReason =
| 'userRejected' // Rejected by the user in the wallet; strictly speaking, not a failure
| 'insufficientFunds' // Not enough funds for the fee or transfer
| 'reverted' // Included onchain, but execution reverted
| 'slippage' // Quote expired or tolerance exceeded; one kind of revert
| 'nonceConflict' // Another transaction used this nonce
| 'dropped' // Mempool dropped it before inclusion
| 'unknown';With isLoading, isSuccess, and isError booleans, impossible combinations such as isLoading && isSuccess remain type-valid and will eventually appear. A union makes invalid states unrepresentable. It also gives a broadcast state a hash without forcing idle to carry an optional hash.
Transition Table
Each transition can be a separate test:
| From | Trigger | To |
|---|---|---|
| Idle | User clicks | Awaiting signature |
| Awaiting signature | Transaction hash received | Broadcast |
| Awaiting signature | User rejects | Idle, not failed |
| Awaiting signature | Wallet error | Failed |
| Broadcast | Included in a block | Pending confirmation |
| Broadcast | Timed out and hash cannot be found | Failed (dropped) |
| Broadcast | Another transaction with same nonce lands | Replaced |
| Pending confirmation | Successful receipt and enough confirmations | Successful |
| Pending confirmation | Failed receipt | Failed (reverted) |
| Pending confirmation | Parent hashes reveal a reorg | Back to broadcast |
| Successful | Confirmation count retreats after a reorg | Back to pending |
The last two rows justify this table: the state machine must allow backward moves. A forward-only machine cannot correct a false success after a reorg.
Three Easy Mistakes
First, track primarily by nonce, secondarily by hash. The hash changes when users speed up or cancel; the nonce does not. If an address’s confirmed nonce has passed your transaction’s nonce but the hash has no receipt, it was replaced. Find the actual transaction at that nonce and continue tracking it.
Second, tracking must survive interruption. Users refresh, close tabs, and change devices. Persist unfinished transactions locally and resume tracking on page load. An in-memory state machine loses everything on refresh.
Third, disable the button while awaiting signature, not only after broadcast. There can be seconds between these states, when repeated clicks create multiple wallet prompts. Duplicate submission is the predictable result of this gap.
Error Translation Table
Map raw errors to “plain-language explanation plus action.” The third column matters most:
| Underlying situation | What to tell users | What they can do |
|---|---|---|
| User rejects | Say nothing and return to idle | Click again if desired |
| Insufficient fee balance | Not enough native token to cover fees | Open funding flow |
| Execution reverts | Transaction could not complete; fee was spent | Retry after seeing likely reason |
| Slippage exceeded | Price moved beyond your chosen tolerance | Adjust tolerance or refresh quote |
| Long wait for inclusion | Network is congested; transaction remains queued | Speed up or cancel |
| Replaced | Your new transaction replaced this one | View the new transaction |
Two hard copy rules:
- Do not say “success” before a receipt. “Submitted,” “broadcast,” and “confirming” are accurate; “successful” is not.
- Do not say only “failed; retry.” Without a cause, users may repeat the same failed transaction and pay another fee.
What It Is Called
A finite set of states and explicit transitions describing a transaction’s full lifecycle.
It replaces boolean flags scattered across components. A good Crypto frontend has a clear model like this.
Shows the expected result before confirmation to make an action feel immediate.
Its required companion is rollback. On failure, replacement, or reorg, restore the prior view and tell users. Optimism without rollback is a well-meant falsehood.
Translates raw wallet, node, and contract errors into explanations and actionable next steps.
Keep one central mapping table instead of scattered catch blocks. Then it can be reviewed, tested, and reused.
When the chain head reorganizes, return a transaction previously shown as successful to an unconfirmed state and roll back optimistic changes if needed.
Frontend handling is simpler than backend handling: the state machine must permit backward transitions. T17 covers the harder backend case.
A new transaction with the same nonce and a higher fee displaces the old one. Wallet “Speed up” and “Cancel” both use it.
For frontends: the original hash may never have a receipt; use the nonce to learn what happened.
The number of blocks added after the block containing a transaction. It measures probabilistic security, not a simple yes/no.
How many are needed is a business decision. One may be enough to update an avatar; a large withdrawal may require dozens.
Hands-on
Use only faucet-funded test tokens. This lab deliberately creates failures and delays, so do not practice on mainnet.
The goal is a model in which you have observed all six states in real or controlled tests, not a polished screen.
Write types before components.
Bring the union type above into your project, then write a pure reducer: current state plus event produces next state.
The acceptance criterion: the reducer has no network calls. Purity makes every transition testable offline.
Connect the happy path through success.
Send a simple testnet transfer. Move to broadcast when you receive the hash, poll for its receipt, then enter success only after status indicates success and the confirmation threshold is met.
Check status explicitly. Add an assertion that throws if a failed status ever enters the success state. That line is the heart of the chapter.
Reach failure.
Construct a transaction that must revert. One easy route is to call a contract function with an argument that violates a requirement, such as withdrawing more than a balance.
The transaction is included onchain, consumes a fee, and has failed receipt status. Confirm that the interface says failed, not successful.
If it shows success, you have reproduced the industry’s common bug in your own code.
Reach replacement.
Send a transaction with a fee far below the current rate so it waits in the mempool.
Record its hash and nonce. In the wallet, click “Speed up” or “Cancel.” It sends a different transaction with the same nonce.
The interface should see that the address’s confirmed nonce passed your transaction’s nonce while your hash has no receipt. It should mark the transaction replaced and track the new hash.
If it keeps spinning, you tracked only hash and not nonce.
Return from awaiting signature to idle.
Initiate a transaction and reject it in the wallet.
The UI should quietly return to idle with the button enabled and no red error. Change it if it treats refusal as a failure.
Simulate a backward transition.
A reorg is difficult to create yourself, but you can send a “confirmation count retreated” event to the reducer. Verify that success becomes pending and that the success message changes back to “confirming.”
This is only a few lines of logic testing, but it proves the model’s rarest property: states can move backward.
Add a central error map and test refresh recovery.
Implement the table above in one place. Then send a transaction and refresh the page before confirmation.
It should resume tracking rather than disappear. If it vanishes, your state lived only in memory.
After seven steps, you have a state machine reusable across projects. It is one of the most reusable pieces of code in this stage.
AI Lab
Use two steps; the second is the useful one:
Step one:
List every failure a transaction can meet between construction and confirmation.
For each, give its trigger, what users observe, and the exact field that identifies it.
Include rejection, insufficient fee balance, revert, slippage, nonce conflict,
dropped transactions, and replacement by another transaction with the same nonce.
Step two:
Write a short plain-language message for each failure, no more than 20 words.
Explain what happened and one action users can take now. Then review each message:
could users mistakenly think the transaction succeeded, funds arrived, funds were spent,
or the transaction can still be canceled?The model may review its own copy well, but you must review it too. “Transaction completed” and “transaction submitted” sound similar yet differ by a receipt’s status field.
One likely omission is replacement. Many tutorials and documents omit it, so models may too. If it does, ask what happens to the old hash after a user speeds up a transaction in their wallet.
Verify these points yourself after using AI
- Did it equate inclusion onchain with success? Check every message for this critical error
- Does it say “successful,” “complete,” or “received” before a receipt exists?
- Did it treat user rejection as an error and show a red message?
- Does it cover replacement? Many models miss this state
- You verified at least one error code, field name, and condition against actual wallet and node responses
- Does each message offer an action users can really take, rather than merely “try again later”?
- Does it distinguish insufficient fee balance from insufficient token balance? Their remedies differ
Real-World Case
One of the most common bugs in new products.
A user sees green “Swap successful,” but the wallet shows no new tokens and a fee was charged. Receipt status indicates failure, yet the frontend never read it. It switched to success as soon as it got a hash.
The bug hides because the interface appears completely normal on happy paths. It surfaces only when execution fails, and developers rarely create a deliberately failing transaction.
That is why step three of the lab creates one on purpose.
A user speeds up a slow transaction in the wallet. It lands quickly, but the page keeps spinning.
Speedup uses the same nonce and a new hash. The frontend polls the old hash, which will never receive a receipt.
A worse variant gives up after several polls and says “transaction failed.” It actually succeeded, but the interface reports failure, making a second submission likely.
A transaction list immediately inserts “Swap successful” when the user clicks. The transaction later fails, but the item remains.
On the next visit, the refreshed list no longer contains it. The user believes the product lost a record rather than learning that the swap never succeeded.
Optimism without rollback creates distrust, not merely a temporary illusion.
A mobile user switches from browser to wallet app to approve, then returns. During the switch, the system may have discarded and reloaded the browser page.
The transaction was sent, but the page has no record of it. The user assumes nothing happened and sends another. Both may execute.
This is why state must persist: a reloaded page needs to recover that a transaction is in progress.
Change One Variable
The state machine does not change. Intermediate states simply last less time.
But one issue may matter more: faster blocks can have less weight individually, and head reorgs may be more likely. “Twelve confirmations” can therefore mean different security on different chains.
Confirmation thresholds must be configured per chain and business action, not set as one global constant.
One click becomes a transaction containing several actions. Two consequences follow.
The benefit: approval and operation can be atomic, saving a prompt and avoiding “approval succeeded but operation failed.”
The difficulty: the transaction has one receipt status, but multiple internal actions. Logs may be needed to determine which step failed. Your error model must grow from “one transaction, one error” to “one transaction, several results.” T27 goes deeper.
Users need not hold the native token, so “insufficient fee balance” disappears from their flow. That is a large UX gain.
But the state machine gains a preliminary step: will the sponsor pay for this transaction? It may decline, impose a limit, or fail during execution. These are new failure sources for your error map.
The sender tracked by nonce may no longer be the user address, so nonce tracking must change too.
A user signs on an offline device, then moves the signed transaction to an online device for broadcast.
An in-person transfer step appears between awaiting signature and broadcast. It could take minutes or days.
“Transaction expired” becomes common. The nonce was available when signed but may have been consumed by another transaction before broadcast. Longer time makes previously negligible races ordinary.
Takeaway Questions
Who is the user? Here, someone who may not care how a blockchain works but needs to know what happened to their money. They need no raw hex, but they deserve the true state, including failure.
Who pays? Users still pay fees for failed execution. State that in failure messages. Otherwise, they may wonder where the money went.
Who bears the risk? When an interface shows success for a failed transaction, users absorb a silent mistake and continue acting on false information. A state machine turns “maybe we display the wrong thing” into transitions that can be tested exhaustively.
Chapter Check
Failed execution is included in blocks and charges fees too; the receipt’s status records failure.
Show success only when the receipt status indicates success and the confirmation threshold is reached. Without both, “successful” is premature.
T1 introduced this. Here it becomes one line of code: read status.
The old hash may never have a receipt. Follow the nonce:
- Query the address’s confirmed nonce.
- If it has passed the original transaction’s nonce while its hash has no receipt, the transaction was replaced.
- Find what actually landed at that nonce, track the new hash, and explain the replacement.
Remember: hashes change, nonces do not. Track primarily by nonce.
No. Return quietly to idle, reenable the button, and show no red alert.
Treating refusal as an error burdens users with a feeling that they did something wrong. Refusal is a capacity we want them to retain, especially for an unclear signing request.
Do not punish caution.
Booleans allow invalid combinations. isLoading and isSuccess can both be true according to the type system, and eventually one branch will make that happen.
A union makes invalid combinations impossible to represent and forces each branch to handle the states. Each state can carry its own data: broadcast has a hash; idle does not need an optional hash.
There is no single prescribed diagram. Check:
- Are all six states present, especially replacement, which many designs omit?
- Can a reorg move “success” back to “pending”?
- Does refusal return to idle rather than failure?
- Does a transaction in progress survive page refresh?
- Does every failure path tell users what to do next?
Point 2 is least often considered and says the most about the completeness of the model.
Key idea
A crypto frontend is fundamentally a transaction state machine, not a pile of pages.