A small web console for a Bitcoin node, from Bitcoin Commons. One download, one page in your browser. It shows you what your node is doing (sync progress, peers on a globe, new blocks, logs) and, if you want, it can also download and run a node for you. It never touches a wallet or any funds.
The product is called Commons Node View. The repository, the Docker image and the Rust crate keep their old names:
blvm-ui(repo and image) andblvm-ui-next(crate and binary). The commands below use those names.
| Home | Settings | First start |
|---|---|---|
![]() |
![]() |
![]() |
It is a Svelte 5 + Tailwind v4 page served by a small Rust program (the "host"). The host asks the node for its state once a second, keeps a little state of its own (block history, peer locations, your settings), and gives the page one JSON snapshot. The node does not need the console for anything: it syncs the same with the console closed.
This repository is the console only. The node, protocol and consensus code live in the rest of the Bitcoin Commons stack (BLVM).
Standalone: you run the node, the console watches it. Point the console at a node that is already running: Bitcoin Node, Bitcoin Knots, or BLVM. It talks to it over the usual JSON-RPC and shows sync, peers, disk and blocks. It can also add or ban peers and, for a node it finds on this computer, stop and start it. This is the default.
Managed: the console runs a BLVM node for you. You pick a network, the console downloads the official BLVM release, checks it against the release's published checksum (releases are not signed yet), and starts it. It keeps every network's data in its own folder, shows the node's log, switches networks for you, and installs node updates when you click. A short setup walks you through it the first time.
You can change your mind later: Settings has Use a managed node (from standalone) and Run setup again.
The first time a managed console opens, it shows a short setup instead of Home. Nothing is installed or started until you finish it.
- Mode. Run a node for me or Connect to my existing node (you then type its RPC address and press Test connection; a login is optional).
- Network. Mainnet, Testnet4, Testnet3 or Signet; Regtest sits under Developer options. Nothing is selected for you. Each card says what the network is, how big it gets and how long it takes to sync (Mainnet is about 800 GB and takes days; Signet and Testnet4 are much smaller test networks whose coins have no value).
- Validation. Assume-valid for the network you picked: Node default (recommended), Verify everything, or Custom height (a slider up to the known chain height plus a number box). Heights only (no block hash). See Assume-valid.
- Blocks to keep. Keep every block (full node) or Keep only the newest blocks (pruned): a pruned node verifies every block but deletes the old ones, so it needs much less disk and cannot give out old history. The newest-N choice has a slider (288 up to the known chain height; the number box takes any value from 288) and an approximate size. The summary's Disk needed and the low-disk warning then use the pruned estimate (the newest N blocks at the network's average block size, plus a fixed allowance for the UTXO set and indexes: about 12 GB on Mainnet, 2 GB on the other networks; labelled approximate) instead of the full-chain size. See Pruning.
- Get the node. Only shown when there is no node program on disk yet. It shows the latest official release and its size; Download fetches it, checks it against the release's published checksum, runs it once to see that it works on this computer, and installs it.
- Storage and options (desktop). Where the data goes, whether to start the node now, and whether to stop it when the console quits.
- Modules. Optional add-ons. Installing modules is not available yet (this host has no module routes): the step says the list is not available and you can skip it. The Modules screenshot below is mock data.
- Summary. What you picked and how much disk it needs. Start writes your choices and starts the node.
| Pick a network | Get the node | Download |
|---|---|---|
![]() |
![]() |
![]() |
The console remembers your choices (in console.json, next to its other files). Setup runs once: after a restart,
the network, the data folder and the downloaded node are found again, and (unless you turned it off) the node starts by
itself, unless you stopped it: a node you stopped stays stopped after any restart. Setup shows up again only if you ask
for it (Run setup again), or while it was never finished and no network is chosen. Skip counts as finished, so after
it the page stays away and the Node card says that no network has been chosen.
On Umbrel the setup has fewer steps (the mode, the data folder and the modules are fixed or come later): welcome, network, validation, blocks to keep, get the node, summary. Skip for now there only marks the setup as done: the app always runs its own node, so it cannot be switched to watching another one. If GitHub cannot be reached the Get the node step says so and offers Try again (which asks for a fresh check instead of the cached answer). Finishing the setup without starting the node is fine even before a node program has been downloaded; starting one needs it.
Home shows sync progress for the chain the node is on, the validated height and the network height, and a live ring with
the inbound and outbound peer split. The validated height is the node's blocks: the last block it has fully validated and
connected, shown as the node reports it (the console adds nothing and fills nothing in between polls; hover the label for
the same words). It is not the download position, so during the first sync it can wait on one missing block and then jump
when the queue behind it validates. While the node is syncing, Home shows a smoothed rate ("about 42 blocks/s", the average
of the last 30 seconds of polls, worked out in the browser); when the height has not moved for 20 seconds it shows instead
"Waiting for a missing block from a peer; the queue continues when it arrives" (the rate is hidden while that line
shows), and drops that line as soon as the height moves. (The node's header count is not part of the status, so no "headers" figure is shown.) Behind it a globe shows where your peers are (country level, looked up offline) and where you are.
The Latest Blocks rail slides a new block in as the node connects it. While the node is catching up (first sync) the
rail adds at most one tile per poll, showing the newest height, with a small "+N" on the tile when the height moved by
more than one since the previous tile; a stalled height adds nothing. (The host's feed has one tile per height for small
steps, which would read like a counter for a crawl of a few blocks a second, so the page collapses them per poll.) Near the
tip, blocks arrive one at a time and each gets its tile as before. Below about 760 px of card width (a narrow window or a
phone) the P2P Connections panel sits under the sync figures instead of beside them. The network height comes from the node's own header count when it has
one; otherwise from the middle of what at least three peers claim (one peer's claim never moves it), and it is never
below the node's own height.
| Synced or catching up | Mid-sync |
|---|---|
![]() |
![]() |
Managed nodes and pruned nodes get a Blocks stored row under the heights. The genesis block counts as stored, so a node
with tip H holds H + 1 blocks: All 110,001 blocks for a full node, or for a pruned one the count and where the stored
range starts ("2,016 of 110,001 · from height 107,985", never more stored than H + 1) with a tag ("Pruned ·
newest 300 blocks (node)") and the word estimated or verified (see Pruning). The heights themselves never
change because of pruning: the node still validates every block, so Validated height and Network height are the chain's. The
number in the tag is the one the running node reports (getpruneinfo, labelled node); while the node does not answer
it is the saved setting, labelled saved. When the saved setting differs from what the node runs with, a chip says
restart pending: saved 400. The Node card in Settings has a Storage line ("Full node" / "Pruned · newest 2,016 blocks
(node)") with the same chip, Insights notes "(pruned: keeps the newest N blocks)" under the chain size, and the Settings
Storage list tags each pruned network "Pruned · N".
Insights has system health (status, uptime, node version, chain, initial-download state), inbound against outbound
peers, how much disk the chain uses next to the free space on its volume, and how many blocks arrived in each of the last
12 minutes. With a managed node the chain size is measured: what the network's folder takes on disk (refreshed about
once a minute), and the free space is that of the volume holding the data root. The node's own size_on_disk is not used
there: the official blvm release computes it as an estimate of about 1 MB per block, which was off by a factor of 2 to 8
on real chains (102 GB reported against 12 GB on disk at 99 000 blocks). With a standalone node the console measures the
node's data folder when it can find it, and otherwise shows the node's figure, labelled "reported by the node".
Everything about peers: the P2P overview with a switch to turn networking off, add a peer, block an address for 24 hours, and full lists of connected and blocked peers with disconnect, block and unban actions.
What you see depends on the mode.
Managed (the node is the console's):
- Node. Running, stopped, starting or crashed, with the network, the installed version, uptime and Start, Stop and Restart. Two switches on the desktop: Start the node when this app starts (it applies only when you left the node running: a node you stopped stays stopped) and Stop the node when this console quits. Neither is shown on Umbrel, where the node belongs to its own container and its agent brings back what you left running, whatever this app is set to; the card says so in one line. If the node agent of an Umbrel app stops answering, the card and the top light say so. If the node crashed, the card shows the last log lines and Try again; if another program holds the node's port, it says which program and refuses to start.
- Network. A carousel of the networks. Picking another one asks first: it shows what the current network keeps on disk, where the new one will continue from, and how much disk is free, then stops the node cleanly, keeps the data of the network you leave, and starts the other. Switching back picks up where it stopped. A switch is refused when less than 5 GB is free.
- Validation. The assume-valid setting of the current network (same three choices and slider as the wizard; the wizard step and this card both open the choices with one plain sentence: every block is still downloaded and processed from the first one, the setting only skips re-checking signatures below the height). Save stores it and shows the options the node will start with. The node reads the setting at start, so while it runs the card says Applies at the next start and offers Restart now (it asks first).
- Blocks to keep. Block pruning for the current network (the same two choices, slider and number box as the wizard).
Save stores it. Switching pruning on or lower over chain data that is already on disk asks first (the node then
converts the store at its next start: slow, and it does not answer meanwhile). Going back to a full node from a store
that a pruning node has worked on needs the data deleted and downloaded again: the card says how much, asks, and has a
Stop the node button, because the node must be stopped first (the confirm button stays disabled while the node agent
on Umbrel does not answer). Taking back a pruning setting that was saved but never used (no node was started with it) is a
plain save: nothing is deleted. While the host deletes the chain data (
managed.networks[].wipingof the current network) the card shows Deleting the chain data… with a spinner, Save, the delete confirmation and Restart now are disabled, and a timed-out Save is not reported as an error (the delete carries on); the wipe confirmation is also disabled while the node is stopping. The card also shows what the running node is doing and the restart pending chip. The node reads the setting at start, so while it runs the card says Applies at the next start and offers Restart now. - Updates. Installed version, latest release, and Update to vX when a newer one exists. The update downloads,
checks, tests the new program, installs it, restarts the node, and puts the old program back by itself if the new
one does not come up. On Linux only (see below). When updates are turned off, or left to an app store (
BLVM_UI_UPDATES), the card says so. - Modules. Add-ons that run next to the node. Installing them is not available yet; the screenshot of this card is mock data. Mining appears in the sidebar only once a mining module is installed and on.
- Console. Show the Logs page (off by default;
show_logsinconsole.json). Part of the sidebar rules: Home, Network, Settings are always there, Logs when the switch is on, and a module's page (Mining) when that module is installed and on. - About. Chain, node RPC, versions, uptime, and a per-network Storage list with free disk and the data folder. Run setup again is here.
| Settings | Switching networks |
|---|---|
![]() |
![]() |
| Updates | Modules |
|---|---|
![]() |
![]() |
Standalone (you run the node): which RPC address the console talks to, a carousel of the usual local RPC ports per chain (Mainnet, Testnet4, Testnet3, Signet, Regtest), a button that stops or starts the node process if it is on this computer (Unix only), and version and uptime details.
The Logs page is off by default: Show the Logs page in Settings (the Console card) puts it in the sidebar, and a
typed #logs while it is off shows a note with a button to turn it on. With a managed node the page tails the node's
log live, starting with the last 500 lines of the file (also those written before the console started), next to a short guide to the lines worth watching. The
full file is networks/<network>/logs/node.log in the data folder (copy-truncated at 50 MB, one older file kept), and
the page has a button to copy its path. With a standalone node the console cannot see the node's output, so the page
shows the commands to run your node with verbose output, save it and follow it. A line such as "RPC authentication is
required but no tokens or certificates are configured" in the node's log is an upstream wording issue: with the login
this console gives the node, Basic authentication is in effect (a call without it gets 401).
The node does not inherit the console's own RUST_LOG (the container image sets one that names only the console, which
silenced every line the node wrote); give the node one with Child environment (child_env) if you want a different level.
Shown in the sidebar only once a mining module is installed and on (the sidebar is otherwise Home, Network, Logs, Settings;
a typed #mining without a module shows a note pointing to Settings). An information page about the Stratum V2 endpoint and the Commons pool: commands to run, no live numbers. Nothing live is
wired to it yet.
- Green: RPC up, at the tip, has peers.
- Amber: syncing (initial block download), catching up after being synced, no peers, or still looking for the node.
- Red: the node is down or not answering RPC (Frozen when the port is open but RPC hangs).
The console does not die when the node dies. It keeps the last known numbers, dims them, and offers a manual connect field.
Commons Node View is in the Bitcoin Commons community app store:
BTCDecoded/commons-umbrel-store.
- In umbrelOS open the App Store, then ⋯ (top right), then Community App Stores.
- Add
https://github.com/BTCDecoded/commons-umbrel-store. - Open Bitcoin Commons App Store and install Commons Node View.
What you get:
- Two containers from the same image (
ghcr.io/btcdecoded/blvm-ui):webis this console, behind Umbrel's login on port 3849;agentruns the node. They share one data folder (/data, Umbrel's app data folder). Either one can restart or crash without taking the other down: restarting the console never touches the node, and stopping the app asks the node to finish saving first (the agent waits up to 200 s; Docker allows 240 s). The node runs inside the agent container: restarting that container restarts the node, and a node is only taken over again when the agent process restarts inside a running container (akill -9of the agent), not after a container restart. Your last Start or Stop is kept: a node you stopped stays stopped after an app restart, an update or a reboot. There is no Start the node when this app starts switch here: the agent restorescontrol/desired.jsonby itself, so a switch would change nothing. - The node is not in the image. It is downloaded on first setup, when you pick a network, from the official
BLVM releases by the web container, checked against the release's
published checksum (releases are not signed yet), and kept under
/data/bin; the agent runs it. Updates are installed the same way when you click, so the app's version and the node's version are independent. - Ports. Umbrel fixes ports in the app's compose file, but you choose the network later, so the node container publishes all five peer-to-peer ports: 8334 (Mainnet), 18333 (Testnet3), 48333 (Testnet4), 38333 (Signet) and 18444 (Regtest). Only the one for your network has a node behind it. The node's RPC port is not published, and it answers only with a login (see below): other apps on Umbrel's shared Docker network cannot call it.
- Port 8333 is not used. Umbrel's own Bitcoin Node app publishes 8333, so Mainnet is on 8334 here and both apps can
run at the same time. The compose file hands each port to the agent as
BLVM_AGENT_P2P_PORT_<NET>, and the agent starts the node with--listen-addr 0.0.0.0:<port>(the same port inside the container and on the host). The web container gets the same variables so Settings prints the port the node really uses. Inbound peers need your router to forward the port to the Umbrel; syncing works without. Another app (not from Umbrel's official store) that already publishes one of the ports still blocks this app from starting, whichever network you pick. - Commons UI. The older Commons UI app uses the same web port, 3849. Uninstall it before installing this one.
The store's README and notes explain how the app is put together (the btc-commons-node-view folder of the store
repository above, once the store branch with it is merged). In the
container the console runs in managed mode with BLVM_UI_AGENT=external (see the node agent).
The desktop console is the same program, run on your own computer, in standalone or managed mode.
- Linux (x86_64 and aarch64): standalone or managed. A desktop bundle is a folder with the
blvm-ui-nextprogram, the built page (web/), and ablvm-bundle.tomlmarker. In managed mode the console downloads the node on first setup (or uses ablvmprogram that sits next to it). The node needs glibc 2.39 or newer (Ubuntu 24.04, Debian 13). Downloadable bundles from GitHub releases are planned; until one is published, build it from source (below). - macOS: standalone only, for now. There is no macOS build of the node to download yet, so the console cannot
install one there. Run your own node and point the console at it. (If you built a node yourself you can still use
managed mode with
BLVM_UI_NODE_BIN=/path/to/blvm; the installer and updates stay off.) - Windows: the page and the console work and can watch a node. Starting, stopping and adopting a node from the console is built for Unix only (the Windows build has no signal handling for it), so it does not work there.
To run it in managed mode with your own data folder:
BLVM_UI_MODE=managed BLVM_UI_DATA_ROOT=$HOME/blvm-data ./blvm-ui-nextthen open http://127.0.0.1:3849. Without BLVM_UI_DATA_ROOT the data goes to ~/.local/share/blvm (Linux) or
~/Library/Application Support/blvm (macOS). The node itself, each network's chain data, its log and your settings all
live in that folder; see the data folder for the layout.
Requirements: Rust 1.88 or newer, and Node.js 20 or newer (to build the page). A node with JSON-RPC listening if you use
standalone mode: the default target is a BLVM Testnet4 node at 127.0.0.1:48332 and Mainnet is 127.0.0.1:8332.
Bitcoin Node and Knots work too; give the console their RPC login (see Configuration).
git clone https://github.com/BTCDecoded/blvm-ui.git
cd blvm-ui
# 1. Build the page (writes web/dist)
cd web && npm install && npm run build && cd ..
# 2. Run the console against your node
BLVM_UI_RPC=127.0.0.1:8332 cargo run --releaseOpen http://127.0.0.1:3849. The console auto-connects to BLVM_UI_RPC and you can switch targets from Settings. To
let it run the node instead: BLVM_UI_MODE=managed cargo run --release.
Optional: download the peer location database so peers show up on the globe (see Peer locations).
A release build needs the binary and the built front end next to each other:
cd web && npm ci && npm run build && cd ..
cargo build --release
mkdir -p dist/blvm-ui && cp target/release/blvm-ui-next dist/blvm-ui/ && cp -r web/dist dist/blvm-ui/web
cp geo/zone.tab dist/blvm-ui/ # optional; the time zone table is also built inThe host looks for the page in $BLVM_UI_WEB_DIR, then web/, dist/ or web/dist/ next to the binary, then this
crate's web/dist.
To make a bundle that manages a node on its own, put a file blvm-bundle.toml (optionally with a line such as
version = "0.1.60") and a blvm program next to the console binary: with both present and nothing saying otherwise, the
console starts in managed mode.
The Dockerfile builds the page, the location database and the binary into a small Ubuntu 24.04 image that runs as
UID 1000 and keeps its block history in /data. The Rust binary is cross-compiled, so a multi-arch build does not
need emulation.
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/btcdecoded/blvm-ui:dev .
docker run --rm -p 3849:3849 -v blvm-ui:/data \
-e BLVM_UI_RPC=host.docker.internal:8332 \
-e BLVM_UI_RPC_USER=user -e BLVM_UI_RPC_PASS=pass \
ghcr.io/btcdecoded/blvm-ui:devThe Docker image GitHub workflow (.github/workflows/docker.yml) builds both architectures on every push and
pull request and publishes to ghcr.io/btcdecoded/blvm-ui:
| Push | Image tags |
|---|---|
tag v0.1.0 |
0.1.0, latest |
main |
main, sha-<commit> |
| pull request | built, not pushed |
The run summary prints the multi-arch digest to pin in the Umbrel app's docker-compose.yml. The plain image runs the console in
standalone mode, where the node is somewhere else and the power button has nothing to start; the Umbrel app runs the
same image twice in managed mode instead (see Umbrel).
The platform is picked by the Rust build target, so Linux, Docker (Umbrel, Start9) and macOS builds come from the same code:
cargo build --release # this machine
cargo build --release --target x86_64-unknown-linux-gnu # Linux x86_64
cargo build --release --target aarch64-unknown-linux-gnu # Linux ARM64(Install a target once with rustup target add ….)
| Piece | Linux (incl. Docker) | macOS / other Unix | Windows |
|---|---|---|---|
| Find / stop / restart the node | reads /proc (no lsof/ps needed), falls back to them |
lsof + ps |
not supported |
| Block history file | $XDG_STATE_HOME or ~/.local/state/blvm-ui-next |
~/Library/Application Support/blvm-ui-next |
%APPDATA%\blvm-ui-next |
| Time zone for the "You!" dot | TZ, /etc/localtime, /etc/timezone |
TZ, /etc/localtime |
TZ |
For Umbrel / Start9 containers, which usually run in UTC, use BLVM_UI_SELF_GEO=fixed:… or pass the host's TZ, and
point BLVM_UI_STATE_DIR at the app's persistent volume so history survives image updates.
Run the Rust host and the Vite dev server side by side. Vite proxies /api to the host, so the page reloads live as you
edit.
# terminal 1: host on :3849
BLVM_UI_RPC=127.0.0.1:48332 cargo run
# terminal 2: live-reloading page on http://127.0.0.1:5174
cd web && npm install && npm run devcargo test # host tests (feed, sync %, persistence, geo, process control, managed mode, installer, agent)
cd web && npm run buildVITE_API_PROXY says where /api goes; it defaults to the host on http://127.0.0.1:3849. To develop the page with no
node and no Rust host, start the mock (below) and point the proxy at it:
VITE_API_PROXY=http://127.0.0.1:3850 npm run devweb/mock/server.mjs serves the built page with a fake /api/* that looks like a real mainnet node: the current mainnet
tip (fetched from mempool.space at start, with a fallback), 8 outbound and 4 inbound peers around the world, disk usage,
and a new block every minute. The managed-node screens have in-memory state, so every button works.
cd web
npm run build
npm run mock # http://127.0.0.1:3850
MOCK_SCENARIO=ibd npm run mock # mid initial block download instead
MOCK_SCENARIO=managed-running npm run mock # a managed node, with Settings, Logs and the switch dialogOther knobs for /__mock: offline=1 (GitHub cannot be reached: the Get the node step offers Try again, which asks GET /api/node/releases?force=1; force_checks in the answer counts those), agent_alive=0. Switch scenario while it runs and reload the page: curl "http://127.0.0.1:3850/__mock?scenario=update-available".
| Scenario | What it shows |
|---|---|
synced, ibd |
a node you run yourself, at the tip or mid-sync (answers like a host from before managed mode) |
first-run |
the setup (add &external=1 for the Umbrel-style three-step setup) |
managed-no-binary |
first run with no node program on disk: the Get the node step (download, check, test, install over a few seconds; &external=1 for the Umbrel flow, &platform_supported=0 for the unsupported-platform message) |
managed-stopped |
managed, no network chosen yet |
managed-running |
a managed Mainnet node mid-sync (add &external=1&agent_alive=0 for an Umbrel app whose node agent stopped answering) |
managed-switching |
a Mainnet to Signet switch in progress |
managed-crashed |
a Testnet4 node that crashed, with the last log lines |
port-conflict |
a start refused because another program holds the node's port |
update-available |
running v0.1.59 with v0.1.60 out; Update runs the whole job |
updates-off |
updates left to an app store (BLVM_UI_UPDATES=store) |
modules |
two modules installed (mock-only routes: the real host does not install modules yet) |
Extra knobs go on the same URL (&free_gb=3, &fail_verify=1, &fail_test=1 (&fail_test=pagesize fails the test run
with the 16 KB page-size explanation), &page_size=16384 (the node's computer uses 16 KB pages: the Get the node step and
the Updates card show the warning and hold the download back, with a Try anyway button that sends force:true (the forced job fails in its test run with the page-size text, or works with &page_fixed=1); &page_size=0 clears it), &fail_start=1, &stop_stuck=1,
&slow=20, &channel=self|store|off, &can_install=0, &custom=1; the header of web/mock/server.mjs lists them all).
For pruning: &pruned=5000 switches block pruning on for the current network (&pruned= clears it),
&has_data=1 pretends chain data is on disk and that a pruning node worked on it (so Delete the chain data and the
conversion warning can be tried; &pruned_data=0 makes it a full store that no pruning node touched, where taking the setting
back is a plain save), &run_keep=300 sets the number the running node was started with (a different pruned= then shows
restart pending; &run_keep= means every block), &node_pruned=1 makes a standalone node say it is pruned, &pruned_verified=1 shows the stored range as verified.
For the Home sync display and the Deleting state: &wiping=1 marks the current network's chain data as being deleted
(&wiping=0 clears it), &ibd=1 reloads the chain mid-sync (&ibd=0 at the tip), &stall=1 keeps the validated height flat
(the waiting for a missing block hint appears after 20 s; &stall=0 resumes), &crawl=1 makes the height creep +1 to +3
a second with one feed tile per height, like the host's feed, and &tip=1000&height=1000 loads a tiny chain
(All 1,001 blocks; with &pruned=288, "288 of 1,001 · from height 713").
| Variable | Meaning | Default |
|---|---|---|
MOCK_PORT |
listen port | 3850 |
MOCK_SCENARIO |
one of the scenarios above | synced |
MOCK_DIST |
folder with the built page | web/dist |
MOCK_TIP |
tip height; skips the network lookup | live tip |
MOCK_BLOCK_SECS |
seconds between new blocks when synced, 0 to freeze |
60 |
cd web && npm run build && npm run screenshotsStarts its own mock server and captures every page and the main cards in both the synced and syncing states, 2x pixel
density, into screenshots/. SCREENSHOT_SIZE=1440x900 gives the size the Umbrel store gallery uses. It uses a
Chromium-family browser already on the machine (Chrome, Chromium, Brave or Edge); set BROWSER_PATH to choose one, or
SCREENSHOT_DIR to change the output folder. The images in docs/screenshots/ are 1440x900 at 2x of the scenarios
named above: home and settings-managed from managed-running, ibd-home from ibd, setup-network from
first-run&external=1, setup-get-node* from managed-no-binary&external=1, settings-updates from
update-available, settings-modules from modules, and the rest from synced.
All settings are environment variables. Runtime ones, for the console:
| Variable | Meaning | Default |
|---|---|---|
BLVM_UI_LISTEN |
address the console listens on | 127.0.0.1:3849 |
BLVM_UI_RPC |
node JSON-RPC address (the standalone target) | 127.0.0.1:48332 |
BLVM_UI_RPC_USER / BLVM_UI_RPC_PASS |
the console's RPC login, for nodes that need one (Bitcoin Node, Knots, Umbrel). In managed mode they are also the login the console gives the node it starts; without them it generates a pair (see The node's RPC login) | none |
BLVM_UI_RPC_COOKIE |
path to the node's .cookie file, used when no user is set; re-read on every call |
none |
BLVM_UI_WEB_DIR |
folder holding the built page | web/, dist/ or web/dist/ next to the binary, then this crate's web/dist |
BLVM_UI_MODE |
standalone (you run the node) or managed (the console runs it); BLVM_UI_MANAGED=1 is an alias |
console.json, then a bundle marker, then standalone |
BLVM_UI_NODE_BIN |
the node program: used by Turn Node On (standalone) and as the program in managed mode. When set, the in-app installer refuses (it would write where the console does not look) | auto-detected (standalone); next to the console in a bundle |
BLVM_UI_NODE_CWD |
working directory for that node (standalone only; a managed node always runs in <data root>/networks/<network>/) |
auto-detected |
BLVM_UI_DATA_ROOT |
managed: folder holding every network's data and logs, the downloaded node and the control files (layout) | ~/.local/share/blvm (Linux), ~/Library/Application Support/blvm (macOS), %APPDATA%\blvm (Windows) |
BLVM_UI_STATE_DIR |
folder for console.json (your settings), feeds.json (Latest Blocks history) and releases.json (release check cache). Managed mode without it: <data root>/console/ (a console that already has a console.json or feeds.json in the per-user folder keeps that folder until the new one has files of its own) |
$XDG_STATE_HOME/blvm-ui-next or ~/.local/state/blvm-ui-next (Linux), ~/Library/Application Support/blvm-ui-next (macOS), %APPDATA%\blvm-ui-next (Windows) |
BLVM_UI_AGENT |
managed: external means the node runs under blvm-ui-next agent in another container and this process spawns nothing (details); anything else runs the node inside this process |
in-process |
BLVM_UI_NODE_HOST |
with BLVM_UI_AGENT=external: host name of the agent's container; the console polls <host>:<network's RPC port> |
btc-commons-node-view_agent_1 |
BLVM_UI_NODE_VERSION |
managed: release version to show for the node program (set by packages that ship a known release) | none (shown as "Custom build") |
BLVM_UI_UPDATES |
in-app node installs and updates: 1 (on), 0 (off), store (done by the app store instead). Wins over console.json. The first install is always allowed; this governs updates once a release is installed |
1 |
BLVM_UI_ALLOWED_HOSTS |
comma-separated host names (no port) that may use the console besides localhost. Once it is set, every IP address and every *.onion name is accepted too, and the single token * accepts any Host. See Security |
none (loopback only) |
BLVM_UI_SETUP |
skip hides the first-run setup |
unset |
BLVM_UI_PLATFORM |
testing only: pretend to be linux-x86_64, linux-aarch64 or unsupported when choosing the release file |
detected |
BLVM_UI_PAGE_SIZE |
testing only: pretend the memory page size is this many bytes (a power of two, for example 16384), to see the page-size warning on a machine with 4 KB pages. Only matters for the linux-aarch64 release |
read from the system |
BLVM_UI_GEO_DB |
path to the .mmdb peer location file |
geo/dbip-country-lite.mmdb next to the binary, then this crate's geo/ |
BLVM_AGENT_P2P_PORT_<NET> |
with BLVM_UI_AGENT=external: the P2P port the agent starts the node of that network on (<NET> is MAINNET, TESTNET4, TESTNET for Testnet3, SIGNET or REGTEST). Read here only to print the right port in Settings and in /api/status; give the web and the agent container the same values. A value that is not a port is ignored |
the network's own port |
BLVM_UI_SELF_GEO |
where the "You!" dot goes: auto (node-reported IP, else time zone), ip, timezone, fixed:LAT,LON[,Label], off. Can also be set at build time (BLVM_UI_SELF_GEO=fixed:52.52,13.405,Berlin cargo build --release); a runtime value wins |
auto |
RUST_LOG |
host log filter | blvm_ui_next=info |
For the node agent (blvm-ui-next agent, the node container of the Umbrel app):
| Variable | Meaning | Default |
|---|---|---|
BLVM_AGENT_RPC_BIND |
address the node's RPC server binds to (--rpc-addr <bind>:<port>); 0.0.0.0 so the console container can reach it |
127.0.0.1 |
BLVM_AGENT_BUNDLED_BIN |
program to run when <root>/bin/blvm does not exist (for example one baked into an image) |
none |
BLVM_AGENT_P2P_PORT_<NET> |
P2P port for the node of that network (MAINNET, TESTNET4, TESTNET for Testnet3, SIGNET, REGTEST), as --listen-addr 0.0.0.0:<port>. The Umbrel app sets MAINNET to 8334 because Umbrel's Bitcoin Node holds 8333. A value that is not a port (0, text, above 65535) is ignored. The agent's check that the network's ports are free looks at this port |
the network's own port (8333, 48333, 18333, 38333, 18444) |
BLVM_RPC_USER / BLVM_RPC_PASS |
the node's RPC login. The agent writes them into the node's config file before every start (see The node's RPC login). A node that listens beyond loopback is not started without them | none |
The agent also reads BLVM_UI_DATA_ROOT (or --root DIR, else /data). Only one agent can run on a data folder
(control/agent.lock); a second one exits with a message.
The setup appears on its own in two cases: a managed console with no network chosen yet, and a fresh standalone install
that has a node program to offer (a bundle, or BLVM_UI_NODE_BIN). A state folder that already holds feeds.json or
console.json (a console from before the setup existed), BLVM_UI_RPC, BLVM_UI_MODE=standalone, or a console with no
node program all skip it. Run setup again and Use a managed node in Settings open it on purpose.
During the initial sync, blocks below the assume-valid height are accepted without re-checking their scripts and
signatures; every block above it is fully verified. This makes the first sync much faster. What the node skips below the
height is precisely this: script and signature checks and, because the initial sync runs on the node's parallel path,
also the per-transaction structure checks, the sigop count and the witness-commitment check. Blocks that arrive at the
tip and reorgs are always fully verified. The one exception is the node's BLVM_IBD_FOLLOW_TIP=1 mode (the console never
sets it; Child environment could), which keeps a node near the tip on the initial-sync path, so blocks below the height
stay unchecked there. The setting is a trust decision: the node does not compare the height with the chain's work.
Only use a height you trust.
console.json keys (managed mode):
| Key | Meaning |
|---|---|
assume_valid_by_network |
{"<network id>": "<value>"}, one entry per network. Values: "default" (the node's built-in height for that network; the console states full validation instead on Testnet4 and regtest), "off" (verify everything), or a block height of up to nine digits. Hash values are accepted only in the legacy global assume_valid key, for old files: the settings API answers 400 for a block hash in assume_valid_by_network and in the wizard's assume_valid (the node's hash form skips nothing). A hash found in an old file is still passed on, and the Validation card and the wizard step say so until it is replaced |
assume_valid |
the older global setting (a value, or a 64-hex hash from an old file). It is the fallback for a network that has no entry above, so existing files keep working |
The node's built-in heights (blvm-node/src/config/mod.rs, default_assume_valid_height_for_network): Mainnet 912,683,
Testnet3 4,550,000, Signet 267,665, Testnet4 and regtest 0. The value becomes --assumevalid <height> (or
--noassumevalid for "off", a height of 0, and the default on Testnet4 and regtest) on the node's command line. The
console removes BLVM_ASSUME_VALID_HEIGHT from the node's environment (the node reads it before the command line); only
the Child environment setting can set it. The node reads assume-valid at start, so a change applies at the next start or
after Restart. On Umbrel the value travels to the node's container in control/desired.json: Save rewrites that file with the new value but the same running, restart_id and start_id (the running node is not touched, a node that crashed or exited by itself is not started by a Save, yet a node the agent starts by itself later, after a crash or a restart of its container, uses the saved value), and Restart or the next start bumps restart_id or start_id, or writes it with the start. The same goes for the pruning value. If Child environment sets BLVM_ASSUME_VALID_HEIGHT, the node uses that height instead of this setting; assume_valid_effective then carries overridden_by_env: true and a note, and the Validation card shows the note.
A full node keeps every block it ever downloaded. A pruned node keeps the newest N blocks and deletes older block
bodies after it has verified them: it still checks every block, rebuilds the same chain state, and serves recent blocks to
other nodes, but it cannot serve old history and cannot look up an old block (getblock on a deleted block answers
"Block not found", the same as for an unknown hash). Pruning is a per-network setting.
console.json:
| Key | Meaning |
|---|---|
prune_by_network |
{"<network id>": <N>}. N is the number of newest blocks to keep, at least 288 (BIP159 promises the last 288 blocks to peers, and a reorganisation cannot be deeper than what is kept) and at most 100,000,000. A network without an entry keeps every block |
The console writes the node's [storage.pruning] config into networks/<id>/blvm.toml (mode 0600) before every start,
because the node has no command-line flag or environment variable for it. The node's own defaults for an omitted key are
unsafe (an omitted mode becomes aggressive, which replays the chain from the first block every 144 blocks; auto_prune
and incremental_prune_during_ibd default to off), so every key is written, with N substituted three times:
[storage.pruning]
auto_prune = true
auto_prune_interval = 144
min_blocks_to_keep = N
incremental_prune_during_ibd = true
prune_window_size = N
min_blocks_for_incremental_prune = 288
prune_on_startup = false
[storage.pruning.mode]
type = "normal"
keep_from_height = 0
min_recent_blocks = NThe file is written on every start, with or without a login ([rpc_auth] is there only when there is one), and the
node is always started with --config ./blvm.toml; otherwise it could pick up ~/.config/blvm/blvm.toml or
/etc/blvm/blvm.toml. On Umbrel the node container's agent writes the file, so the value travels in
control/desired.json (prune_keep_blocks) with the next start or restart.
The [storage.pruning] keys and the type-tagged mode are the same in the node the official releases are built with:
the public blvm v0.1.60 release pins blvm-node 0.1.62 from crates.io; its config/storage.rs is identical to the clone
in this workspace (commit e39e82a, see UPSTREAM_VERSIONS.md). So the same file works with the public binaries. The node prints no line saying that
pruning is on. It logs Loading configuration from: ./blvm.toml and Configuration loaded successfully from file (a file it
cannot parse is reported as Failed to load config file ... Using defaults, and then it runs with no pruning and no login), and
getblockchaininfo shows "pruned": true and getpruneinfo "pruning_enabled": true, "mode": "normal".
What to expect:
- The node reads the setting at start. A change applies at the next start or after Restart. The dashboard shows what the
running node says (
getblockchaininfo.pruned, and ingetpruneinfopruning_enabledandmin_blocks_to_keep, the number of blocks it keeps; one more call in the full poll batch, only for a console that manages the node or a node that says it is pruned), so a saved change that has not reached the node yet is shown as pending (pruning.pending_restart: on/off or the number differs). The stored-range estimate and the probe use the node's number, not the saved one. While the node does not answer,node_reports_pruned,node_enabled,node_keep_blocksarenulland nothing is pending: an old answer is never carried across a stop. - What is deleted. Block bodies only. The node keeps headers, the chain state (so every new block is still fully
validated), and, in this mode, the witness and undo data, so the disk still grows slowly. The node's database file
(
data.mdb) never shrinks after blocks are deleted: it reuses the freed space but does not give it back, so a store that was large stays large. A fresh sync with pruning on stays small. - Switching pruning on or lowering N over existing data is slow. The node deletes old blocks one by one, from the start
of the chain. The conversion begins with the first new block the node accepts after it has started (automatic pruning runs
in the node's run loop and
prune_on_startupis off), not at the start itself, and the node does not answer meanwhile: minutes on Testnet4, possibly hours on Mainnet. On regtest it never began on its own in our tests, because blocks made withgeneratetoaddressdo not trigger automatic pruning. The console warns and asks first (PATCH /api/settingsanswers with awarning). Raising N is free, but blocks already deleted do not come back (the response carries anote). - Going back to a full node needs a resync. The node accepts the change without complaint and would then advertise
blocks it no longer has. So the console refuses it when chain data exists and a node was once started pruned on it:
409with{"needs":"wipe"}. The folder remembers that innetworks/<id>/meta.jsonasran_pruned: true, written when a node is spawned with a[storage.pruning]block (by the console, or by the agent in the node container), and cleared only by the wipe.prune_effective.has_pruned_datasays so (has_datasays only that data exists). A pruning setting that was saved but never used over a full store is taken back with a plain save (200, nothing deleted). Sending the same patch with"confirm_wipe": true, with the node stopped (409"Stop the node first" otherwise), deletesnetworks/<id>/data(only that folder:meta.json,logs/andblvm.tomlstay), forgetsran_pruned, the saved heights and the Latest Blocks history of that network (the status shows no old heights afterwards); the node then downloads the chain again. This is the only thing in the console that deletes chain data, and only on request. Order of a wipe, with the console's state locked: all starts are held (also a queued automatic restart after a crash, which is cancelled), the node is checked to be stopped again, the network is markedwiping(managed.networks[].wiping; a start, a settings change or a switch answers409meanwhile); then the lock is released for the delete (a status poll does not wait for it) and taken again to finish. The whole wipe, including the save that follows it, runs to completion even if the page disconnects (a reload, a closed tab): the request only waits for it, and the next poll shows the result. With the agent in another container (Umbrel) the console cannot see the process: the agent must have a fresh heartbeat and astate.jsonthat has caught up with the last start, stop or restart request written todesired.json,desired.jsonmust not ask for the node to run, and the node must not be starting, running or stopping; otherwise409"The node agent is not answering; try again when it is back." (or "Stop the node first"). The same quiet agent holds back every other change that needs a stopped node (network, data folder, node program, RPC addresses, setup with another network, a network switch); a plain Stop is still delivered. A patch that movesdata_rootand removes pruning is judged against the new folder. - Automatic pruning follows the blocks the node receives. The node prunes every 144 blocks of the chain it follows. On
regtest, blocks made with
generatetoaddressdo not trigger it (onlypruneblockchain <height>, an admin call, prunes there), so a regtest node can keep more than N blocks for a long time. - The numbers. The node's own
pruneheightis not reliable (it is kept in memory, starts at 0 after every restart, and ignores the pruning done during the initial sync), so the console works out the stored range itself. The estimate isfrom = max(0, height - N + 1)andstored = height - from + 1, counted from the tip the console knows (while the node is still catching up the newest N blocks are at the top of the chain, so the estimate can say zero stored). It then verifies the estimate against the node: it asks for the block at the estimated start and for the one just below it (getblockhash+getblock); if the one below is gone and the one at the start is there it shows verified. The node deletes in steps of 144 blocks, so it normally keeps a little more than N; the console then looks further down (at most 8 batched requests, up to about 100 calls in all) and shows the real start. The probe makes those calls over the console's own RPC login, so they count against the logged-in rate limit (a burst of 100, then 10 a second), not the stricter limit for requests without a login. It looks again about every 144 blocks (and at least every two minutes), not on every poll. If nothing can be verified (for example while the blocks are still downloading) the figure stays estimated. Nothing here changeslocal_heightornetwork_height. - The genesis block. The node never serves the body of block 0 (
getblockanswers "Block not found" even on a full store), so the probe never asks below height 1. A store whose block 1 is there is stored from the start: the range is reported as starting at height 0 with the genesis block counted as stored, so a full node with tip H shows H + 1 blocks (a chain shorter than N is "verified" that way too).
The console only listens on localhost by default. It can start, stop and update your node and change peer settings, so do not expose it to a network you do not trust.
Every request that changes something (POST/PUT) must have Content-Type: application/json, a Host that is
localhost (or listed in BLVM_UI_ALLOWED_HOSTS), and, when the browser sends one, an Origin on such a host. Every
other route (see the API table) also needs the header X-BLVM-UI: 1 (X-Commons-UI: 1 works too); the page
sends it on every post. For /api/node, /api/connect and /api/rpc the header is not enforced yet: set REQUIRE_UI_HEADER_ON_LEGACY_ROUTES to
true in src/http.rs (one line) once the page that sends it is what you serve. If you reach the console over a LAN
address or a name, add that name to BLVM_UI_ALLOWED_HOSTS or the post is refused.
LAN, Umbrel and Tor. Every /api/* request, reads included (/api/status too), is answered 403 unless its Host is
localhost or listed in BLVM_UI_ALLOWED_HOSTS. The 403 text names the variable, the page shows it, and the console
logs a warning at startup when BLVM_UI_LISTEN is reachable from other computers (not a loopback address) while the
variable is unset. For the Umbrel compose file (the app proxy forwards the app's host name as Host), set it before
upgrading, for example:
environment:
BLVM_UI_LISTEN: 0.0.0.0:3849
BLVM_UI_ALLOWED_HOSTS: umbrel.local,btc-commons-blvm-ui_web_1 # every name the browser or the proxy uses, no portWithout it the page loads but power, connect and the status itself are refused. Behind a gateway that passes the
browser's own Host through (Umbrel does), people reach the app by name, by LAN address (192.168.1.5:3849) or over Tor
(*.onion): once BLVM_UI_ALLOWED_HOSTS lists at least one name, every IP-address Host and every *.onion Host is
accepted too (the same goes for Origin), and the token * accepts any Host. With the variable unset only loopback
works, as before.
An /api/* path the console does not have answers 404 with a JSON body (never the page), and a request body larger
than 64 KiB is 413; the cards for features a smaller host lacks (updates, modules) say "not available".
Downloads are checked, not signed. The node program the console downloads comes only from
github.com/BTCDecoded/blvm/releases/download/ (redirects only to GitHub hosts, at most 120 MB). It is verified against the
published checksums, so you know it is the file the release lists; releases are not signed yet, so this does not prove
who built it. The program is also run once (--version) before it is installed, and an update that does not come up is
rolled back.
By default the console only watches a node you run yourself (standalone). In managed mode it also starts, stops
and restarts one blvm binary for you, keeps each network's data in its own folder, and writes the node's output to a
log file. First start describes the setup walkthrough.
How the mode is chosen, first match wins:
BLVM_UI_MODE=managed|standalone- the
modesaved inconsole.json(in the state directory, next tofeeds.json; written by the wizard and Settings) - a file
blvm-bundle.tomlnext to the console executable and ablvmbinary beside it: managed - otherwise standalone
Layout under the data root (BLVM_UI_DATA_ROOT; on Umbrel /data, shared by both containers):
bin/blvm link to the program in use (the in-app installer keeps it current)
bin/blvm-v0.1.60 downloaded release programs (the last two are kept)
bin/installed.json {tag, asset, sha256, installed_at, previous_tag} of the program in bin/blvm
bin/staging/<tag>/ a download in progress (deleted when it ends)
control/desired.json what the console wants running (written by the console)
control/state.json what the agent reports (written by the agent, every 2 s and on change)
control/node.pid which node process the agent started (a restarted agent or console adopts it)
control/agent.pid the `agent` process (only with the `agent` subcommand)
console/console.json the console's settings (managed mode without BLVM_UI_STATE_DIR); also feeds.json, releases.json
networks/<network>/ the node's working directory
networks/<network>/data/ chain data (--data-dir ./data)
networks/<network>/logs/node.log everything the node prints (copy-truncated at 50 MB, one .1 kept)
networks/<network>/meta.json first use, last use, last height the console saw (see below)
A restart never repeats the setup: console.json remembers setup_done, the network and the other choices, the node
program is found again in bin/blvm, and with start the node when the console starts (start_on_boot, on by
default in managed mode) the node starts by itself, provided you did not leave it stopped (node_wanted in
console.json: set by Start, Restart, a network switch and a setup that starts the node, cleared by Stop and by a setup
that does not). With an external agent the agent restores its own desired.json by itself, so the console writes
nothing when that file exists, and writes running: true only for a setup that was never applied. Nothing is started
when no network has been chosen or no node program exists yet.
There is no default network: the console never starts a node until a network was chosen (mainnet, testnet4,
testnet, signet, regtest). The node is started as
blvm --network <n> --data-dir ./data --rpc-addr 127.0.0.1:<port> --listen-addr 0.0.0.0:<p2p port> --config ./blvm.toml (plus --noassumevalid on Testnet4 and regtest, or your assume-valid choice) with
BLVM_SCHNORR_PER_SIG=1 in its environment (upstream's default collector mode skips Taproot signature checks; the
per-signature mode is the complete one). Both can be changed in console.json: assume_valid_by_network (per network:
default, off or a height; assume_valid is the fallback, see Assume-valid) and child_env ({"NAME":"value"}; an empty value removes the variable).
Before it spawns anything the console checks that the network's RPC and P2P ports are free. If another program holds one the start is refused and the Node card says which ("The RPC port 48332 is in use by pid 4242 (blvm)"); it is never shown as running, and the other program's height, peers and version are never shown as this node's (the same check repeats while the node runs, a few seconds apart).
Process states: stopped, starting (until the node's RPC answers any method, and the answering listener is this
node's; the node waits about 30 s for peers first; a silent node is stopped after 180 s, but a node that is alive and
still writing its log is waited for, 15 minutes at most), running, stopping (SIGTERM, then SIGKILL after 200 s; the node needs up to 30 s to save state) and
crashed. A crash is restarted after 5 s, 30 s and 120 s (the node's whole process group, modules included, is killed first); a
fourth crash within 10 minutes stays crashed with the last 50 log lines (terminal colour codes removed). Changing a
setting that needs a stopped node, or running setup, cancels a restart that is still waiting. SIGHUP (closing the
terminal) quits the console like SIGTERM, stopping the node unless stop_node_on_exit is false. When the console quits it stops the node first; set stop_node_on_exit to false (Settings, or
console.json) to leave it running. If the console itself dies the node keeps running, and the next console re-adopts it
from control/node.pid.
The version shown is never a guess: blvm_version is "—" whenever the node is not answering, and the version it last
reported moves to blvm_version_seen ({version, at}). In managed mode the version of the binary comes from running
<bin> version (at start, and again when the file changes).
Each network lives in its own folder, so switching never deletes anything. In Settings, Network, pick another network on the carousel: the console asks first and shows what it knows (how much the current network keeps on disk, the block it will continue from, whether the new one already has data, free disk, and a warning where it applies, for example that Testnet4 checks every block in full). On Confirm:
- Stopping: the current node gets a graceful stop (SIGTERM, up to 200 s, the same as the Stop button) and its data stays where it is. A node that is already stopped skips this step.
- Starting: the console saves the new network, points its own polling at that network's RPC port, and starts the
node in
networks/<new>/with that network's own arguments (--noassumevalidon Testnet4 and regtest, plus yourassume_validandchild_envsettings). The page follows by itself: Home shows the new network's sync state, and Latest Blocks shows that network's saved history, because history is kept per RPC address. - Done, or Failed with the last 50 log lines and a Try again button.
managed.switch in /api/status carries {id, from, to, phase, started, error?, log?} while this runs and for 30
seconds after it ends. While it runs the Start/Stop/Restart buttons, setup, and network changes in /api/settings are
refused, and a second switch is answered 409 busy. Details that are easy to ask about:
- The saved network (
console.json) changes only after the new node has actually been spawned. If the old node does not stop in time (a plain stop already allows 200 s plus a SIGKILL), or the new node cannot be spawned, the switch fails and the old network stays chosen. If the console itself quits in the middle, the next start sees whichever network was saved last: the old one before the spawn, the new one after it (that node is then re-adopted fromcontrol/node.pid). - If the new node crashes, stops by itself or never answers (180 s), the switch is
failed. The console already follows the new network at that point; the automatic crash restart is cancelled so it cannot race with the button, and Try again starts that node again. - A switch asked for while the node is
starting,stopping, or waiting to restart after a crash is accepted: it cancels any queued restart, waits for the old process to be gone, and goes on. - A switch while the node is stopped skips the stopping step and still starts the new network.
- Switching to the network you are on, with under 5 GB free on the data volume, or before any network was chosen is
refused (the preview says why in
reason).
meta.json is {created, last_used, last_height, last_header} (unix seconds and block heights). The console writes it
when a node starts, stops or is adopted, and every 60 s while one runs; last_height is the height of the last
successful RPC poll. It lets the switch dialog and the network table say "block 712 004" for a network whose node is off.
The network table in /api/status (managed.networks[]) is filled from these folders: bytes (allocated size,
measured every 60 s), has_data (chain files exist, beyond the node's empty module folder), last_height, last_used.
In managed mode the console can download the official node from the GitHub releases of BTCDecoded/blvm (Linux x86_64
and aarch64 only: there is no macOS or Windows build, and the program needs glibc 2.39 or newer, i.e. Ubuntu 24.04 or
Debian 13). The release list comes from api.github.com (no token, cached ten minutes in memory and in releases.json,
the last answer is used when GitHub cannot be reached). POST /api/node/install (the first install, any channel) and
POST /api/node/update (a newer release; follows the updates channel) run one job at a time:
- before anything is downloaded the console reads the memory page size of the computer that will run the node (on
Umbrel the node container's agent reports it in
state.json). The official linux-aarch64 release (v0.1.60) is built with jemalloc at 4 KB pages and aborts on a kernel with bigger pages (the Raspberry Pi 5 uses 16 KB); in that caseGET /api/node/releasessaysruns_here:falsewith the reason, the page shows it and disables the download, andPOST /api/node/installand/updateanswer409 {ok:false,code:"page_size",error}without fetching anything (unless the request carries"force":true, which skips this check and goes on to the download and the test run; the page offers it as Try anyway for a newer release that may support the device, and the test run still refuses a program that aborts, so nothing wrong is installed); - download the raw asset
blvm-<version>-<platform>tobin/staging/<tag>/(at most 120 MB; only fromgithub.com/BTCDecoded/blvm/releases/download/, redirects only to GitHub hosts;bytes/totalprogress); - verify its SHA-256 against the release's
checksums.sha256(orSHA256SUMS-blvm-<platform>); a mismatch deletes the download. Releases are not signed yet: this only proves the file is the one the release lists; - test runs the new program once (
--version, thenversion); it must exit 0 and print a version. A wrong CPU or a too-old glibc is reported here with the system's words, and nothing is installed. A program that aborts with<jemalloc>: Unsupported system page sizegets the plain page-size explanation instead of the raw lines (the raw lines stay in the console's log); - install moves it to
bin/blvm-<tag>, swaps the linkbin/blvmatomically, writesbin/installed.json, and keeps only the current and previous version on disk; - restart (only if a node was running) stops the node and starts it on the new program. If it crashes, stops,
never answers its RPC, or crashes within
60 sof first answering, the link is put back, the node is started on the old program, and the job endsfailedwithrolled_back: true; - done or failed with
errorin plain words.
The version shown to the user is the installed release tag (managed.node_version, v0.1.60); a program that did not
come from the installer (BLVM_UI_NODE_BIN, a setting, a bundle) is a "custom build". When BLVM_UI_NODE_BIN is set the
installer refuses (it would write somewhere the console does not look). The installer works the same with an external
agent: the program lands in the shared bin/ and the node is restarted through desired.json.
- In-process (default): the console itself starts, watches and stops the node. Nothing needs configuring.
- External (
BLVM_UI_AGENT=external, used by the Umbrel app): the node runs in a second container that mounts the same data folder and runsblvm-ui-next agent --root /data. The node lives inside that container: restarting the container restarts the node. The console then spawns nothing: start, stop, restart and switch writecontrol/desired.json({generation, network, running, binary, env, assume_valid, prune_keep_blocks, stop_grace_secs, rpc_port, restart_id, start_id}; saving a setting rewrites it with the new values, the samerunning,restart_idandstart_id, and the agent starts a node it brings back by itself with them), the process state is read fromcontrol/state.json, logs are read from the sharednetworks/<network>/logs/node.log, and the port-conflict check is skipped (the agent does it next to the node). The console polls the node atBLVM_UI_NODE_HOST:<rpc port>. Restarting the console never touches the node; quitting the console never stops it.
blvm-ui-next agent [--root DIR] (DIR defaults to $BLVM_UI_DATA_ROOT, else /data) has no web server. It acts once
per new desired.json generation (start, when running just turned on or start_id went up and the node is not running; stop; or stop then start when the network or restart_id changed; a generation that only carries saved settings changes what the next start uses and nothing else), supervises
the node with the same machinery as the in-process agent (per-network folder, file logs, crash restarts after
5 s / 30 s / 120 s, three tries), adopts a node that outlived it (a kill -9 of the agent process; in a container the
node dies with the container, because the agent's container is its parent) through control/node.pid, starts the node
again by itself after a container restart when desired.json says running (a node that was stopped while it was
saving is killed without a flush, so give Docker the 240 s grace the app's compose file sets), and
writes state.json ({agent_version, heartbeat, generation, state, pid, since, network, exit_code, exit_signal, restarts, last_error, conflict, adopted, crash_log, installed_version, installed_report, binary, binary_found, restart_id}) on every change and every 2 s. On SIGTERM it stops the node (SIGTERM, then SIGKILL after
stop_grace_secs, default 200 s), writes the final state and exits 0, leaving desired.json as it was.
| Variable (agent process) | Meaning | Default |
|---|---|---|
BLVM_AGENT_RPC_BIND |
address the node's RPC server binds to (--rpc-addr <bind>:<port>); 0.0.0.0 so the console container can reach it |
127.0.0.1 |
BLVM_AGENT_BUNDLED_BIN |
program to run when <root>/bin/blvm does not exist (for example the one baked into the node image) |
none |
BLVM_AGENT_P2P_PORT_<NET> |
P2P port per network (MAINNET, TESTNET4, TESTNET, SIGNET, REGTEST), listening on 0.0.0.0; see the table above |
the network's own port |
On Umbrel the console and the agent run from the same image, so they are always the same version. A hand-mixed install
(a newer console with an older agent, or the other way round) works for Start, Stop and Restart, with two differences.
An old console never bumps start_id, so a new agent ignores its Start (or Restart) on a node that crashed or
exited while desired.json already said running; Stop, then Start works, and so does a Restart of a running node. An
old agent ignores start_id and starts a node that crashed or exited while desired.json says running on any
new generation, so a Save in a newer console can bring such a node back. Run the console and the agent from the same
image.
The node reads no environment variable for its RPC login: it only reads [rpc_auth] from a config file. So before every
start the agent (the in-process one too) writes networks/<network>/blvm.toml (mode 0600) and starts the node with
--config ./blvm.toml (always, also without a login: the file then has no [rpc_auth] table, and carries the
pruning block when a network is pruned):
transport_preference = "tcponly" # the one key the node insists on in a config file; "tcponly" is its own default
[rpc_auth]
required = true
username = "…"
password = "…"- Umbrel (external agent): the login is
BLVM_RPC_USER/BLVM_RPC_PASSon the agent container (Umbrel derives the password from the seed, see the store'sexports.sh); the web container sends the same pair asBLVM_UI_RPC_USER/BLVM_UI_RPC_PASS. The agent binds the RPC toBLVM_AGENT_RPC_BIND(0.0.0.0there, because the console is in another container) and refuses to start a node whose RPC would be reachable beyond loopback without a login. - In-process (desktop): the node's RPC stays on
127.0.0.1. The console usesBLVM_UI_RPC_USER/BLVM_UI_RPC_PASSwhen both are set, else it generates a pair once (usercommons, a random password) and keeps it inconsole.json(mode 0600). The page never shows it, and/api/settingsand/api/statusdo not carry it. - Safety check: once a node answers, the agent asks it for
getblockcountwithout a login. If that works (the node did not apply the config file, for example because it could not parse it and fell back to its defaults), the node is stopped and the card says why. A request without or with a wrong login gets401from the real node (tested against the builtblvmon regtest). Only an explicit401or403counts as "asks for a login": a dropped connection or a timeout (the node limits the connections of one address) is no answer, and the check is repeated a few seconds later before the node is calledrunning. - A node that is still running from before this version (desktop, in-process) has no login. The console creates one
and saves it, then restarts that node once to turn it on: the node is adopted, it answers a caller without a
password, so it is stopped and started again with the config file, and the Node card says "Restarted the node to turn
on its RPC password." (the log has the same line). The safety check above keeps stopping a node that should have a
login and does not. A start without a login (loopback only) rewrites
blvm.tomlwithout[rpc_auth], so a login from an earlier start is never applied by accident (the node reads that file from its working folder even when it is not told to).
Quick start (regtest, a node binary you built):
BLVM_UI_MODE=managed BLVM_UI_NODE_BIN=/path/to/blvm BLVM_UI_DATA_ROOT=/tmp/blvm-data cargo run --release
# then, from the page's wizard or with curl:
curl -s -H 'Content-Type: application/json' -H 'X-BLVM-UI: 1' \
-d '{"mode":"managed","network":"regtest","start":true,"stop_on_exit":true}' http://127.0.0.1:3849/api/setupThe install ends at the test step with The new program does not run on this computer: <jemalloc>: Unsupported system page size. Cause: the official linux-aarch64 node release (v0.1.60) is built with jemalloc at 4 KB memory
pages, and the page size is fixed when the program is compiled. A Raspberry Pi 5 runs its default kernel with 16 KB
pages, so the program aborts at its first allocation. Nothing is wrong with the download, and nothing was installed.
Two ways on:
- Wait for a release built with 16 KB page support. It has been requested from Bitcoin Commons. When a newer release is out, the Try anyway (a newer release may support this device) button next to the warning skips the console's check (it only knows that the page size is bigger than 4 KB, not which release supports it) and runs the download and the test run; if the program still aborts, nothing is installed.
- On a Raspberry Pi 5 only, switch the kernel to 4 KB pages: add
kernel=kernel8.imgto/boot/firmware/config.txtand reboot. This is a change to the system, so do it only if you know what it means.
The console now checks before it downloads: it reads the page size, and when the release cannot start here it says so
in the Get the node step and the Updates card, disables the download, and answers 409 to POST /api/node/install
and /update. If the check could not read the page size, the test run still turns the jemalloc lines into the same
explanation. GET /api/node/releases carries runs_here, runs_here_reason and page_size; /api/status carries
managed.platform (see the API); the console's log has one page size N bytes line at start (and the agent's
own, on Umbrel), with a warning when the release does not run.
Offline, country-level IP lookup with the DB-IP Country Lite database (CC BY 4.0). It is a 4 MB download, about 8 MB
unpacked, and git-ignored. Download it once into geo/:
curl -L -o geo/dbip-country-lite.mmdb.gz https://download.db-ip.com/free/dbip-country-lite-YYYY-MM.mmdb.gz
gunzip geo/dbip-country-lite.mmdb.gzEach country is drawn at the average of its time zone cities (from the built-in tzdb table), and peers in the same country get a small fixed offset so their dots do not stack. The larger City Lite file also works; the country file is used first when both are present. Without a file, the console runs normally and peers just have no dot on the globe.
Kept in feeds.json, one entry per node RPC address, so it survives page reloads, console restarts, rebuilds and
network switches. It is only cleared when:
- the node's height stays well below the saved height for about 15 seconds (the chain data dir was wiped), or
- the node reports a different chain on the same RPC address.
A node that briefly reports height 0 while restarting does not clear it.
The page only uses these; they are handy for scripts too.
| Route | Does |
|---|---|
GET /api/status |
the full snapshot the page renders (sync, peers, disk, feed, health). disk_used_* is the chain size and disk_used_source says where it comes from: "folder" (measured, always in managed mode) or "node" (the node's own figure, an estimate in the official release) |
POST /api/connect {"rpc":"host:port"} |
point the console at another node |
POST /api/node {"action":"toggle"} |
start or stop the node process |
POST /api/rpc {"method":…,"params":[…]} |
pass-through for peer settings: addnode, setban, clearbanned, disconnectnode, setnetworkactive |
GET /api/status (more) |
adds mode, setup_done, managed (process state, pid, network or null before one is chosen, data root, free disk, networks[] with bytes, has_data, last_height, last_used, p2p_port, switch, node version, crash log; null when standalone), modules_pages (page ids of installed and enabled modules; [] while nothing can be installed), show_logs, bundle_available, blvm_version_seen, pruning ({enabled,keep_blocks,node_reports_pruned,node_enabled,node_keep_blocks,pending_restart,stored_from_height,stored_blocks,source}: the console's setting for the current network, getblockchaininfo.pruned and getpruneinfo (pruning_enabled, min_blocks_to_keep) of the running node (null while it does not answer), whether the saved setting differs from what the node runs with, the stored range, and source "estimated", "verified" or "unknown"; see Pruning); each networks[] row also has wiping; managed.platform is {page_size, node_page_size, runs_here, runs_here_reason, agent}: the console's own memory page size in bytes, the page size of the computer that runs the node, whether the official release can start there, and (node in another container) agent:{page_size,runs_here,reason} as the agent wrote it to state.json (platform), else null |
POST /api/node {"action":"start"|"stop"|"restart"|"toggle"} |
managed: returns at once, watch managed.process.state. Also POST /api/node/start, /stop, /restart |
GET /api/node/logs?after=<seq>&limit=200 |
managed: {lines:[{seq,text}],file} from the in-memory tail of node.log (lines= also works); 404 when standalone |
GET /api/settings |
console.json as the console sees it (effective values, env_locked), plus assume_valid_by_network, assume_valid_defaults ({"<network id>":{"height":<node's built-in default>,"forced_off":<the console runs it with full validation>}}) and assume_valid_effective ({network,value,argv} for the current network: the options the node will start with, plus overridden_by_env and note when Child environment sets BLVM_ASSUME_VALID_HEIGHT; null before a network is chosen), p2p_ports ({"<network id>": <port>}: the network's own port, or the one from BLVM_AGENT_P2P_PORT_<NET> when the agent is external), prune_by_network, prune_min_blocks (288) and prune_effective ({network,keep_blocks,has_data,has_pruned_data} for the current network; both are read from the folder; has_pruned_data is data plus ran_pruned in meta.json; null before a network is chosen) |
GET /api/node/releases[?force=1] (force skips the cache; the page's Try again uses it) |
{installed:{tag,installed_at}|null, latest:{tag,published_at,notes_url}|null, asset:{name,size,platform}|null, platform:"linux-x86_64"|"linux-aarch64"|"unsupported", can_install, reason, available, channel} plus current, custom, platform_supported, checked_at, notes_url, stale, offline, check_error, and runs_here (false when the official release cannot start on the computer that runs the node: 16 KB memory pages on linux-aarch64, such as a Raspberry Pi 5), runs_here_reason (the plain explanation with the two options, else null) and page_size (bytes of the node's computer, null when unknown; on Umbrel the agent's, from state.json). can_install does not change with runs_here: the page shows the warning and holds the download back. The first install is allowed on every channel; updates need channel:"self". Offline: the cached answer with stale:true, or latest:null, can_install:false and the reason. force=1 skips the cache at most once a minute |
POST /api/node/install {"tag"?:"v0.1.60","version"?:"0.1.60","downgrade"?:false,"force"?:false} |
start the first install (newest release when no tag): 202 {ok:true,job}; 409 {ok:false,error:"busy"} while a job runs; 409 {ok:false,code:"page_size",error} when the release cannot start on this computer's memory page size (runs_here:false; nothing is fetched; "force":true skips this refusal and goes on to the test run); 400 {ok:false,error} in plain words (unsupported platform, nothing to install, already installed, BLVM_UI_NODE_BIN set, bad tag, offline, a tag GitHub does not know: code:"no_such_release", an older tag than the installed one without downgrade:true: code:"downgrade") |
POST /api/node/update same body |
update an installed release; also 400 when nothing was installed by the console, or the channel is off/store |
GET /api/node/install (also /api/node/update) |
the last job until a new one starts: {phase:"idle"|"download"|"verify"|"test"|"install"|"restart"|"done"|"failed", tag, bytes, total, error, rolled_back} (also under job, with kind, started, finished). The same fields are in managed.update (with available, latest, channel) while a job runs and for 30 s after |
PUT or POST /api/settings |
patch: mode, network, data_root, node_bin, rpc, rpc_overrides, start_on_boot (alias start_on_launch; default on in managed mode), stop_node_on_exit (keep_node_running_on_exit is its inverse), show_logs (the Logs page switch, default false), modules_disabled, updates, assume_valid, assume_valid_by_network ({"<network id>": "default"|"off"|"<height>"|null}: only the networks named change, null removes an entry, a bad id or value (a block hash included) is a 400 that names it and saves nothing; no stop needed, the node reads it at its next start), prune_by_network ({"<network id>": <288 to 100000000>|null}, same patch rules; null for a network whose chain data a pruning node has worked on (has_pruned_data) is refused with 409 {"needs":"wipe"} unless the body also has "confirm_wipe": true and the node is stopped; switching on or lowering over existing data succeeds with a warning, raising with a note; see Pruning), child_env. setup_done: true alone just marks the setup as done (what Skip sends on Umbrel). Mode, network, data root and binary can only change while the node is stopped (409), and not while an install or update runs and not when an environment variable pins them (409) |
POST /api/setup {"mode":"managed","network":"regtest","data_root"?,"node_bin"?,"assume_valid"?:"default"|"off"|"<height>","prune_keep_blocks"?:null|<288 or more>,"confirm_wipe"?:bool,"start":bool,"stop_on_exit":bool} |
the wizard's result: validates (managed needs a network; a binary only when start is true, so a setup that only records the choice works before a program is downloaded; standalone may carry rpc), writes console.json (assume_valid and prune_keep_blocks are stored under the chosen network; a bad value is a 400, and the pruning guards are the Settings page's: a warning in the answer, or 409 {"needs":"wipe"}), creates the folders, starts the node only when start is true, and remembers whether the node was to run. Refused with 409 while an install or update runs |
POST /api/setup/test {"rpc":"host:port","user"?,"pass"?} |
one RPC check: {ok,chain,height,version,error} |
GET /api/network/preview?to=signet (or POST with {"to":"signet"} / {"network":"signet"}) |
managed: what the switch dialog shows: {from,to,from_bytes,to_bytes,from_height,to_height,to_last_used,from_ibd_pct,free_bytes,estimate_bytes,blocked,warnings[],allowed,reason}. allowed:false carries the plain reason (same network, a switch running, under 5 GB free, no network chosen). 404 when standalone, 400 for an unknown network |
POST /api/network/switch {"to":"signet"} ({"network":…} also works) |
managed: validates like the preview, then answers 202 {ok,id,switch,status} and runs in the background (progress in managed.switch). 409 {error:"busy"} while another switch runs; 400 {ok:false,error} when refused for any other reason |
src/ Rust host: HTTP server, RPC polling, snapshot, block feed, geo lookup, node process control
(agent.rs node agent, agent_daemon.rs `agent` subcommand, control.rs control files, releases.rs +
installer.rs node downloads, managed.rs / switch.rs managed mode)
web/src/ Svelte front end (App.svelte + one panel per page in lib/)
web/public/ logo, fonts, globe texture
web/mock/ mock API server and screenshot script
geo/ time zone table (+ the optional .mmdb you download)
docs/ screenshots used in this README (1440x900 at 2x, from the mock host)
Dockerfile multi-arch container image (see Docker)
MIT. See LICENSE.












