Start here
Read-only quickstart
Build the node and the CLI from the Rust workspace, open a UNP tunnel to a node you started, and read status, blocks, receipts, chains and the node registry.
What the CLI is
uninet-cli is a terminal client for one running node. It is, before anything else, a UNP tunnel client: a node's /api/* routes are not served over plain HTTP. The external router in bin/uninet-node/src/api/mod.rs binds only /unp/* — the three bootstrap routes this CLI uses (/unp/resolve, /unp/handshake, /unp), plus the relay, onion and domain-gateway routes — and its fallback answers every other path with 400 UNP_TUNNEL_REQUIRED. Reaching an API route therefore means completing a bootstrap, a key exchange and a sealed request — which is what the CLI does on every invocation.
The problem this quickstart addresses is narrower than the usual one. You are not connecting to a network someone else operates; you are building two binaries from the workspace, starting a node on your own machine, and reading what that node computed. Everything below is reproducible from source alone.
What the CLI can ask for
The subcommand enum in bin/uninet-cli/src/main.rs has exactly seven variants. Six issue a fixed GET; the seventh, raw, takes a method and a path from you.
| Command | Route it calls | Notes |
| --- | --- | --- |
| status | GET /api/status | Height, tip, state root, validator count, block parameters. |
| health | GET /api/health | Liveness of the inner router, through the tunnel. |
| block <height\|latest> | GET /api/blocks/{height} or /api/blocks/latest | latest has its own handler and returns two fewer fields. |
| receipt <tx_hash> | GET /api/receipt/{tx_hash} | 64 hex characters; a leading 0x is stripped locally. |
| chains | GET /api/chains | Rendered as a table. |
| nodes | GET /api/nodes/registry | The registry, not /api/nodes — see below. |
| raw <method> <path> | whatever you name, --body <json> optional | Prints the HTTP status as well as the body. |
Global flags, all with env fallbacks where noted: --node (UNINET_NODE, default http://127.0.0.1:3001), --addr (UNINET_ADDR, default unp://self.node/), --token (UNINET_SESSION_TOKEN), --json, --timeout (seconds, default 10), --dev-insecure, and -v / --verbose.
There is no keygen, no identity create, no sign, no login and no tx send. That is not an omission in this page — those variants do not exist in the enum, and the crate's only cryptographic imports are ed25519::verify, an ephemeral X25519 keypair and ECDH for the handshake, a CSPRNG for the client nonce, BLAKE3 derive-key and AES-256-GCM. Nothing in bin/uninet-cli/ signs anything. Why that follows from the registration design, rather than from an unfinished command table, is set out below.
Build
The workspace builds both binaries. The node's package is uninet-node-bin and its binary is uninet-node; the CLI's package and binary are both uninet-cli.
cargo build --release -p uninet-node-bin -p uninet-cli
Start a node
./target/release/uninet-node --port 3001 --data-dir ./data
--port defaults to 3001, --data-dir to ./data, and --log-level to info. A node started this way runs single-node: transactions apply locally with no gossip, because --enable-p2p is off by default. The chain state trie is disk-backed under {data_dir}/state, block history under {data_dir}/blocks, both behind an EncryptedBackend over a DiskBackend, so a restart restores the same chain rather than re-running genesis. The remaining flags are --config (a JSON file naming validators, genesis and bootstrap peers), --p2p-port, --dev-insecure-api and --dev-cohost-child.
What happens before your first command
uninet-cli uninet-node :3001
│ │
│ GET /unp/resolve?addr=unp://self.node/ │
│ ────────────────────────────────────────────▶ │
│ ◀──────── Ed25519 pubkey, X25519 pubkey, │
│ nonce, timestamp, signature │
│ verify sig over nonce ‖ ts_le ‖ node_id │
│ │
│ POST /unp/handshake (ephemeral X25519) │
│ ────────────────────────────────────────────▶ │
│ ◀──────── session id, server nonce │
│ key = BLAKE3-derive-key( │
│ "uninet unp session v1", │
│ ecdh ‖ client_nonce ‖ server_nonce) │
│ │
│ POST /unp session_id ‖ AES-256-GCM(request)│
│ ────────────────────────────────────────────▶ │
│ ◀──────── counter ‖ sealed signed envelope │
│ verify envelope against the pinned key │
node_id is blake3(node_pubkey). The GCM nonce is a per-direction counter, so requests go one at a time — enforced at the type level by &mut self on UnpTunnel::fetch. A response envelope whose Ed25519 signature does not verify against the key pinned at resolve is discarded rather than printed.
--node accepts http:// only. An https:// URL is refused with an explanation rather than silently upgraded: confidentiality comes from the tunnel, not from TLS on the bootstrap listener.
Reading the chain
./target/release/uninet-cli status
Field names come from the handler in bin/uninet-node/src/api/chain.rs; the renderer prints an object as aligned key: value lines, sorted by key, with a null value shown as an em dash rather than a plausible zero. Hashes print at their full 64 characters and are abbreviated here.
block_time_ms: 2000
chain_id: 5a69…1463
child_chains: {}
height: 0
max_block_size: 4194304
max_txs_per_block: 1000
max_validators: 1
multi_node: false
node_id: 8c07…b5de
state_root: d411…9a30
tip_hash: 7b26…04ef
validator_count: 1
A node you just started is at height 0, and it stays there until something writes: in single-node mode AppState::submit_transaction builds and applies one block per transaction, so blocks are never produced on a timer and never empty after genesis.
chain_id is not per-install: the primary chain is built from ChainConfiguration::default(), so it is blake3("default-chain") — 5a69aa3761cd33cf… — on every node started without a --config that overrides it. multi_node is false and max_validators is 1 on a standalone node, because both are computed from whether real config validators and a p2p transport are present. Hosted child chains appear as ids under child_chains, not as extra rows.
./target/release/uninet-cli block latest
hash: 7b26…04ef
height: 0
parent_hash: 0000…0000
proposer: 8c07…b5de
state_root: 4e02…bb71
timestamp: 1757520411
tx_count: 0
That is the genesis block: height 0, an all-zero parent hash, no transactions, and this node as proposer because it is the only entry in the validator set. Its state_root is the genesis root, which is not the state_root /api/status prints — the node writes further elements into the trie on first boot, after genesis has been committed.
Ask for a numbered height instead — block 0 — and the same block comes back with two additional fields, view and sequence. The two routes are separate handlers with different projections of the same header; this page reports the difference rather than flattening it.
./target/release/uninet-cli chains
CHAIN ID NAME TYPE HEIGHT VALIDATORS CHILDREN ACTIVE
----------------- ------- -------- ------ ---------- -------- ------
cc1fb8752157c833… Nexus Nexus 0 1 3 true
5a69aa3761cd33cf… default Personal 0 1 0 true
/api/chains always returns exactly two entries — the Nexus root and this node's own chain — regardless of how many chains the hierarchy holds. A fresh chain's configuration defaults to ChainType::Personal with the name default. The Nexus row reports block_height: 0 unconditionally, and its three children on a fresh node are the company chain, the Chain of Tools and the DNS chain, all added to the hierarchy in AppState's constructor. This node's own chain is not one of them — it is registered as a child of the company chain — even though /api/chains reports its parent as the Nexus id. Anything else this node hosts appears as an id inside a children array, not as a row of its own.
A receipt is serialized straight from the TransactionReceipt struct, which derives Serialize over a Hash([u8; 32]) — so tx_hash comes back as a 32-element byte array, not the hex string the block routes emit:
./target/release/uninet-cli receipt <64-hex transaction hash> --json
The fields are tx_hash, status ("Success" or "Failure"), gas_used, events, error and tx_index. A hash of the wrong length or with a non-hex character is rejected locally, before the round trip. On a node you have only read from there is nothing to look up: the handler answers receipt not found in a HTTP 200 body and the CLI exits 3.
nodes reads /api/nodes/registry rather than the sibling /api/nodes, and the CLI's own doc comment says why. The sibling route derives its validator list's latency, uptime, hardware and usage figures arithmetically from each validator's index in the set — 8.0 + idx * 4.0 ms, 16 or 8 cores — so none of it is measured. The registry reports this node's own reservation ledger for its self entry and each peer's last heartbeat for the rest, and marks a peer offline after 30 seconds without one. The registry route requires an authenticated caller, so over the tunnel it answers 401 authentication required unless you pass --token. Its columns are node id, self, online, container count, free/total CPU, RAM and disk, and last seen.
raw reaches anything else. /api/overview is worth trying, because it returns an estimated_fields array naming its own unmeasured numbers, and the renderer tags each one:
./target/release/uninet-cli raw GET /api/overview
HTTP 200
assets: 1 (estimated by the node)
block_height: 0
containers_online: 1
…
tps: 0 (estimated by the node)
Exit codes
| Code | Meaning |
| --- | --- |
| 0 | The node answered and the answer is printed. |
| 1 | The node could not be reached, its identity or a response failed to verify, or an argument failed local validation. |
| 2 | Usage error from clap: unknown subcommand or bad flag. |
| 3 | The node answered with a refusal — an unknown block, an unknown receipt, a 4xx. |
Three is deliberately not two. Several handlers return HTTP 200 with an error field — get_block and get_receipt both do — so the CLI checks the envelope as well as the status, and --json still exits non-zero on an error body.
Why there is no write path here
Registering an identity is a client-side cryptographic operation, and the node's register handler is written to reject anything less. The client runs PBKDF2 over the password to get a master secret, salted with salt_auth = SHA-256("uninet-auth-salt-v1|" + lowercase(trim(username))), then splits master with two domain-separated HKDF labels: wrapKey under info "uninet-wrap-v1", which never leaves the device and unwraps the Ed25519 private key, and auth_hash under info "uninet-auth-v1", which is the only half transmitted. Because the labels differ, the value the node receives cannot open the private-key blob the node also stores. The node salts that auth_hash with 16 random bytes and stretches it with Argon2id — m = 19456 KiB, t = 2, p = 1, 32-byte tag — before it reaches chain state, and stores the result as v2:<hex salt>:<hex tag>.
The request body carries the public key, the auth_hash, client-encrypted blobs for the private key, the profile and the biometric, the profile hash, and a signature. Before writing anything, the handler recomputes SHA-256(username ‖ public_key ‖ profile_data_hash) over the raw submitted strings and verifies the Ed25519 signature against the submitted public key, because the identity's element id is derived from that key and an unverified signature would let anyone pre-register someone else's public key.
Authenticated reads are the same shape. A session token is header.claims.signature, signed by the user's own Ed25519 key over SHA-256(headerB64 "." claimsB64), with claims.sub required to equal hex(blake3(pub)) so a caller cannot sign with one key and assert another identity. A second accepted form exists for password login, where the node signs the token with its own key after checking the credential — but that too is minted by a node on behalf of a client that completed the derivation above.
Every one of those steps needs a private key and a signing operation. The CLI holds neither: it generates an ephemeral X25519 keypair for the tunnel handshake and otherwise only verifies signatures. The component that performs the registration derivation is the client application: the handler's own doc comment names RegisterForm.tsx from that app as the implementation its digest check mirrors. A --token minted there can be handed to the CLI; the CLI cannot produce one.
What this quickstart does not address
- Bootstrap trust. The resolve response carries the node's public key and a signature made with it. Verifying that proves the responder holds the private key for the key it just sent; it does not prove that key belongs to the node you meant to reach. The CLI pins that key for the life of the invocation and re-resolves on the next one; there is no known-hosts file and no out-of-band source of truth.
- Network position. The CLI opens a direct tunnel to the origin in
--node. It is one hop, sealed but not onion-routed, and anyone watching your link sees which node you queried. The CLI refuses to proceed if resolve returns a relay descriptor instead of a node identity. - Addresses served elsewhere. A node resolves
unp://self.node/, its own public key, and domains registered on its own DNS chain. A direct address belonging to another node returnsUNP_ADDR_REMOTE, and a username returns the same, because resolving either needs the cross-node overlay. - The
--dev-insecurepath. It bypasses the tunnel entirely and talks to the loopback listener a node opens only with--dev-insecure-api <port>andUNINET_ALLOW_INSECURE=1. That listener injects the node-operator identity for every caller, which is why the CLI refuses to point it at a non-loopback host. Nothing on that path is signed or verified. Use it for tests, not for judging behaviour. - The operator of the node you query. Reading through a sealed tunnel constrains what the network sees, not what the machine sees. The node computes the answer and can log the question.
How it composes
The transport this page uses is the direct case of the addressing layer described in private networking — same address form, without the relay path. The node on the other end is the process described in running a node, which is what you started above.
The write path the CLI does not implement rests on identity: a key you generate, an element id derived from it, and a signature the node checks before it writes. What that identity is then permitted to do is authority, carried with a request rather than inferred from having reached the node — which is why a tunnel that opened successfully still returns 401 on the registry route. The assumptions behind all of it are collected in the threat model.
What is not finished
The CLI is read-only — it has no key generation, no identity creation and no transaction signing — so any walkthrough involving a write path is written against the client application rather than the terminal.
The confidential resolver is an interface with no backend, and the node starts without one, so a request cannot be resolved to a service across nodes. Read the addressing and routing pages as design you can test locally, not as a deployable private-hosting path.
The repository ships no public bootstrap or seed address, no committed genesis/chainspec file and no hosted endpoint; peer discovery is a Kademlia behaviour that is constructed but never driven, and the only route onto a network is to be pre-listed as a validator in a config file every participant already holds.