A self-hosted block explorer for Proof-of-Work EVM chains — Etherscan/Blockscout-style UI, a full block indexer, PoW reward and fee-burn accounting, token tracking, a Solidity contract verifier, live gas tracker, and both a native REST API and an Etherscan-compatible API.
Everything is driven by one config file: chain name, currency, chain ID, RPC endpoints, reward schedule, and branding.
Indexing
- Blocks, transactions, receipts, logs, uncles
- Internal transactions via
debug_traceBlockByNumber(callTracer) ortrace_block, auto-detected - Reorg detection and rollback (configurable depth)
- ERC-20 / ERC-721 / ERC-1155 transfer extraction, token metadata, holder balances
- Native balance refresh for touched accounts
- Daily statistics rollups that back every chart
PoW accounting
- Block rewards read from the chain itself (
trace_blockreward traces or coinbase balance deltas), with a configurable schedule as fallback - Uncle rewards
(uncleNumber + 8 − blockNumber) × base / 8and inclusion bonusbase / 32 - EIP-1559 base-fee burn tracked per transaction and per block (disable for pre-1559 chains)
- Optional treasury/dev-fund split off the block subsidy
Explorer UI
- Dashboard with live blocks/transactions (SSE) and summary graphs
- Blocks, uncles, transactions, internal transactions, token transfers
- Transaction detail showing which contract function was called, with decoded arguments — from the verified ABI when available, otherwise from a signature database
- Decoded event logs, address pages with tabs, token pages with holders and NFT inventory
- Top accounts (rich list), top miners with hashrate distribution
- Charts & statistics page, live gas tracker
- Contract source viewer, Read Contract, Write Contract (via your browser wallet)
- Dark and light themes
Contract verification
- Single flattened file, multiple files, or Solidity standard-JSON input
- Compiler binaries downloaded and cached on demand (checksum-verified)
- Full vs partial match, with metadata, immutables and library placeholders masked
- Constructor arguments recovered from the deployment transaction
- Proxy detection (EIP-1167 minimal proxies, EIP-1967 slots)
- Etherscan-compatible
verifysourcecodesohardhat verifyandforge verify-contractwork
- Ubuntu 22.04 or 24.04 (the installer targets Debian/Ubuntu; the app itself is portable)
- Node.js 20+
- PostgreSQL 14+
- An EVM node with JSON-RPC enabled
For internal transactions, expose a tracing API on the node:
geth --http --http.api eth,net,web3,debug --gcmode archiveErigon/OpenEthereum-family nodes are detected through trace_block instead. Without either, the
explorer runs fine — the Internal Transactions tabs are simply empty and a warning is logged.
git clone <your-repo> evm-explorer && cd evm-explorer
sudo bash deploy/install.sh \
--rpc http://127.0.0.1:8545 \
--chain-id 5070 \
--chain-name "FusionLayer" \
--short-name FXL \
--currency-name FusionLayer \
--symbol FXL \
--domain explorer.example.com \
--ssl admin@example.comThe installer sets up Node 20, PostgreSQL, nginx, a service user, a database with a generated password, builds both apps, installs two systemd units, and configures nginx to serve the explorer on both the bare server IP and your domain.
When it finishes you get:
| Access | URL |
|---|---|
| Direct (no nginx) | http://<server-ip>:4000 |
| Via nginx, bare IP | http://<server-ip>/ |
| Via nginx, domain | https://explorer.example.com/ |
Useful flags: --port, --start-block, --db-name/--db-user/--db-pass, --install-dir,
--skip-nginx, --force-config. Run bash deploy/install.sh --help for the full list.
cp config/chain.config.example.json config/chain.config.json # edit it
docker compose -f deploy/docker-compose.yml up -d --buildA node on the Docker host is reachable from the containers at http://host.docker.internal:8545.
# database
sudo -u postgres createuser explorer --pwprompt
sudo -u postgres createdb -O explorer explorer
# config
cp config/chain.config.example.json config/chain.config.json
cp .env.example .env # optional overrides
# backend
cd server && npm install && npm run build
node dist/tools/migrate.js # apply schema
node dist/indexer/main.js & # indexer
node dist/api/main.js & # API + UI
# frontend (dev server with hot reload, proxies /api to :4000)
cd ../web && npm install && npm run devFor production the API serves web/dist itself, so a single port covers UI and API.
config/chain.config.json — every field is documented below. Any value can be overridden by an
environment variable (see .env.example), which takes precedence.
"chain": {
"name": "FusionLayer",
"shortName": "FXL",
"chainId": 5070,
"networkId": 5070,
"consensus": "pow",
"currency": { "name": "FusionLayer", "symbol": "FXL", "decimals": 18 },
"logoUrl": "",
"description": "Proof-of-Work EVM chain"
}The indexer warns at startup if chainId disagrees with the node's eth_chainId.
"rpc": {
"urls": ["http://127.0.0.1:8545", "http://backup:8545"],
"batchSize": 40,
"timeoutMs": 30000,
"maxRetries": 3,
"maxConcurrency": 4
}Multiple URLs are used as failover — a transport error rotates to the next endpoint.
Block rewards are measured from the chain, not assumed from a formula. The explorer never has to be told what a block paid: it asks the node, so forks, treasury splits and any consensus quirk are picked up automatically.
"rewards": {
"source": "trace",
"fallback": "schedule",
"schedule": {
"mode": "decay",
"decay": { "initialWei": "9000000000000000000", "intervalBlocks": 500000, "reductionPercent": 5 },
"constantWei": "9000000000000000000",
"eras": []
},
"uncles": { "enabled": true, "maxDepth": 6, "inclusionRewardDivisor": 32, "formula": "ethereum" },
"burn": { "enabled": true },
"treasury": []
}source |
How it works | Requires |
|---|---|---|
trace (default) |
Reads the type: "reward" entries from trace_block — the engine's own record of what it credited, per block and per uncle |
a client with the trace_ API (Erigon, Nethermind, OpenEthereum-family) |
balanceDelta |
Derives issuance from the coinbase balance change across the block, subtracting the fees, transfers and gas the indexer already knows about | an archive node (historical eth_getBalance) |
schedule |
Computes the reward from the formula below | nothing |
At startup the indexer probes the node and logs which source it settled on:
[rewards] block rewards will be read from trace_block reward traces (probed block 1)
With source: "trace" the indexer also prefers trace_block for internal transactions, since one
call then returns both — no extra RPC round trip per block.
fallback decides what happens for a block the primary source cannot report (schedule, or none
to record zero). Every block stores which method produced its number, and the UI labels it:
traced / balance delta / estimated — so an estimated figure is never passed off as fact.
Consulted only when the chain cannot be measured, so it should mirror consensus as closely as you know it.
schedule.mode |
Behaviour |
|---|---|
constant |
Every block pays constantWei |
eras |
Reward changes at the listed fork blocks (Ethereum-style 5 → 3 → 2) |
decay |
initialWei reduced by reductionPercent every intervalBlocks |
FusionLayer ships as decay: 9 FXL, −5% every 500,000 blocks, i.e. 9 → 8.55 → 8.1225 → …
Fractional percentages are allowed.
Era boundaries are inclusive of the interval block: block 500,000 still pays 9 FXL and the first
reduction lands on 500,001 (the ECIP-1017 convention). This was measured on chain, not assumed —
eth_getBalance deltas give exactly 9.000000 FXL at block 500,000 and 8.550000 at 500,001.
Note the schedule is an approximation of consensus by construction — prefer a traceable or archive node so it is never used.
uncles.formula: "none"disables uncle rewards entirely. Use it for chains that record uncles but do not pay for them — FusionLayer is one of these. Measured on chain: the includer receives a flat subsidy regardless of uncle count, and a non-includer uncle miner receives 0 FXL (verified at blocks 495,003 and 495,008). Withnone, the explorer neither credits uncle miners nor carves an inclusion bonus out of the measured subsidy.burn.enabled: falsefor chains that never activated EIP-1559 — all fees then go to the miner.treasurydiverts a percentage of the block subsidy when usingschedule. Undertrace, treasury payouts are detected automatically from reward traces addressed to non-coinbase accounts and shown as their own line, so this list can stay empty.
treasury describes protocol payments to a fixed address:
"treasury": [
{ "label": "Team", "address": "0x560ea0…", "mode": "fixed",
"amountWei": "2000000000000000000", "percent": 0, "fromBlock": 1, "toBlock": 5000000 }
]mode |
Meaning |
|---|---|
fixed |
amountWei per block, paid on top of the miner subsidy (does not reduce it) |
percent |
a share carved out of the miner subsidy |
toBlock: 0 means forever. Like block rewards these are measured when the node allows it — the
grant address's balance delta is taken with its own transaction activity removed — and only fall back
to the numbers above when historical state is unavailable.
FusionLayer pays the team 2 FXL per block for the first 5,000,000 blocks. Measured on chain: the grant is flat and does not decay with the miner reward (at block 510,192 the miner received 8.55 FXL and the team 2.0 FXL). Total issuance is therefore 11 FXL/block in era 0, not 9.
The explorer separates created coin from recycled coin:
totalIssued— subsidy + uncle payouts + treasury grants. Transaction fees are excluded, since they move existing coin rather than create it.totalBurnt— EIP-1559 base fee destroyed.circulatingSupply—chain.genesisSupplyWei + totalIssued − totalBurnt. SetgenesisSupplyWeiif your chain has a premine; FusionLayer has none, so it is"0".
These cover the indexed range only. If you start indexing at a non-zero startBlock, they
describe that window rather than the whole chain.
Changing the reward config after indexing does not retroactively fix stored rewards. Re-index from the affected height:
sudo systemctl stop explorer-indexer
sudo -u explorer node /opt/evm-explorer/server/dist/tools/maintenance.js reindex-from <N>
sudo systemctl start explorer-indexer"indexer": {
"startBlock": 0,
"confirmations": 0,
"batchBlocks": 20,
"pollIntervalMs": 2000,
"reorgDepth": 64,
"receipts": { "method": "auto" },
"traces": { "enabled": true, "method": "auto" },
"tokens": { "enabled": true, "trackBalances": true, "metadataConcurrency": 4 },
"balances": { "enabled": true, "batchSize": 200, "intervalMs": 5000 },
"stats": { "intervalMs": 60000 }
}receipts.method—autoprobeseth_getBlockReceiptsand falls back to batchedeth_getTransactionReceipt. Force withblockorindividual.traces.method—auto,callTracer,traceBlock, oroff.confirmations— keep the indexer N blocks behind the head on a chain with frequent reorgs.
"server": {
"host": "0.0.0.0",
"port": 4000,
"publicUrl": "https://explorer.example.com",
"corsOrigins": ["*"],
"trustProxy": true,
"rateLimit": { "enabled": true, "max": 300, "windowMs": 60000 },
"serveWeb": true
}serveWeb: false turns the process into a pure API (useful if a CDN serves web/dist).
"verification": { "enabled": true, "solcDir": "./data/solc", "allowDownload": true, "compileTimeoutMs": 120000 },
"features": { "internalTransactions": true, "tokens": true, "verification": true, "charts": true,
"etherscanApi": true, "topAccounts": true, "gasTracker": true },
"web": { "title": "FXL Explorer", "tagline": "", "themeColor": "#f2a900",
"links": { "website": "", "github": "", "twitter": "", "discord": "" } },
"price": { "enabled": false, "source": "coingecko", "coingeckoId": "", "refreshMs": 300000 }Turning a features flag off hides the nav entry and returns 404 from the matching endpoints.
For an air-gapped host set verification.allowDownload: false and pre-populate data/solc/
with the compiler binaries and list.json.
systemctl status explorer-api explorer-indexer
journalctl -u explorer-indexer -f
systemctl restart explorer-api explorer-indexer # after editing the config
curl localhost:4000/api/v1/health # db + rpc + indexer lagAfter rebuilding the web UI, restart the API. The static file handler resolves web/dist when
the API boots, so a freshly built bundle gets a new hashed filename that the running process will not
serve — the request falls through to the SPA handler and returns index.html with a text/html
content type. The browser then refuses it ("Expected a JavaScript-or-Wasm module script") and the
page renders blank:
cd /opt/evm-explorer/web && npm run build && systemctl restart explorer-api/api/v1/health returns 503 when the database or RPC is unreachable — point your uptime monitor
at it.
Maintenance CLI — run from /opt/evm-explorer/server as the explorer user:
node dist/tools/maintenance.js status # chain head, indexed head, lag, counters
node dist/tools/maintenance.js rebuild-stats # recompute every day in stats_daily
node dist/tools/maintenance.js recount # recompute the counters table
node dist/tools/maintenance.js reindex-from 500000 # drop data from a height and rewind
node dist/tools/maintenance.js refresh-balances # flag every address for a balance refresh
node dist/tools/maintenance.js refresh-token 0x… # refetch one token's metadata
node dist/tools/maintenance.js reset-tokens # refetch metadata for every tokenRe-index from scratch
sudo systemctl stop explorer-indexer explorer-api
sudo -u postgres psql -c "DROP DATABASE explorer;"
sudo -u postgres createdb -O explorer explorer
sudo systemctl start explorer-api explorer-indexerBackup
sudo -u postgres pg_dump -Fc explorer > explorer-$(date +%F).dumpSync speed. Indexing is bounded by RPC round-trips. Raise indexer.batchBlocks and
rpc.batchSize if the node keeps up; lower them if the node starts timing out. Tracing is by far
the most expensive step — set traces.enabled: false for a fast initial sync and turn it back on
later (already-indexed blocks will not gain traces retroactively without a re-index).
Addresses can be shown as names instead of hashes. Two sources feed one registry:
"labels": {
"file": "./address-names.json",
"fusionx": {
"enabled": true,
"address": "0x13e4b3fE79388C6eF206481655c8557320811104",
"profileBaseUrl": "https://fusionx.social",
"refreshMs": 300000
}
}Manual entries live in config/address-names.json, resolved next to the chain config. Only name
is required:
{
"0x560ea05cc475396cb3d957d55b5da3e54d819a1e": {
"name": "FusionLayer Team",
"type": "team",
"description": "Protocol treasury.",
"link": "https://fusionlayer.org/",
"links": { "GitHub": "https://github.com/0xFusionLayer" },
"verified": true
}
}type renders as a badge (free text — exchange, bridge, team, contract, burn…). Keys are
case-insensitive and $-prefixed keys are treated as comments.
Applying an edit. The file is read once at API startup and cached, and only the API reads it — the indexer is not involved, so nothing needs re-indexing:
sudo nano /opt/evm-explorer/config/address-names.json
sudo systemctl restart explorer-apiBrowsers pick the change up within 5 minutes, or immediately on reload. Confirm with
curl -s localhost:4000/api/v1/labels | jq '.count'.
The file is never overwritten by an upgrade — it is excluded from the installer's rsync and only
seeded from address-names.example.json when absent, so hand-curated names survive install.sh
even with --force-config. Edit the live file on the server; the example is only a template.
A malformed entry is skipped with a warning rather than taking the API down — check
journalctl -u explorer-api -n 30 after a restart if a name does not show up.
FusionX usernames are mirrored from the social contract. The indexer enumerates the registry via
getTotalUsers / getUserAddressById / getUserBasic / getUserProfile and stores it in
fusionx_users. A full pass runs every refreshMs, which also catches username and profile edits
that an append-only event scan would miss. Addresses then display as @username with a link to
${profileBaseUrl}/${username}. Set enabled: false on a chain without FusionX.
Manual entries win over on-chain usernames, so you can override a self-assigned name.
- Names appear anywhere an address is rendered — block miners, transaction from/to, account lists — but links always use the address, never the name.
- Both are searchable:
arrow,@arrowandFusionLayer Teamall resolve to their address, with exact username matches ranked first. GET /api/v1/labelsreturns the whole set (small enough to fetch once and cache client-side);GET /api/v1/labels/:addressreturns one.
Upgrading is designed to be safe: nothing is destructive, and indexed data survives.
cd /opt/evm-explorer
systemctl stop explorer-indexer explorer-api
# drop in the new source, keeping config/ and data/
unzip -o ~/explorer.zip
cd server && npm install && npm run build
cd ../web && npm install && npm run build
cd ../server && npm run migrate
systemctl start explorer-api explorer-indexer
journalctl -u explorer-indexer -fWhat protects you:
- Migrations are additive and idempotent. Every statement uses
IF NOT EXISTS/ADD COLUMN … DEFAULT, applied files are recorded inschema_migrations, and an advisory lock stops the API and indexer racing to migrate at the same moment. Re-runningnpm run migrateon an up-to-date database is a no-op. New columns land on existing rows with a default rather than rewriting them. - Config gains new keys automatically. The file is merged over built-in defaults, so a key added in a later version starts with a sensible value and your file keeps working untouched.
- Renamed or removed keys stop the boot. Unknown keys would otherwise be ignored while the
renamed ones silently fell back to defaults — a stale
rewards.modeconfig would have started cleanly and paid the default 2 coin subsidy instead of FusionLayer's 9. Startup now refuses and prints the old → new mapping. - The installer will not clobber your config.
deploy/install.shkeeps an existingconfig/chain.config.jsonunless you pass--force-config.
Indexed rows are a snapshot of how the code interpreted each block, so changing that interpretation does not rewrite history by itself.
| Change | Re-index? |
|---|---|
| API, web UI, styling, charts | No — restart only |
| New API field computed from stored columns | No |
| Reward logic, treasury grants, burn settings | Yes, from the affected height |
| Token detection, tx method decoding, trace settings | Yes, for the affected range |
| Adding a column that is only populated going forward | No |
Re-index a range without discarding the rest of the chain. Stop the indexer first — this deletes indexed data from that height and rewinds the cursor, and the indexer refills it on next start:
systemctl stop explorer-indexer
cd /opt/evm-explorer/server
npm run maintenance -- reindex-from 495000 # drop from this height, rewind the cursor
systemctl start explorer-indexer # refills as it catches back upDeleting a range decrements the running totals (total_issued, total_burnt, block and transaction
counts) as part of the same transaction, so re-indexing does not double-count supply. If the counters
ever look wrong anyway, rebuild them from the underlying tables:
npm run maintenance -- recount # counters table, recomputed from scratch
npm run maintenance -- rebuild-stats # daily chart aggregates
npm run maintenance -- status # chain/indexer health and current counterspackage-lock.json is committed for both server/ and web/, so npm install reproduces the same
dependency tree on the VPS. Node 20+ is the only host requirement.
Native REST API under /api/v1 and an Etherscan-compatible surface at /api. Full endpoint
listing is at /api-docs in the UI.
curl localhost:4000/api/v1/stats
curl localhost:4000/api/v1/blocks?limit=5
curl localhost:4000/api/v1/transactions/0x…
curl "localhost:4000/api?module=account&action=balance&address=0x…"Live updates come from /api/v1/stream (Server-Sent Events, blocks and transactions events).
No API key is needed — any value is accepted.
// hardhat.config.ts
etherscan: {
apiKey: { fxl: "any" },
customChains: [{
network: "fxl",
chainId: 5070,
urls: { apiURL: "https://explorer.example.com/api", browserURL: "https://explorer.example.com" }
}]
}forge verify-contract <ADDRESS> <CONTRACT> \
--verifier etherscan \
--verifier-url https://explorer.example.com/api \
--etherscan-api-key anyconfig/ chain.config.json — the single source of truth
db/migrations/ SQL schema, applied automatically at startup
server/ TypeScript backend
src/config.ts config loader with env overrides
src/rpc.ts batching JSON-RPC client with failover
src/indexer/ block ingestion, rewards, traces, tokens, balances, stats
src/api/ Fastify server, routes, gas oracle, SSE stream
src/verifier/ solc manager, bytecode matching, verification
src/tools/ migrate.ts (schema) and maintenance.ts (ops CLI)
web/ React + Vite single-page UI
deploy/ install.sh, nginx templates, systemd units, Docker
The API and the indexer are separate processes sharing one database, so the indexer can be restarted or paused without taking the site down.
- Vyper verification is not implemented — Solidity only.
- Compiler binaries are the official
linux-amd64builds. On another platform, pointverification.solcBaseUrl/solcListUrlat the matching directory on binaries.soliditylang.org. total_addresses,total_contractsandtotal_tokenscounters are recomputed by the stats job (default every 60s), so they trail the live tables slightly.- Token holder counts are refreshed periodically rather than on every transfer; balances themselves are updated per block.
- The rich list ranks accounts the indexer has seen. Accounts funded in the genesis block only appear once they are touched by a transaction, unless you index from block 0.