JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

The full stack of a dApp: wallet, RPC, contract, and frontend

One click to send a token touches a browser, a key vault, an RPC server, a gossip network, and an EVM — and comes back. This capstone wires every layer of the ladder into one working dApp.

One click, a dozen machines

You tap Send 1 USDC in a web app. A wallet pop-up slides up; you press Confirm; a spinner turns for about twelve seconds; a green checkmark appears. It feels like sending a chat message. It is not. In those twelve seconds your click crossed your browser, a key vault that never let go of your secret, a JSON-RPC server, a peer-to-peer gossip network, and a validator's EVM — then the answer travelled all the way back to repaint one number on the screen. Almost every idea in this whole ladder just ran, in order, to move one stablecoin.

A decentralized application (dApp) is not one program. It is a pipeline of five loosely-coupled layers, each of which you have already met: (1) a frontend in the browser, (2) a wallet that guards your keys and signs, (3) an RPC endpoint that is your door onto the network, (4) the chain itself — mempool, block production, EVM execution, finality — and (5) an indexer that reads the chain back into a shape your UI can query. This guide walks one user action through all five, with the real code and the real wire format at each hop, and is honest about where the 'decentralized' part quietly leans on centralized infrastructure.

Layer 1 — the frontend and the Web3 library

The frontend is an ordinary web app — React, a button, some state. What makes it a dApp is a Web3 library (ethers.js or the newer viem) that turns human intentions into the EVM's calling convention. Recall from the EVM rung that a contract call is just a blob of ABI-encoded calldata: a 4-byte function selector (the first bytes of keccak-256 of the signature) followed by 32-byte-padded arguments. The library does that encoding so you never write raw hex by hand.

The first thing to internalize is the split between reads and writes. A read (someone's balance, a pool price) costs nothing, creates no transaction, and needs no signature — it just runs the contract's code locally on a node via `eth_call`. A write (transferring a token) changes Ethereum's world state, so it must be a signed transaction that pays gas. viem models this as two clients: a `publicClient` for reads and a `walletClient` for writes.

import { createPublicClient, http, parseAbi } from 'viem'
import { mainnet } from 'viem/chains'

// A "public client" = a read-only connection to an RPC endpoint.
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http('https://eth-mainnet.g.alchemy.com/v2/<API_KEY>'),
})

// The ABI is the calling convention: names, argument types, returns.
const erc20 = parseAbi([
  'function balanceOf(address) view returns (uint256)',
  'function transfer(address to, uint256 amount) returns (bool)',
])

// A read is FREE: no transaction, no signature. It runs eth_call on the node.
const bal = await publicClient.readContract({
  address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC
  abi: erc20,
  functionName: 'balanceOf',
  args: ['0x1111...your address'],
}) // -> 12_500000n  (USDC has 6 decimals, so this is 12.5 USDC)
viem reading a balance: the ABI tells it how to encode the call; readContract issues eth_call and decodes the 32-byte result back into a bigint.

Layer 2 — the wallet: keys stay home, signatures travel

Here is the architectural decision that makes the whole thing safe: the dApp never sees your private key. A browser wallet like MetaMask (or a mobile wallet reached over WalletConnect) is a separate, sandboxed non-custodial vault. The frontend asks it to do things; the wallet shows you a human-readable prompt; only if you approve does it sign with the key. The key never crosses into the web page — which is why a malicious site can ask, but cannot steal, unless you click Confirm.

Wallets expose themselves through a tiny standard interface, EIP-1193: a single `provider.request({ method, params })` function. viem's `custom(window.ethereum)` transport wraps exactly that injected object. Connecting is just asking the wallet to reveal an account, which under the hood is the `eth_requestAccounts` RPC method — the call that triggers the familiar 'Connect wallet?' dialog.

import { createWalletClient, custom } from 'viem'
import { mainnet } from 'viem/chains'

// window.ethereum is the EIP-1193 provider injected by MetaMask.
const walletClient = createWalletClient({
  chain: mainnet,
  transport: custom(window.ethereum),
})

// Ask the wallet to expose an account -> triggers eth_requestAccounts.
const [account] = await walletClient.requestAddresses()

// A WRITE costs gas and needs a signature. The WALLET signs, not the dApp.
const hash = await walletClient.writeContract({
  account,
  address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
  abi: erc20,
  functionName: 'transfer',
  args: ['0x2222...recipient', 1_000000n], // 1 USDC (6 decimals)
})
// `hash` is the transaction hash. The token has NOT moved yet — it is
// only in the mempool. We wait for a receipt in Layer 4.
Submitting a write through an injected wallet. writeContract here calls eth_sendTransaction; MetaMask fills nonce + gas, signs, and broadcasts via its own RPC, returning the tx hash.

Inside that one `writeContract` call the wallet quietly does four jobs you studied in earlier rungs: it looks up your nonce (so the transaction can't be replayed or reordered), it estimates and sets the EIP-1559 fee fields (`maxFeePerGas` and `maxPriorityFeePerGas`), it serializes and RLP-encodes the transaction, and it signs the hash of that with your key using ECDSA over the secp256k1 curve. The signature is your authorization; anyone can verify it recovers your address, but no one can forge it without the key.

Layer 3 — the RPC endpoint: your door onto the network

A signed transaction is just bytes until some node accepts it. The JSON-RPC endpoint is that node's public API: a plain HTTPS server that speaks a small menu of methods. To broadcast, the wallet sends the signed, RLP-encoded blob to `eth_sendRawTransaction`; the node validates the signature and nonce, then gossips it into the mempool via peer-to-peer propagation. Crucially, the response is the transaction hash, not a receipt — the node is promising to try, not confirming inclusion.

# What actually goes over HTTPS to the RPC endpoint when you broadcast:
POST https://eth-mainnet.g.alchemy.com/v2/<key>
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "eth_sendRawTransaction",
  "params": ["0x02f8b0018203...<RLP-encoded signed EIP-1559 tx, type 0x02>"]
}

# The node replies with the transaction HASH (not a receipt -- it isn't mined yet):
{ "jsonrpc": "2.0", "id": 1, "result": "0x9a8f1c...c21" }
The raw JSON-RPC call beneath writeContract. The 0x02 prefix marks an EIP-1559 typed transaction. The hash lets you track the tx; it is not proof of execution.

Where does that endpoint come from? Three options, with a real trade-off. (1) Run your own full node / execution client — maximal trustlessness, but you maintain a machine. (2) Use a managed node provider like Alchemy or Infura — one URL, no ops, but you are now trusting a company that can see your IP and queries, rate-limit you, or go down. (3) A hybrid, using providers for convenience and your own node for sensitive paths. Most production dApps pick option 2, which is the single biggest place a 'decentralized' app quietly recentralizes.

Layer 4 — on-chain: execution, inclusion, and the receipt

Now the layers you spent rungs on take over. From the mempool, a block builder selects your transaction (the fee market: your priority tip competes for blockspace above the burned base fee). A proposer includes the block; every node re-executes your `transfer` on the EVM, which decrements one balance slot and increments another in the smart contract's storage, charging gas opcode by opcode. The contract emits a `Transfer(from, to, value)` event into the logs. Then the block is attested, and after two epochs reaches finality — at which point reverting it would cost an attacker a third of all staked ETH.

How does the frontend learn it worked? It polls. The library calls `eth_getTransactionReceipt(hash)` repeatedly; the node returns `null` while the tx is still pending, then — once mined — a receipt carrying the status (`success` or `reverted`), the gas actually used, the block number, and the emitted logs. viem wraps this loop in `waitForTransactionReceipt`.

// Poll until the tx is in a block, then read its receipt.
const receipt = await publicClient.waitForTransactionReceipt({ hash })

receipt.status       // 'success'  (or 'reverted' -- a mined-but-failed tx
                     //              STILL costs gas; you paid for the work)
receipt.blockNumber  // 19_482_103n
receipt.gasUsed      // e.g. 51_000n
receipt.logs         // includes the ERC-20 Transfer(from, to, value) event

// A receipt only means "included". For high-value flows, also confirm depth
// or wait for finality before treating the money as truly settled.
const safe = await publicClient.waitForTransactionReceipt({
  hash, confirmations: 12,
})
Reading the receipt. Note that a reverted transaction is still mined and still charges gas — 'failed' is not the same as 'free' or 'never happened'.

Layer 5 — reading the chain back: events, indexers, and the UI

The transfer succeeded — but how does the UI show *'your last 50 transfers'*? You cannot ask the chain that question directly. A node can give you current state (`balanceOf` right now) cheaply, but the chain has no built-in 'query all history' index. Events are the bridge: every `Transfer` log is permanently recorded with indexed topics you can filter on. `eth_getLogs` lets you scan a block range for matching events.

import { parseAbiItem } from 'viem'

// Direct log scan: fine for a narrow range, falls over across millions of blocks.
const logs = await publicClient.getLogs({
  address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
  event: parseAbiItem(
    'event Transfer(address indexed from, address indexed to, uint256 value)'
  ),
  args: { to: account },           // filter on the indexed `to` topic
  fromBlock: 19_000_000n,
  toBlock: 'latest',
})

// Problem: providers cap getLogs (e.g. 10k results / a few thousand blocks).
// Scanning all of history from the browser is slow, paginated, and fragile.
Querying events directly. This works for a small window but does not scale to 'all history' — which is why a dedicated indexer exists.

For real apps this is the job of an indexer. A service like The Graph runs nodes that watch the chain, decode every event, and write them into a database you query with GraphQL in milliseconds — joins, sorting, aggregates, the things `eth_getLogs` can't do. You define a subgraph (which contracts, which events, how to map them), and the indexer backfills history and stays live. So the full read path is: chain → events → indexer → GraphQL → your UI. A block explorer like Etherscan is just a giant general-purpose indexer with a web UI on top.

The whole lifecycle, and where it leans on trust

Let us trace one click through every layer, top to bottom and back, so the architecture sits as a single picture in your mind.

  1. Frontend. You click Send 1 USDC. The Web3 library (viem/ethers) reads the ABI and encodes the call into calldata: selector `0xa9059cbb` (transfer) + padded recipient + amount.
  2. Wallet. The library hands the request to your non-custodial wallet over EIP-1193. It shows a human-readable prompt; on approval it sets the nonce and EIP-1559 fees, RLP-encodes the tx, and signs the hash with ECDSA. Your key never leaves the vault.
  3. RPC endpoint. The signed blob is POSTed to a JSON-RPC node via `eth_sendRawTransaction`. The node checks it and gossips it into the mempool; you get back a transaction hash, a promise — not a confirmation.
  4. Chain. A builder picks it from the fee market; a proposer includes the block; every node re-executes it on the EVM, updating storage and emitting a `Transfer` log. Attestations pile up; two epochs later it is final.
  5. Back up. The frontend polls `eth_getTransactionReceipt` until it sees `status: success`, then paints the optimistic 'sent!' state. For history, it queries an indexer (The Graph) over GraphQL rather than scanning logs itself.

Step back and notice the honest seams. The consensus layer is genuinely decentralized and trustless — that is the part you spent twelve rungs earning. But the access layer around it often is not: the RPC endpoint is usually a single company, the indexer is usually a single company, and the frontend is usually served from a single domain on centralized hosting. A government that can't touch Ethereum can still pressure Infura, Etherscan, and your DNS. Decentralizing the chain is solved; decentralizing the stack on top of it — local nodes, light clients, IPFS-hosted frontends, multiple RPCs — is the ongoing engineering frontier.

That is the whole machine. A transaction you can now follow from a button, through a signature that proves it's you, across an RPC door, into an EVM that executes Solidity you could write, onto a ledger secured by stake you understand — and back to a screen. Every earlier rung was a part; this was the assembly. You have walked the full stack of a dApp.