Skip to content
Blockchain Association of Cameroon
BAC-L1-FOUND-1194 h

TypeScript: the front end of a chain application

Building the interface that connects a wallet, builds a transaction and follows its status — without ever touching the private key.

  • Connect a wallet and request a signature
  • Track a transaction's lifecycle through to finality
  • Make pending and failure states legible

A chain application's front end is the only place the user sees, and the only one where a presentation mistake costs them money. This workshop teaches you to write that front end in TypeScript: represent amounts without losing a digit, connect a wallet and request a signature without ever coming near the private key, follow a transaction from the click to finality, and make waiting and failure states legible. You build on the RPC requests of the previous workshop. The browser cannot run the TypeScript compiler or a real wallet: the online exercises are readings of faulty code and console output; the real interface is built in the independent lab.

Lessons 1/4 · 15 min

TypeScript and amounts: not losing a digit

A transfer form that lies

A developer writes a transfer form. The user types an amount, the code converts it to a number, sends it. Everything works in tests with small amounts. Yet in JavaScript, 0.1 + 0.2 gives 0.30000000000000004, and a large amount can be rounded with no message at all. When it is money, a silent error is the worst kind.

TypeScript is JavaScript with types added: labels (number, text, list…) checked by a compiler before execution. tsc --noEmit runs that check alone. Types disappear at run time: they catch bugs while you write, but do not protect against malformed data arriving from the network. Vite is the tool that serves the page during development and bundles it for production; it turns TypeScript into JavaScript without checking it, so type checking remains a step you run yourself.

The number trap

An ordinary JavaScript number is a 64-bit floating-point number. Integers are exact there only up to 2^53 − 1 = 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER). Yet many chains count in very small units: a token with 18 decimals is worth 10^18 units, far beyond.

Example. A balance of 1,000,000,000,000,000,001 units (one token plus one unit):

console.log(Number(1000000000000000001n));   // 1000000000000000000
console.log(1000000000000000001n);           // 1000000000000000001n

With Number, the extra unit vanished, with no error. The `bigint` type (arbitrary-size integers, suffix n) keeps everything. Practical rules:

  • Represent an amount in the smallest unit as a bigint, never as a decimal number.
  • Mixing bigint and number in an operation throws an error (TypeError), which is a good signal.
  • JSON.stringify fails on a bigint: convert to text with toString() to send or store it.
  • Convert to "readable" decimal text only at the last moment, for display.

Converting what the user types

Avoid floating-point arithmetic: cut the string.

function toUnits(text: string, decimals: number): bigint {
  if (!/^\d+(\.\d*)?$/.test(text)) throw new Error("Invalid amount");
  const [whole, frac = ""] = text.split(".");
  if (frac.length > decimals) throw new Error("Too many decimals");
  return BigInt(whole + frac.padEnd(decimals, "0"));
}
// toUnits("1.5", 18) -> 1500000000000000000n
// toUnits("1.5.5", 18) -> error (the whole string is validated before any splitting)

First validate the whole string with a regular expression: otherwise a split that keeps only the first two pieces would accept "1.5.5" and silently read it as 1.5. The "too many decimals" case is refused rather than rounded: better a message than an amount different from what the user thinks they send. (bigint literals such as 1n need a compilation target of ES2020 or later, which is the case for recent Vite templates.)

Types that describe states

Type unions make it impossible to combine just anything:

type Wallet =
  | { kind: "disconnected" }
  | { kind: "connected"; account: string; chainId: number };

In the disconnected state there is no account field: the compiler refuses to read it. This is the basis of the next two lessons. Also turn on strict mode ("strict": true in tsconfig.json): without it many of these guarantees evaporate.

What this does NOT guarantee

  • Types are a promise made to the compiler. A JSON response received from the node is of unknown type until you have checked it: writing as Balance silences the compiler, not the problem.
  • An exact amount is not a reasonable amount: also validate the ceiling, the available balance and the address.
  • Form validation protects nothing on the server or chain side: the chain will accept or refuse anyway.

Recap: amounts as bigint in the smallest unit, text cut rather than computed, type unions for states.

Lessons 2/4 · 20 min

Connecting a wallet and requesting a signature

Who holds the key?

The principle that governs everything: your page never sees the private key. The wallet (a browser extension, a mobile app or a hardware device) keeps it. The page sends it requests; it shows the user what they are about to sign, and the user accepts or refuses. No legitimate interface asks to paste a private key or a recovery phrase: if yours did, that would be a flaw.

This workshop takes as its example the standard shape of Ethereum-style browser wallets, described by EIP-1193: a "provider" object with a request method. Wallets of other ecosystems have other libraries, but the general flow is the same: detect, request access, check the network, request a signature, handle refusal. In the Polkadot ecosystem, for example, wallet extensions go through another interface (enable the extension, read the accounts, obtain a signer), the network is usually identified by the hash of its genesis block rather than by a numeric chain id, and a refusal arrives as an error whose message you read, with no 4001 code. Details depend on the library and its version: read its documentation.

The flow, step by step

  1. Detect whether a wallet is present. If not, a "no wallet" state with a useful message (what to install, when).
  2. Request access to accounts. The user may refuse: in this standard, the error carries the code 4001. That is not a bug, it is a choice. Beware: some libraries wrap this error (for example with a text code such as "ACTION_REJECTED" and the 4001 nested in an inner field; check the documentation and version you use). So test several shapes rather than a single e.code === 4001.
  3. Read the network (the chain id) and compare it with the one your application expects. Another network is a state of its own: "wrong network".
  4. Request a signature only when the user acts (a click), never at page load.
  5. Listen for changes: the user may change account or network in their wallet at any time.
type WalletState =
  | { kind: "no-wallet" }
  | { kind: "disconnected" }
  | { kind: "connecting" }
  | { kind: "rejected" }
  | { kind: "wrong-network"; account: string; chainId: number; expected: number }
  | { kind: "connected"; account: string; chainId: number }
  | { kind: "error"; message: string };

Seven states, each with its own display. With a single "connected" boolean, half the cases would be confused.

Signing a message to log in

To prove to a server that one controls an account, you have it sign a message. The server supplies a single-use nonce (same idea as in the accounts workshop: preventing replay), an issue time and an expiry:

app.example asks you to sign in.
Account: <address>
Nonce: 8f3a9c21   (supplied by the server, single use)
Issued: 2026-10-12T08:00:00Z
Expires: 2026-10-12T08:05:00Z

The server verifies the signature, the nonce (never used), the expiry and the domain: the first line (here app.example) ties the signature to your site, and a message made for another domain must be refused, which limits phishing. A standard exists for this with Ethereum-style accounts: EIP-4361 ("Sign-In with Ethereum"), whose message also states the URI, the version and the chain id. Signing a login message costs no fee and sends no transaction.

Message signature or transaction: two different acts

Message to signTransaction
Effect on the chainnoneyes, if it is included
Feenoyes
Typical useprove control of an accounttransfer, call a contract

A known trap: some signed messages (off-chain authorisations) can grant a right without any visible transaction. See below.

Signature phishing: what the user sees versus what they sign

Your button text says "Claim the gift", but what the wallet asks to sign is a permission to spend the user's tokens, unlimited amount, in favour of an unknown address. If the user signs without reading, they have handed over their tokens. Real attacks follow this pattern.

What your code must do:

  • Build the readable text from the object that will actually be signed, never from two separate sources that can diverge.
  • Ask for the strict minimum: an exact amount rather than "unlimited", a short duration.
  • Prefer structured, typed data (such as EIP-712 for wallets that support it), which the wallet can show field by field, over unreadable raw bytes.
  • Protect the page itself: a compromised dependency or a tampered page can replace the request. Lock dependencies (as in the Docker workshop) and limit the scripts loaded.

What the user must do: sign nothing they do not understand, and check the address and amount on the wallet's screen, not on the page.

What this does NOT guarantee

  • "Connected" is not "authenticated": only the server's verification of a signature proves it.
  • A signature proves control of a key at one moment, not a person's identity.
  • Your good code does not protect a user who signs blind; it reduces what they can be made to sign by surprise.

Recap: the key stays in the wallet; seven states; single-use nonce; text built from what is signed.

Lessons 3/4 · 20 min

Following a transaction through to finality

The click is not the end

In an ordinary application, a click on "Send" calls a server that answers "done". On a chain, the immediate reply only says the transaction was submitted. What follows unfolds in several stages that the interface must show.

The stages of a transaction

  1. Preparing: the user fills in the form.
  2. Awaiting signature: the wallet has the hand. The user may refuse.
  3. Submitted: the node accepted the transaction and the application knows its hash. It sits in the pool of pending transactions (mempool). Nothing is decided yet.
  4. Included: a block contains it. Convention in this workshop: a transaction in the head block has 1 confirmation, and each block added on top adds one.
  5. Finalised: the chain considers it irreversible according to its finality rule.

The failure paths:

  • Refused by the user (before submission).
  • Rejected by the node at submission: nonce too low, fee too low, insufficient balance, wrong network (see the transactions workshop).
  • Dropped or replaced: left a long time in the pool with too low a fee, it is evicted, or replaced by another carrying the same nonce.
  • Included but failed: execution failed (for example a contract refused the operation). On Ethereum-style chains, the transaction is indeed in a block, the fee is generally paid, but the requested changes are undone. The receipt (the node's record of the outcome) carries a success or failure flag: "included" therefore does not mean "succeeded".
  • Removed by a reorganisation: the block that held it is no longer on the main chain.

Finality

Two families, not to be confused:

  • Probabilistic: the more blocks are piled on top, the less likely a reorganisation, though never impossible. There is no magic number: you choose a depth according to what you risk.
  • Deterministic: a validator voting mechanism declares a block final; beyond that, it cannot change unless validators betray their commitment, which is punished on some protocols. Some nodes expose a "finalised" tag; your interface must read that information, not guess it.

Worked example (fictitious chain): one block every 6 seconds, and "finalised" is displayed at 12 confirmations. Under the workshop convention the inclusion block already counts for 1: 11 more blocks are needed, i.e. 11 × 6 = 66 seconds after inclusion (roughly, since block times vary). On a chain with 12 seconds per block, 11 × 12 = 132 seconds. The interface should show progress ("5 of 12") rather than a spinning circle.

A typed state machine

type TxState =
  | { kind: "awaiting-signature" }
  | { kind: "submitted"; hash: string }
  | { kind: "included"; hash: string; confirmations: number }
  | { kind: "finalized"; hash: string }
  | { kind: "failed"; hash?: string; reason: "user-rejected" | "node-rejected" | "reverted" }
  | { kind: "dropped"; hash: string }
  | { kind: "replaced"; hash: string; by: string }
  | { kind: "reorged"; hash: string };

function label(s: TxState): string {
  switch (s.kind) {
    case "awaiting-signature": return "Confirm in your wallet";
    case "submitted": return "Sent, not yet in a block";
    case "included": return "In a block (" + s.confirmations + " confirmations)";
    case "finalized": return "Finalised";
    case "failed": return "Failed";
    case "dropped": return "Dropped by the network";
    case "replaced": return "Replaced by another transaction";
    case "reorged": return "Removed by a reorganisation, waiting for a new inclusion";
  }
}

If you add a new state to the union, the compiler flags the switch that does not handle it: in strict mode, with an explicit return type (string here), it rejects a function that may return nothing. For a function that returns nothing, or to get a clearer error, you add a default branch that requires the type never (see lesson 4). That is the main benefit of typing states.

Keeping the thread

As soon as the hash is known, record it in the browser (for example localStorage, with no secret, under a storage key that includes the account address and the chain id, so another account does not resume the wrong transaction): if the user reloads the page or loses their connection, the application resumes tracking. To track, query the receipt for the hash or subscribe to new blocks (previous workshop), with recovery after a drop.

What this does NOT guarantee

  • A hash obtained does not prove the transaction will be included.
  • A confirmation count is not a guarantee: it is a probability that decreases.
  • Your interface reports what its node says; a node that is behind or misinformed can be wrong.

Recap: submitted, included, finalised are three different statements; "included" is not "succeeded".

Lessons 4/4 · 15 min

Making waiting and failure legible

An honest interface says three things

At each state, the user must be able to read: what happened, what is known and what is not, and what they can do now. Example for a transaction left waiting a long time:

"Transaction sent 3 minutes ago, not yet in a block. It can still be included: the network may be busy. Do not resend it right away. Number: 0x… [Copy] [View in explorer]"

By contrast, three classic faults:

  • Displaying "Success" as soon as submission succeeds. That is false: nothing is included.
  • A spinner that spins forever, with no time, no number, no way out.
  • A message like "Error: [object Object]" or a raw technical trace.

Translating errors

An error coming from the library is of unknown type in TypeScript (unknown in a strict catch). You recognise it, classify it, translate it:

type UserMessage = { title: string; hint: string; retry: boolean };

function describe(e: unknown): UserMessage {
  const code = typeof e === "object" && e !== null && "code" in e
    ? (e as { code: unknown }).code
    : undefined;
  if (code === 4001) {
    return { title: "You refused the request", hint: "Nothing was sent.", retry: true };
  }
  return { title: "Unexpected failure", hint: "Try again; if it persists, note the time.", retry: true };
}

The errors your application must be able to name, in order of frequency in practice: user refusal, wrong network, insufficient balance (for the amount and the fee), fee too low, nonce already used, node unreachable, transaction failed at execution. For each, a clear sentence and an action. Keep the technical detail reachable ("Show details") for people helping a user.

Honest waiting

  • Immediate: from the click, the interface reacts (button disabled, text "Waiting for your wallet"). On a slow connection, this is the difference between "it worked" and "I click again".
  • No double sending: disable the button while a request is in progress, and keep the current request in state to resume it.
  • Duration: show elapsed time, and the expected duration when known.
  • Overrun: after a delay, change the message without concluding wrongly: "No confirmation after 3 minutes. The transaction may still be included."
  • Not only colour: a red or a green is not enough (colour blindness, screen in full sunlight). Add text and an icon with clear meaning.
  • Label optimism: if you update the displayed balance before confirmation, mark it "pending".

Offline and slow

Many of your users are on a mobile connection that drops now and then. Plan a visible "offline" state, automatic resumption when the network returns, and a light application: load the transfer page's code only when it is opened.

A compiler that forces handling everything

function assertNever(x: never): never {
  throw new Error("Unhandled state: " + JSON.stringify(x));
}
// in a switch, the default branch calls assertNever(state)

When someone adds the state "replaced" and forgets the matching display, compilation fails: the omission is caught before the users.

What this does NOT guarantee

  • A nice message does not prevent failure; it keeps the user from making things worse (sending twice, giving up wrongly).
  • Translating an error without understanding it disguises it: test each message against the real cause (see the lab).
  • A displayed "finalised" state is only as reliable as the source declaring it.

Recap: say what happened, what is known, what can be done; never announce a success early.

Guided lab

The browser can run neither the TypeScript compiler nor a wallet: these steps are reading and diagnosis exercises on faulty code excerpts and console output. It is a real skill: before writing an interface, you must recognise a silently rounded amount, a false "success", a misleading error message. The excerpts are short and realistic; the complete transfer interface with status tracking is the independent lab.

A browser is no substitute for a real terminal: this lab trains reading and reasoning; the real hands-on practice is in the independent lab.

A certified account is required

Graded activities record results in your name and are the basis for a credential. They therefore need a complete certified file and a current consent. Filling in the file does not verify your identity; the Secretariat can verify it afterwards ("Verified" badge).

  1. 1.Find the defect in an amount

    An interface displays a user's balance. The node returns 1000000000000000001 units (one token plus one unit, 18 decimals). The code and its output:

    const raw = 1000000000000000001n;
    const shown = Number(raw) / 1e18;
    console.log(shown);        // 1
    console.log(Number(raw));  // 1000000000000000000

    Answer the question in the answer grid: what is the defect and what is the right approach?

  2. 2.Find the false success

    Here is a colleague's "Send" button handler.

    async function onSend() {
      setStatus("signing");
      const hash = await wallet.sendTransaction(tx);
      setStatus("success");
      showToast("Payment made!");
    }

    Assume sendTransaction returns the hash as soon as the node has accepted the transaction. Answer the question in the answer grid: what is wrong in this code from the user's point of view?

  3. 3.Count confirmations

    Tracking log of a transaction (fictitious numbers). Workshop convention: a transaction in the head block has 1 confirmation.

    09:14:02  hash 0x7c…  submitted
    09:14:20  receipt: included in block 200
    09:14:56  head block: 205

    Answer the question in the answer grid: how many confirmations does the transaction have at 09:14:56?

  4. 4.Judge an error message

    An interface's error handler and what the user did:

    try {
      await wallet.sendTransaction(tx);
    } catch (e) {
      showError("Network error, check your connection.");
    }

    The user simply clicked "Reject" in the wallet window. The library threw an error carrying the code 4001. Answer the question in the answer grid: what is the problem and which behaviour is correct?

Security exercise — Booby-trapped signature: what you see, what you sign

A promotion page displays a big button: "Claim 10 free tokens — no fee". The user clicks. Their wallet opens a window that decodes the request as follows (fictitious addresses):

Page          : promo.example
Action        : Allow spending of your tokens (permit: off-chain signature)
Beneficiary   : 0xBBBB…0001  (unknown address)
Amount        : unlimited
Valid         : no expiry
Fee           : none (no transaction is sent)

The page text mentions neither a permission, nor a beneficiary, nor unlimited. The page claims "signing is free". An authorisation signed off-chain, of the "permit" kind (such as EIP-2612 for ERC-20 tokens), indeed costs no fee at signing time, but it grants a real right over the account's tokens. (A classic approve, by contrast, is a transaction sent on the chain, which costs a fee: that is not the case described here.)

You are the author of a legitimate application's interface that must request permissions, and you review this case to draw your rules from it. You must tell what protects the user from what does not: it is not the page that decides, it is what the wallet displays and what the user reads.

Answer-grid question: tick ALL the accurate statements.

Workshop quiz

Eight questions drawn from a larger pool, 70% to pass. It opens once every lab checkpoint is passed. You can retake it: the draw changes.

A certified account is required

Graded activities record results in your name and are the basis for a credential. They therefore need a complete certified file and a current consent. Filling in the file does not verify your identity; the Secretariat can verify it afterwards ("Verified" badge).

Independent lab (outside the site)

Not graded online: do it on your own machine and publish it to your Git repository.

Do this on your own machine, outside the site. Not graded online; the interface is assessed, among other things, on the clarity of its error states.

  1. Create a Vite project in TypeScript with strict mode. Choose the libraries (an EVM client such as ethers, or a Polkadot library) by reading their documentation, and note their exact versions in the README: this course imposes none.
  2. Use only a test account with test funds, created for the occasion. Never paste a private key or a recovery phrase into code, a file or a page.
  3. Implement the seven states of the WalletState type of lesson 2 (no-wallet, disconnected, connecting, rejected, wrong-network, connected, error) and react to the two events that make them change: account change, network change.
  4. Add a transfer form with exact amount conversion (bigint, smallest unit), refusal of extra decimals, and a balance check for the amount AND the fee.
  5. Write the transaction state machine (awaiting signature, submitted, included with counter, finalised, failed, dropped, replaced, removed by a reorganisation), with an exhaustive assertNever branch. Save the hash in localStorage and resume tracking after a reload.
  6. Trigger each failure (refusal in the wallet, wrong network, insufficient balance) and check that the message names the cause and offers an action.
  7. Use the browser developer tools' network throttling and cut the network: check the offline state, the resumption and the immediate visual feedback. Measure the size of the loaded page and reduce it.

Takeaways

  • Handle amounts as bigint in the smallest unit; Number rounds without error beyond 2^53 − 1.
  • The private key stays in the wallet; the page sends requests, and treats refusal (code 4001 on the EIP-1193 standard) and wrong network as normal states.
  • What the user reads must be built from what will actually be signed; a login message carries a single-use nonce and an expiry.
  • Submitted, included, finalised are three different statements; included is not succeeded, and a confirmation count is a probability.
  • Typed states and an exhaustive switch force every case to be displayed; a good interface says what happened, what is known and what can be done.

Common misconceptions

  • Misconception : Number is enough for amounts: if there were a problem, JavaScript would throw an error.

    In fact : Beyond 9,007,199,254,740,991, Number rounds silently. You need a bigint, in the smallest unit.

  • Misconception : Once the wallet has returned a hash, the payment is done.

    In fact : The hash proves submission. The transaction can still be dropped, fail at execution or be removed by a reorganisation.

  • Misconception : Signing a message is always harmless, since it costs no fee.

    In fact : An off-chain signature can grant a real right over tokens. The absence of a fee says nothing about what is authorised.

Glossary

bigint
JavaScript arbitrary-size integer type (suffix n), which loses no digit unlike Number beyond 2^53 − 1.
Wallet
Software or device that keeps the private keys, displays what is requested and signs only after the user agrees.
Chain id
Number that designates a specific network; it is included in what is signed so a transaction is not valid on another network.
Receipt
Record the node keeps of the outcome of an included transaction, with a success or failure flag.
Finality
Point from which a block is considered irreversible, probabilistically (confirmations) or deterministically (validator vote).