Crypto OS
Technical Crypto OSStage 3 · Full-stack Onchain Application

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:

StateMeaningWhat the interface should doTerminal?
Awaiting signatureWallet request sent; waiting for userPrompt wallet confirmation; disable buttonNo
BroadcastHash received; transaction in mempoolShow hash and explorer link; allow speedupNo
Pending confirmationIncluded in a block; too few confirmationsShow confirmation progressNo
SuccessfulReceipt status indicates success; enough confirmationsShow result and refresh dataYes
FailedReceipt status indicates failureExplain cause and next stepYes
ReplacedAnother transaction with same nonce landedTrack new hash or explain cancellationYes

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:

  1. Idle
  2. AwaitingSignature
  3. Broadcast
  4. Pending
  5. Confirmed
That is the easy path. Most engineering work lies in branches from each point.

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:

FromTriggerTo
IdleUser clicksAwaiting signature
Awaiting signatureTransaction hash receivedBroadcast
Awaiting signatureUser rejectsIdle, not failed
Awaiting signatureWallet errorFailed
BroadcastIncluded in a blockPending confirmation
BroadcastTimed out and hash cannot be foundFailed (dropped)
BroadcastAnother transaction with same nonce landsReplaced
Pending confirmationSuccessful receipt and enough confirmationsSuccessful
Pending confirmationFailed receiptFailed (reverted)
Pending confirmationParent hashes reveal a reorgBack to broadcast
SuccessfulConfirmation count retreats after a reorgBack 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 situationWhat to tell usersWhat they can do
User rejectsSay nothing and return to idleClick again if desired
Insufficient fee balanceNot enough native token to cover feesOpen funding flow
Execution revertsTransaction could not complete; fee was spentRetry after seeing likely reason
Slippage exceededPrice moved beyond your chosen toleranceAdjust tolerance or refresh quote
Long wait for inclusionNetwork is congested; transaction remains queuedSpeed up or cancel
ReplacedYour new transaction replaced this oneView 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

Transaction State MachineTransaction state machine

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.

Optimistic UIOptimistic UI

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.

Error MappingError mapping

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.

Reorg HandlingReorg handling

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.

ReplacementTransaction replacement

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.

ConfirmationsConfirmations

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

Hands onImplement a full six-state transaction state machineAny frontend framework + testnet + a block explorerFree; use testnet throughout

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

AI LabAsk AI to enumerate transaction errors and write clear messages, then inspect each for misleading claimsLevel 2 · AI Copilot

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

Broadcast was shown as success

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.

The interface spun forever after speedup

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.

Optimistic update without rollback

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 signing callback was lost when switching apps

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

What ifUse a chain with subsecond block times

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.

What ifThe user has a smart contract wallet that batches actions

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.

What ifAnother party pays the user's fee

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.

What ifThe product must support offline signing

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

4
Who is the user?

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.

5
Who is paying?

Who pays? Users still pay fees for failed execution. State that in failure messages. Otherwise, they may wonder where the money went.

9
Who bears the risk?

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

Key idea

A crypto frontend is fundamentally a transaction state machine, not a pile of pages.

Finished this chapter’s lab? Mark it completeView all progress

On this page