An open source block explorer for EVM chains. Every block, transaction and transfer is indexed locally, so you can follow where value actually moved.
A self-hosted explorer for any EVM-compatible chain. Point it at a JSON-RPC endpoint and it indexes the chain into a local SQLite file, then serves six pages over that index: a live overview, blocks, transactions, block and transaction detail, and an address page with a flow ledger showing value in and out - plus, when the address holds code, what that code can do.
It speaks standard Ethereum JSON-RPC and nothing else - no vendor API, no hosted service, no tracing extensions. It runs against NuraChain, a local Hardhat or Anvil node, or any other EVM network.
It is not a Bitcoin explorer: Bitcoin is UTXO-based and speaks a different RPC.
Ethereum JSON-RPC has no method that answers "what has this address done?". There is
eth_getBalance for a number and eth_getTransactionByHash for a hash you already know, but no
address history. Every explorer you have used - Etherscan included - answers that question from
its own index rather than from the node. So does this one.
The index is a cache, not a source of truth. Delete .data/index.db and it replays from
START_BLOCK.
A deployed contract keeps no names. eth_getCode returns runtime bytecode - no ABI, no source,
no argument names - so an explorer either verifies source or reads the bytes. This one reads the
bytes, and says so on the page:
- Entry points. The dispatcher compares the first four bytes of every call against the selectors it answers to. Walking the opcodes recovers that list, and a table of published signatures gives most of them their names back. A selector no standard claims is printed as four bytes rather than labelled with a plausible guess.
- Interfaces. ERC-20, 721, 1155, 165, 2612, 4626, Ownable, AccessControl, Pausable - claimed only when every selector of that interface is present, so the badge means the contract answers them, not that it says it does.
- Current values. The zero-argument getters it actually has (
name,symbol,decimals,totalSupply,owner,paused, ...), called live and decoded. - Compiler and source metadata. solc appends a CBOR trailer naming its version and an IPFS hash of the metadata. That is what the deployer stamped, not proof of anything - but it says which compiler to point at the source if you want to verify it.
- Proxies. EIP-1967, beacon, EIP-1822 and EIP-1167 clones are followed to their implementation, and the functions come from there. A proxy's own code answers nothing.
- Deployment. Who deployed it, in which transaction and block. This half comes from the index - the chain cannot map a contract back to the receipt that created it.
Source verification is not implemented: nothing here compiles source or checks it against the deployed code, and the page says as much above everything it shows.
A named function can also be called, and the page splits the two kinds because the EVM does:
- Read -
viewandpure. Answered by this server through its own node: arguments are encoded,eth_callruns, and the return is decoded against the type the standard declares. No wallet, no signature, no fee. A revert comes back as the reason it gives, printed where the value would have been, because a revert is an answer. - Write - everything else. The server encodes the calldata and stops there. The browser hands those bytes to the reader's own wallet (EIP-1193, so any injected wallet), the wallet asks its owner, and the wallet sends it. Nothing here signs, and the server's own node connection is never in that path.
Two constraints are load-bearing:
- The read endpoint is not an RPC passthrough. Only
view/pureentries of the signature table can be named, so the callable surface is a fixed list of published getters rather than whatever a caller writes in the body. - No Write button exists until the wallet is connected and on this chain. The same calldata sent on another network reaches a different contract, or nothing at all.
Selectors with no published signature are listed but not callable - without an ABI there is no way to know what arguments they take.
- Node.js >= 24 - the server runs TypeScript natively, with no build step
- An EVM JSON-RPC endpoint
npm install
cp server/.env.example server/.env # then set RPC_URL and CHAIN_ID
npm run devOpen http://localhost:3001. The API runs on :3000, with /api proxied to it.
The indexer starts with the server, catches up from START_BLOCK to the head, then follows new
blocks every POLL_MS. The first sync of a long chain takes a while; the UI works while it runs
and fills in as blocks land.
RPC_URL=https://rpc.nurachain.net
CHAIN_ID=1020
CHAIN_NAME=Nura Chain
CURRENCY_SYMBOL=NURA
CHAIN_SITE_URL=https://nurachain.net
START_BLOCK=0Indexing a mainnet from genesis is not practical on one machine. Start near the head:
RPC_URL=https://your-node
CHAIN_ID=1
START_BLOCK=21000000 # a recent height, NOT 0
POLL_MS=6000 # keep it under the chain's block time
BATCH_SIZE=25 # raise for a fast node, lower for a rate-limited oneOnly blocks from START_BLOCK up are searchable. That is the trade for a first sync measured in
minutes rather than weeks.
Every key the server reads is documented in server/.env.example. Keep
the two files in step: a key added there belongs in .env too.
| Key | Default | What it does |
|---|---|---|
PORT |
3000 |
Server port |
NODE_ENV |
development |
production serves the built client |
CLIENT_DIR |
../application/dist |
Built client, served from the same origin |
SSR_ENTRY |
../application/dist-server/entry.server.js |
SSR bundle |
RPC_URL |
http://127.0.0.1:8545 |
The chain to index |
CHAIN_ID |
31337 |
Chain id, shown in the UI |
CHAIN_NAME |
Local EVM |
Shown in the header and footer |
CURRENCY_SYMBOL |
ETH |
Suffixes every amount |
CURRENCY_DECIMALS |
18 |
Native token decimals |
CHAIN_SITE_URL |
(unset) | The chain's website, linked from its name in the footer |
EXPLORER_URL |
(unset) | This explorer's public URL, given to wallets as the block explorer |
START_BLOCK |
0 |
Height to index from |
POLL_MS |
2000 |
How often to check for a new head |
BATCH_SIZE |
25 |
Blocks per catch-up batch |
DB_PATH |
.data/index.db |
The SQLite index |
npm run build
NODE_ENV=production npm startOne process serves the API and the built client on one origin, so there is no CORS to configure. Put a reverse proxy in front for TLS.
In a container - build from the repo ROOT, where the workspace lockfile and .dockerignore live:
docker build -f server/Dockerfile -t nura-explorer .
docker run -p 3000:3000 --env-file server/.env nura-explorer/api/healthz answers orchestrator probes.
On a bare host, from a fresh clone - the unit runs the server directly from source, with the client built ahead of it:
npm ci
cp server/.env.example server/.env # then set RPC_URL and CHAIN_ID
npm run build
sudo npm run service:install
sudo npm run service:startsudo npm run service:deploy rebuilds and restarts after a git pull.
What to know before running it for real:
- The index is a file. Back up
DB_PATH, or accept a replay on loss. Deleting it is safe. - Reorgs are handled. On a parent-hash mismatch the indexer walks back and rolls the orphaned blocks out, rather than serving transactions that were un-mined.
eth_getBlockReceiptsis probed once and falls back to per-transaction receipts on nodes that lack it - slower, still correct.- Rate limiting is on. A burst of requests answers
429.
| Command | What it does |
|---|---|
npm run dev |
Server and client together |
npm run build |
Client bundle, SSR bundle, prerender |
npm start |
Run the built app (set NODE_ENV=production) |
npm run check |
Typecheck and lint every workspace |
npm test |
Every suite in every workspace |
npm run test:coverage |
Server suite with a coverage report |
npm run test:shuffle |
Every suite in random order - the isolation gate |
npm run service:deploy |
Rebuild and restart (root) |
npm run service:install |
Write and load the systemd unit (root) |
npm run service:uninstall |
Stop, disable and remove the unit (root) |
npm run service:start / :stop / :restart |
Control the service (root) |
npm run service:status |
What systemd thinks of it |
Everything runs offline. No suite binds a port, reaches a network or touches a file the repository
does not own: the index is sqlite :memory: per test, the node is a stubbed gateway or a stubbed
fetch answering JSON-RPC, and the browser half runs under happy-dom. That is what makes the same
command give the same answer on a laptop, on a fork's pull request, and at three in the morning.
| Command | Scope |
|---|---|
npm test |
Everything, both workspaces |
npm run test:coverage |
Server suite plus a coverage report in server/coverage |
npm run test:shuffle |
Everything, in random order |
npm test --workspace server |
Server only |
npm test --workspace application |
Browser half only |
Narrower slices, from within a workspace:
| Command | Scope |
|---|---|
npm run test:unit -w server |
ABI coercion, bytecode analysis, the cache, configuration |
npm run test:integration -w server |
The sqlite index, the sync loop, the RPC client |
npm run test:api -w server |
Both HTTP surfaces - the typed API and the Etherscan shim |
npm run test:security -w server |
Only the security suites |
npm run test:property -w server |
Only the property and fuzz suites |
npm run test:components -w application |
Component rendering and interaction |
npm run test:stores -w application |
The browser stores, wallet included |
npm run test:watch -w server |
Re-run on change |
test:shuffle is a gate, not a curiosity. Both halves lean on module-level singletons - the
stores in the browser, the signature table on the server - so a test that only passes in
declaration order is one that will fail for somebody else, on an unrelated pull request, with no
way to reproduce it. CI runs the shuffled pass on every push for that reason.
Where the suites live:
server/tests/
support/fixtures.ts a chain, an index and an app, built the same way by every spec
values.spec.ts ABI coercion and encoding - unit, boundary, property, fuzz
contract.spec.ts bytecode analysis - unit and fuzz over arbitrary bytes
store.spec.ts the sqlite index: constraints, transactions, ordering, concurrency
indexer.spec.ts sync state transitions, reorgs, partial failure
client.spec.ts the JSON-RPC client, against a stubbed node
cache.spec.ts the read-through cache in front of the node
http.spec.ts the typed API: status codes, validation, security
etherscan.spec.ts the wallet-facing compatibility surface
config.spec.ts environment and configuration
app.spec.ts the original end-to-end suite over the index
application/tests/
format.spec.ts amount arithmetic - the highest-risk code in the explorer
locale.spec.ts the ten dictionaries and everything that reads them
components.spec.ts components mounted in a DOM, driven by real events
stores.spec.ts scroll lock, theme, toasts
wallet.spec.ts the EIP-1193 wallet store, against a fake provider
application/ the UI
src/components/ui/ button, badge, card, input, pagination, skeleton, toast, tooltip
src/components/chain/ hash links, cadence strip, flow ledger
src/pages/ home, blocks, block, txs, tx, address
server/ the API and the indexer
src/app.ts every route, schema and handler, declared once
src/chain/client.ts the JSON-RPC gateway
src/chain/indexer.ts catch-up, follow, reorg rollback, transfer decoding
src/chain/store.ts the SQLite index
src/chain/contract.ts bytecode -> selectors, event topics, compiler metadata
src/chain/signatures.ts the selector -> signature table that gives them names back
src/chain/values.ts typed text <-> abi encoding, for the arguments of a call
src/inspect.ts one contract: describing it, reading it, encoding a call to it
The API is declared once in server/src/app.ts, and the browser gets a typed client from that
same declaration - client.blocks.one(...) is checked against the handler's own schema.
Issues and pull requests are welcome. For anything larger than a fix, open an issue first so the approach can be agreed before you spend the time.
Before opening a pull request, all three gates must pass:
npm run check
npm test
npm run test:shuffleHouse style is enforced by the linter and visible in any neighbouring file: Allman braces, one import per module, and comments that state a constraint the code cannot show rather than narrating what changed.
- Adding a page: one row in
application/src/routes.tsplus its*.page.azerothcomponent. - Adding a chain field: it starts in
server/src/schemas.ts. The browser's client is inferred from the server's declaration, so the wire shape is decided in exactly one place. - Anything touching amounts belongs in
application/src/lib/format.tsand needs a test. A uint256 does not survive a double, and an explorer that misreports a balance has failed at its only job.
Do not open a public issue for a security bug. Report it privately so a fix can ship before the details are public.
Two things worth knowing before you deploy this:
- The index is a cache, never a source of truth. Every figure the UI shows can be re-derived
from the chain by deleting
DB_PATHand replaying. Nothing irreplaceable lives in it. RPC_URLmay carry a provider key. It is read server-side and never reaches the browser - the client only ever talks to this server's own API. Keep it that way when adding endpoints.
MIT


