The localhost DIG node for the DIG Chrome extension, shipped as a self-contained, cross-platform Rust binary that installs as an OS service (Windows, Linux, macOS).
Naming. The binary, crate, and service are all
dig-node— the canonical, user-facing name for the local DIG node. Every machine-readable surface (/health,/version,--json) identifies itself asdig-node. The bind env-var names areDIG_NODE_PORT/DIG_NODE_HOST— the stable configuration contract (see the Environment section). For the legacy Linux package + the installer's pre-rename fallback, the GitHub release also publishes each binary under the olddig-companion-*filename (identical bytes). SeeUSER_JOURNEY.md.
The extension resolves chia:// (DIG) URLs by fetching encrypted, Merkle-proven content over a DIG
RPC and then verifying + decrypting it in the extension. By default it talks to rpc.dig.net;
pointing its server.host setting at dig-node makes that RPC local. The node speaks
the same wire contract as rpc.dig.net — because it routes every request to digstore's
dig_node_core::handle_rpc, the exact local-first node the native DIG Browser
runs in-process. So the extension works against it byte-for-byte, with the bonus that any .dig
store the node has cached locally is served without leaving the machine.
A Rust OS-service binary. The shipped artifact is the Rust
dig-nodebinary — a single binary with no runtime dependency (no Node install required) that installs cleanly as a Windows/Linux/macOS service, which a Node process does not do reliably, especially on Windows.
Download (or build — see below) the dig-node binary for your OS, then:
dig-node install # register as an auto-starting OS service on 127.0.0.1:9778
dig-node start # start it now
dig-node status # confirm it's serving (probes /health)To remove it:
dig-node stop
dig-node uninstall| OS | Service backend | Privilege | Runs as |
|---|---|---|---|
| Windows | Service Control Manager (SCM) | Administrator required for install/uninstall |
LocalSystem |
| Linux | systemd (user unit) | no root needed | the installing user |
| macOS | launchd (user agent) | no root needed | the installing user |
- Windows: the SCM has no per-user services, so
install/uninstallmust run from an elevated (Run as administrator) terminal.dig-nodedetects a non-elevated console and tells you, rather than failing deep insidesc.exe. The installed service runs the binary's internalrun-serviceentrypoint, which speaks the Windows Service Control Protocol (so the SCM does not kill it with error 1053). After install it auto-starts on boot. - Linux / macOS: the service installs at user level (systemd
--user/ a launchd GUI agent), so nosudois needed and it runs as you. (systemd user services start at login; enable linger —loginctl enable-linger $USER— if you want it running without an active session.)
In the DIG Chrome extension's options, set server host to:
localhost:9778
(dig-node defaults to the uncommon high port 9778 — deliberately clear of the collision-prone
common-dev ports like 80/8080, and the sibling of the dig-wallet API's 9777. The extension's
server.host default targets 9778 to match. Override with DIG_NODE_PORT if you need another port.)
The node opens two loopback listeners for the same app so it is reachable two ways at once:
| Address | Listener | Always on? |
|---|---|---|
http://localhost:<port> (default localhost:9778) |
127.0.0.1:<port> |
Yes — unprivileged, conflict-free. The guaranteed fallback. |
http://dig.local (no port) |
127.0.0.2:80 |
Best-effort — needs the privileged :80 bind (+ on macOS a loopback alias). Falls back gracefully if it can't bind. |
- The bare
http://dig.localURL (no:port) works becausedig-installerwrites a hosts entry127.0.0.2 dig.localand the node binds127.0.0.2:80. A distinct loopback IP (.2, not.1) is used so the port-80 bind can never collide with an unrelatedlocalhost:80service. - Neither listener binds
0.0.0.0— both are loopback IPs, so the node is never LAN-exposed. - Graceful fallback (never aborts): binding
127.0.0.2:80is privileged and may fail (no privilege, port in use, or — on macOS — a missing loopback alias). If it fails, the node logs a structured warning and serves localhost-only; it never aborts.localhost:<port>is the guaranteed fallback. SetDIG_NODE_DIGLOCAL=0to skip thedig.localattempt entirely. - Host-header allowlist. Both listeners answer only to the canonical local names —
dig.local,localhost,127.0.0.1,127.0.0.2(with or without a:port). A request with any otherHost(the classic DNS-rebinding vector — a public name pointed at the loopback bind) is rejected421 Misdirected Requestwith a cataloguedINVALID_REQUESTerror body, even though the bind is already loopback-only. A missingHost(HTTP/1.0 / health probes) is allowed.
| Platform | What it needs for 127.0.0.2:80 |
Notes |
|---|---|---|
| Windows | The installed service runs as LocalSystem (elevated), so the :80 bind works. A manual dig-node run from a non-elevated shell will fail the bind → localhost-only. |
— |
| Linux | root or the CAP_NET_BIND_SERVICE capability on the binary (sudo setcap cap_net_bind_service=+ep $(command -v dig-node)). A systemd user service has neither by default → localhost-only unless granted. |
127.0.0.2 is part of the always-up 127.0.0.0/8 loopback range — no alias needed. |
| macOS | The 127.0.0.2 loopback alias must exist first: sudo ifconfig lo0 alias 127.0.0.2. Binding :80 also needs elevation. The installer/service can add the alias (it does not persist across reboot unless made a launchd/networksetup step). |
.local is RFC-6762 mDNS-reserved (mDNSResponder); a /etc/hosts entry normally wins, but verify resolution if a dig.local name does not resolve. |
.localis RFC-6762 (mDNS) reserved. Ahosts//etc/hostsentry fordig.local(written by the installer) normally takes precedence over mDNS on all three platforms, so the name resolves to127.0.0.2without a multicast lookup. On macOS, where mDNSResponder is the resolver, confirm the hosts entry wins ifdig.localever fails to resolve.
dig-node run # serve on 127.0.0.1:9778 until Ctrl-C
# or simply:
dig-node # bare invocation == runAll knobs are environment variables (read at startup; install records the current values into the
service's environment so the service serves identically):
| Env var | Default | Meaning |
|---|---|---|
DIG_NODE_PORT |
9778 |
Port the node listens on (127.0.0.1). Uncommon high port (dig-wallet 9777 sibling) to avoid 80/8080 collisions. |
DIG_NODE_HOST |
127.0.0.1 |
Bind address (loopback — the node is a same-machine endpoint). |
DIG_RPC_UPSTREAM |
https://rpc.dig.net |
Upstream DIG RPC the embedded node proxies ciphertext/proof requests to on a local cache miss, and relays unhandled methods to. |
DIG_NODE_CACHE |
%LOCALAPPDATA%\DigNode\cache / $HOME/DigNode/cache |
On-disk cache dir for synced .dig modules (owned by dig-node). Leave it unset to share one cache with the DIG Browser — see below. |
DIG_NODE_CACHE_CAP |
1 GiB |
Cache size cap (floored at 64 MiB), LRU-evicted. Also settable via the cache.setCapBytes RPC. |
DIG_NODE_DIGLOCAL |
1 (on) |
Whether to ALSO open the bare-http://dig.local listener (127.0.0.2:80) beside localhost:<port>. Auto-attempt with graceful fallback (see Addressing). Set to 0/false to skip the attempt. |
DIG_NODE_MAX_OUTGOING_BYTES_PER_SEC |
0 (unlimited) |
Outgoing-bandwidth cap in bytes/second. Past the cap, a request for content this node holds is redirected to another peer known to hold it (SPEC §17) instead of served over-budget; served locally as a graceful fallback when no alternate holder is known. |
dig-node and the native DIG Browser both run
the SAME canonical dig-node-core node engine library (this repo), and both default to the SAME on-disk cache dir
(%LOCALAPPDATA%\DigNode\cache on Windows, $HOME/DigNode/cache on Linux/macOS). So when both are
installed they share ONE cache — a capsule fetched by the browser is served from disk by the
standalone service and vice-versa, with no double-store.
- Omit
DIG_NODE_CACHE(the default) to keep that sharing — the node does not invent a path, it leaves dig-node to resolve its shared canonical default. dig-node makes that shared dir safe for two processes at once: atomic content-addressed module writes (so two writers converge with no partial files) plus a cross-process advisory lock around eviction and the config read-modify-write. (Requires thedig-nodecrate at the #95/#96 Pass A revision this repo pins.) - Set
DIG_NODE_CACHEonly to move that shared cache to an explicit location (a service data dir, or a volume shared between machines). If you do, set the same value for the browser's launch environment so the two keep sharing one cache; pointing them at different dirs gives each its own (un-shared) cache.installrecords an explicitDIG_NODE_CACHEinto the service environment so the installed service uses the same dir you installed it with. - Is the cache actually shared right now?
GET /healthandcache.getConfigreport acache.sharedboolean:true= the shared canonical dir,false= dig-node fell back to a process-private dir because the canonical dir was unwritable (it logs a one-shot warning and keeps serving, just un-shared for that session).cache.getConfigalso returns the effectivecache_dirpath.
POST / speaks JSON-RPC 2.0, the same contract rpc.dig.net and the native DIG Browser's
in-process node expose:
| Method | Behaviour |
|---|---|
dig.getContent / dig.getCapsule |
Verified retrieval — returns blind ciphertext + a Merkle inclusion proof + chunk lengths ({ ciphertext, root, complete, next_offset?, inclusion_proof, chunk_lens, …, source }). Served local-first from a cached .dig module, else proxied to the upstream verbatim (so the proxy path carries total_length / offset too) and the window cached. source is local or remote. The client (extension/hub/browser) verifies + decrypts — the node mirrors the ciphertext contract, it does not return plaintext. |
dig.getAnchoredRoot |
The store's chain-anchored tip root, resolved on-chain by walking the DataStore singleton lineage on coinset.org (the trusted root for the extension's chia:// root-pinning). |
dig.getManifest |
A capsule's (storeId:rootHash) embedded normalized public manifest (data-section id 13): the store's complete public file surface as of that commit — { schema_version, entries: [{ path, latest_root, generation_index, sha256_latest, version_count }] }. Served local-first when this node holds the requested capsule. null (never an error) when the module carries no manifest section (an older .dig, or a private store); -32004 when the capsule isn't held locally at all. |
cache.getConfig / cache.setCapBytes / cache.clear |
On-disk cache config: { cap_bytes (floored at 64 MiB), used_bytes, cache_dir, shared } — cache_dir is the effective dir and shared whether it is the canonical dir shared with the DIG Browser (#96). |
cache.listCached / cache.removeCached / cache.fetchAndCache |
Cached-capsule manager (storeId:rootHash). |
rpc.discover |
Method discovery — returns this node's OpenRPC document (the standard OpenRPC discovery method), so a client can introspect every method + error over the wire with no out-of-band knowledge. |
control.* |
CONTROL / admin surface (loopback-only + local-token gated — see below). Manage the node: hosted/pinned stores, cache, §21 sync, config. Read methods above stay open; only control.* requires the token. |
dig.getProof, dig.listCapsules, anything else |
Blind passthrough — relayed verbatim to the upstream, so the node stays a correct transparent proxy for methods it doesn't resolve locally. |
Beside the open read RPC, the node exposes a CONTROL / admin surface so a same-host controller — the DIG Browser "My Node" UI, or any local tool — can MANAGE the node. This is the server side of SYSTEM.md → "the browser is also the dig-node's CONTROLLER UI" (dig-node = serve + be-controllable; the browser = consume + control).
Two layers gate the control surface (the read methods are not gated):
- Loopback-only — the whole server binds
127.0.0.1, so nothing off-machine can reach any method. - Local authorization for the mutating
control.*namespace. A random control token (32 bytes, 64-hex) is generated at first run into the node's config dir at<config_dir>/control-token(next to dig-node'sconfig.json;0600on Unix). A same-host controller reads that file — it can, because it runs as the same user on the same machine — and presents the token on everycontrol.*call, as theX-Dig-Control-Tokenrequest header or aparams._control_tokenfield. A call without a valid token is rejected withUNAUTHORIZED(-32030). Token verification is constant-time; the token is generated at runtime and never committed.
This is the standard local-capability-file pattern (cf. Chia's daemon / Bitcoin's cookie auth): possession of the on-disk token is authorization, so a random web page (which cannot read a local file) is rejected even though it can reach loopback, while the legitimate local controller is allowed.
All control.* methods require the local control token. Params are a JSON object; store is a
capsule reference storeId or storeId:rootHash (each part lowercase 64-hex).
| Method | Params | Result |
|---|---|---|
control.status |
— | { running, service, version, commit, dig_node_version, protocol, uptime_secs, addr, upstream, cache:{cap_bytes,used_bytes,dir,shared}, hosted_store_count, cached_capsule_count, pinned_store_count, sync:{available} } |
control.config.get |
— | { addr, port, upstream, upstream_override, cache_dir, cache_shared, config_path, sync_available } |
control.config.setUpstream |
{ upstream } |
{ upstream, requires_restart:true } — persisted; the running node captured its upstream at startup, so the change takes effect on the next start. A blank upstream clears the override. |
control.cache.get |
— | { cap_bytes, used_bytes, dir, shared } |
control.cache.setCap |
{ cap_bytes } |
{ cap_bytes } (floored at 64 MiB) |
control.cache.clear |
— | { cleared:true } |
control.hostedStores.list |
— | { stores:[ { store_id, pinned, capsule_count, total_bytes, capsules:[{capsule,root,size_bytes,last_used_unix_ms}] } ] } |
control.hostedStores.pin |
{ store } |
{ store_id, root, pinned:true, fetch:{status,…} } — records the pin; pre-fetches the capsule via §21 sync when a concrete root is given. |
control.hostedStores.unpin |
{ store } |
{ store_id, unpinned, evicted_capsules } — removes the pin and evicts the store's cached capsules. |
control.hostedStores.status |
{ store } |
{ store_id, pinned, capsule_count, total_bytes, capsules:[…] } |
control.sync.status |
— | { available, method:"section-21-whole-store-sync", pinned_total, pinned_synced, whole_store_trigger_supported } |
control.sync.trigger |
{ store } (= storeId:rootHash) or { store_id, root } |
{ store_id, root, status:"synced", size_bytes, served_root }, or NOT_SUPPORTED (-32031) if no §21 identity. |
What's proxied vs. owned. Cache + sync operations proxy to the node engine library (dig_node_core)
(cache_*, clear_cache, set_cache_cap_bytes, Node::cache_fetch_and_cache / cache_remove_cached
/ cache_list_cached) — the node never duplicates the cache/read logic. The shell owns only the
small state the crate does not model: the pin registry (pinned_stores) and the upstream
override (upstream_override), persisted under the node's own keys in dig-node's shared
config.json (atomic temp+rename writes that never clobber dig-node's keys).
The DIG Browser "My Node" UI calls these methods over loopback (http://localhost:<port>/), reading
the token from <config_dir>/control-token and sending it as X-Dig-Control-Token. Discover the
whole surface — methods, x-requires-auth flags, the info.x-control-auth token scheme, and the
error catalogue — from GET /openrpc.json or rpc.discover.
A single fetch tells an agent what the node is, where it serves, and what it speaks:
| Endpoint | Returns |
|---|---|
GET /health |
{ status, service:"dig-node", version, commit, mode, addr, upstream, cache:{ dir, cap_bytes, used_bytes, shared }, methods:[…] } — extends the original health body (existing probes keep parsing status/version/mode/upstream/cache). cache.shared (#96) tells whether the cache is the dir shared with the DIG Browser. |
GET /version |
{ service:"dig-node", version, commit, dig_node_version, protocol } — the build fingerprint, to correlate a running node to an exact source revision. |
GET /openrpc.json |
The OpenRPC document for the JSON-RPC surface (methods + error catalogue), generated from the method/error source so it cannot drift. |
GET /.well-known/dig-node.json |
The canonical discovery doc: identity, bound addr, cache (dir/cap_bytes/used_bytes/shared), the method catalogue, the error catalogue, and pointers to the OpenRPC/health/version endpoints. |
CORS reflects chrome-extension:// and the local page origins http://localhost / http://dig.local
/ http://127.0.0.1 / http://127.0.0.2 (with or without a :port), so the extension and any page
served from a canonical local name can call it.
Every subcommand accepts the global --json flag: machine output goes to stdout, human prose
to stderr.
dig-node status --json
# {"ok":true,"action":"status","service":"dig-node","version":"0.5.0","serving":false,"addr":"127.0.0.1:9778",…}
dig-node install --json
# {"ok":true,"action":"install","installed":true,"registered":true,"started":false,"label":"…","scope":"system","addr":"127.0.0.1:9778",…}On failure: { "ok":false, "action":…, "error":{ "code", "exit_code", "message", "hint" } }.
Each failure class maps to a distinct process exit code (not a generic 1), backed by the
typed ExitCode enum in src/cli.rs:
| Exit | Code (UPPER_SNAKE) |
Meaning |
|---|---|---|
| 0 | OK |
Success. |
| 1 | NOT_SERVING |
status: the node is not responding (scriptable "is it up?"). |
| 2 | USAGE |
Bad arguments / usage error. |
| 3 | PERMISSION_DENIED |
install/uninstall need an elevated (Administrator) console (Windows). |
| 4 | SERVICE_FAILED |
A service operation failed (register/start/stop/uninstall). |
| 5 | BIND_FAILED |
run: could not bind the loopback address. |
| 6 | IO_ERROR |
Other I/O error. |
Wire errors carry a stable UPPER_SNAKE symbolic name in error.data.code (+ error.data.origin
distinguishing node-shell errors from upstream/boundary ones), beside the numeric JSON-RPC
code. Catalogued in src/meta.rs (the ErrorCode enum), embedded in /openrpc.json and
/.well-known/dig-node.json:
| JSON-RPC code | Name | Origin | Meaning |
|---|---|---|---|
| -32700 | PARSE_ERROR |
shell | Request body was not valid JSON. |
| -32600 | INVALID_REQUEST |
shell | Not a single JSON-RPC object (batch arrays unsupported). |
| -32601 | METHOD_NOT_FOUND |
boundary | Not resolved locally or by the upstream. |
| -32602 | INVALID_PARAMS |
upstream | Invalid or missing method parameters. |
| -32000 | DISPATCH_FAILED |
shell | The node failed to dispatch the request. |
| -32010 | UPSTREAM_ERROR |
shell | The blind-passthrough relay to the upstream failed. |
| -32030 | UNAUTHORIZED |
shell | A control.* method was called without a valid local control token. |
| -32031 | NOT_SUPPORTED |
shell | A control op the node build can't perform (e.g. §21 sync with no identity). |
| -32032 | CONTROL_ERROR |
shell | A control operation failed at runtime (e.g. could not persist the pin registry). |
The control-plane codes are -32030/-32031/-32032. -32020/-32021/-32022 are RESERVED
for the onion-routing (private-retrieval) contract and are never minted by the control plane.
The node engine does not reimplement the .dig store format — the dig-node-core crate depends
on digstore's store-format library crates (digstore-core/-crypto/-chain/-host/-remote/
-stage, git-pinned to one coherent rev) and owns the read path itself in dig_node_core::handle_rpc.
Every host shell routes each request to that one dispatch. This is the same node the native DIG
Browser runs in-process, so the node and the browser share one read path, one cache, and one cache
contract (see "Shared .dig cache" above). The dependency direction is dig-node-core → store-lib,
never the reverse — digstore is only ever an RPC client of a node.
dig-nodeis a clean Cargo dependency: the guest-wasm build prerequisite that gatesdigstore-cli(itsbuild.rsembeds the compiled guest) does not apply todig-node, which depends only ondigstore-host/-core/-remote/-chain. No special build step is needed.- All TLS is
rustls(no system OpenSSL), so the binary is genuinely self-contained. - The node adds only the shell around that node: the HTTP server, CORS,
/health, request normalisation, a blind-passthrough fallback (dig-nodeanswers onlydig.getContent/dig.getAnchoredRoot/cache.*and returns "method not found" for the rest; the node relays those to the upstream), and the OS-service install/management.
Requires Rust (the repo pins 1.94.1).
cargo build --release # → target/release/dig-node[.exe]
cargo test # routing, config, cache-key, service helpers + an in-process server test
cargo fmt --check
cargo clippy --all-targets -- -D warningsThe first build fetches the dig-node git dependency (and its digstore/wasmtime tree), so it takes
a few minutes; subsequent builds are fast.
build.rs captures the git commit SHA at build time (the /version `commit` field)
src/
main.rs CLI (run / run-service / install / uninstall / start / stop / status) + --json rendering
lib.rs module wiring
config.rs env-driven Config (port/host/upstream) — pure, tested
meta.rs self-describing surface: version/build info, method catalogue, ErrorCode catalogue,
OpenRPC + /.well-known/dig-node.json documents — pure, tested
cli.rs --json envelopes + the differentiated ExitCode table — pure, tested
rpc.rs JSON-RPC routing + request normalisation + catalogued error envelopes — pure, tested
control.rs CONTROL/admin surface (control.*): hosted-stores/cache/sync/config management +
the loopback-only local-token auth gate + pin registry — pure helpers tested
server.rs axum HTTP server: /health, /version, /openrpc.json, /.well-known/dig-node.json, CORS,
POST / → dig_node_core::handle_rpc (+ rpc.discover) + the gated control plane, passthrough fallback
service.rs OS-service install/uninstall/start/stop/status (service-manager) + /health status probe
win_service.rs Windows Service Control Protocol entrypoint (windows-service; Windows only)
USER_JOURNEY.md the dig-node user/operator/agent journey, surfaces, and ecosystem hand-offs
- The wire contract is identical to
rpc.dig.netand to the native browser's in-process node, because all three are (or route to)dig_node_core::handle_rpc. Clients see one consistent local-node API across the extension and the native browser. - The verify/decrypt read-crypto lives in the extension (the same
dig_clientWASM the hub uses); the node serves the ciphertext + proof the extension consumes. See the repoSYSTEM.md.
- Docs: https://docs.dig.net/docs/run-a-node — running a
local
dig-node(install, configure, point a client at it). - Discord: https://discord.gg/dignetwork — questions, help, and the rest of the DIG Network community.
MIT (the binary). The bundled dig-node read path is GPL-2.0-only (digstore). See the DIG Network
organization.