Operating a node
CLI reference
Every uninet-cli subcommand, flag, default and exit code: reading a node, checking its answers against the validators' signatures, and signing with a key of your own.
What uninet-cli is
uninet-cli is a terminal client for one running node. It does three things: it reads the
chain, it checks what a node says against the validators' own signatures, and it acts with a
key of its own. It makes an Ed25519 key, registers the key's identity, and signs transfers,
name registrations, stakes and any other transaction a node prepares.
The binary is a thin layer over a Rust library, uninet-client (crates/client in the
workspace), which any other Rust program can use the same way: the tunnel client, the key
file, the session token, the signing calls and the two verifiers all live there.
The problem the binary exists to solve is that a node's /api/* routes are not on its public
HTTP surface at all. The external listener serves only /unp/* (the three bootstrap paths
this client uses, alongside the relay, delivery, onion-cell and domain-gateway routes) and
answers every other path with 400 UNP_TUNNEL_REQUIRED. Reaching a node means speaking the
tunnel, and this client is the tunnel implementation that ships with the workspace.
How a command reaches the node
Every invocation performs the full bootstrap before it sends its request, and exits afterwards. There is no cached session on disk.
GET /unp/resolve?addr=... node Ed25519 key, node X25519 key, 32-byte nonce,
timestamp, signature over nonce | ts_le | node_id
| X25519 key, where node_id = blake3(node_pubkey)
|
POST /unp/handshake client ephemeral X25519 key + client nonce
--> session id, server X25519 key, server nonce,
signature over the exchange, the client's
key included
|
v
session key = BLAKE3-derive-key(
"uninet unp session v1",
ecdh | client_nonce | server_nonce)
|
v
POST /unp session_id (32B) | AES-256-GCM(TunneledRequest)
AAD "unp-tunnel-v1", nonce = counter | direction
--> tx_counter (u64 LE) | sealed response envelope
Five checks are fail-closed. The bootstrap signature is verified before a session is opened. The node's X25519 key is compared between resolve and handshake, and a change aborts without deriving a key. The handshake's signature must cover this client's own X25519 key, so a session agreed with someone in between is refused. Every response envelope's Ed25519 signature is verified against the key pinned at resolve, bound to this session and request, and an envelope that does not verify is discarded without being printed. A response whose counter is below the expected receive counter is rejected as replayed.
Requests are strictly serial, and &mut self on the tunnel enforces that at the type level:
the request frame carries no counter of its own, so the node decrypts with its own monotonic
receive counter and two requests in flight would desync it.
Global flags
Every flag below is global: it may appear before or after the subcommand.
| Flag | Env | Default | Effect |
|---|---|---|---|
--node <ORIGIN> | UNINET_NODE | http://127.0.0.1:3001 | Node origin. http:// only; an https:// URL is refused, not downgraded, and a URL carrying a path is rejected. A missing port means 80. |
--addr <UNP> | UNINET_ADDR | unp://self.node/ | The UNP address resolved on that node. |
--network <FILE> | UNINET_NETWORK | none | A network file or node config. The node is refused unless its key is one of the validators the file lists. |
--node-key <HEX> | UNINET_NODE_KEY | none | The one node key to expect, for a node outside the validator set. |
--key-file <FILE> | UNINET_KEY_FILE | uninet-key.json | The key file the signing commands use. |
--token <TOKEN> | UNINET_SESSION_TOKEN | none | Sent as Authorization: Bearer ... inside the sealed frame. The signing commands make their own; this is for a token made elsewhere, such as a node operator's. |
--yes | none | off | Sign and send without asking first. What is signed is still printed. |
--no-wait | none | off | Return once the node has taken a transaction, without waiting for its receipt. |
--json | none | off | Print the node's raw JSON body, envelope included, in place of the formatted view. |
--timeout <SECS> | none | 10 | Per-request connect, read and write timeout. A value of 0 is raised to 1. |
--dev-insecure | none | off | Bypass the tunnel and speak plain HTTP to a node's --dev-insecure-api loopback listener. |
-v, --verbose | none | off | Print one transport line to stderr. Through the tunnel: node key prefix, operator wallet prefix, session id prefix, session expiry. Under --dev-insecure: the transport name and the origin. |
UNINET_PASSWORD supplies the key file's password without a prompt, for scripts. The CLI
reads it and sends it nowhere.
--dev-insecure is refused for any host that is not localhost, ::1 or 127.*, because
the listener it targets stamps every caller with the node operator's identity. That listener
exists only when the node was started with both --dev-insecure-api <port> and
UNINET_ALLOW_INSECURE=1 in the same environment. The flag on its own is logged as an error
and ignored (the node keeps running, serving only /unp/*), so an inherited launcher script
cannot switch the bypass on by itself.
Reading
| Command | Arguments | Route | Auth |
|---|---|---|---|
status | none | GET /api/status | none |
health | none | GET /api/health | none |
block <HEIGHT> | height, or the literal latest | GET /api/blocks/{height}, or /api/blocks/latest | none |
receipt <TX_HASH> | 64 hex characters, optional 0x | GET /api/receipt/{tx_hash} | none |
chains | none | GET /api/chains | none |
nodes | none | GET /api/nodes/registry | --token required |
relays | none | GET /api/relays | the node's operator |
raw <METHOD> <PATH> | --body <JSON> | whatever you name | depends on the route |
Two arguments are validated locally. block accepts a u64 or latest and nothing else:
tip, -1 and 1.5 are rejected. receipt trims whitespace, strips a 0x prefix,
lowercases, and requires exactly 64 hex characters; the error names the length you supplied.
nodes reads /api/nodes/registry and not the sibling /api/nodes, because that sibling
fabricates its validator list's latency, hardware and usage figures from the validator's index
in the set. The registry does not: the node's own row is read from its live reservation
ledger, and a peer's row is whatever that peer last sent in a NodeHeartbeat. That gossip
travels over the p2p swarm, so on a node started without --enable-p2p the only populated row
is the node's own: peer validators are seeded from the validator set with every capacity
figure at zero, and stay that way until a heartbeat replaces them. Without a token the
registry answers 401 with {"error":"authentication required"} and the CLI exits 3.
raw prints the HTTP status above the formatted body, because an arbitrary route may answer
4xx with a body worth reading, and a response the node did not label as JSON is printed as
text with its content type and not reported as a parse failure. Under --json the body
prints alone, without the status line. --body must parse as JSON locally first, and a path
that does not start with / is rejected.
Checking a node's answers
Two commands take nothing the node says on trust. Each is given the genesis validators' keys
(--genesis, a node config or network file) and checks everything else on your machine.
| Command | What it proves |
|---|---|
dns-verify <domain> --genesis <FILE> | Who holds a .unp name: the block's commit certificate against the pinned validators, that the certified block is the primary chain's, the name's proof against that block's state root, and the name's term. --owner <HEX> asks whether one wallet holds it; --height and --chain-id narrow the block. |
relays-verify --genesis <FILE> | The relay list: the validator set followed from genesis through every change, each proven against a block the set before it signed; then the count of the relay list and one entry per slot, with the stake behind each relay proven present or absent. It prints whether each relay validates and what it is placed under. --max-age <SECS> (600 by default) bounds how old the block may be. |
Exit 0 means verified. dns-verify exits 3 for a verified no (held by another wallet, or
expired or unclaimed as of a recent block) and 1 when the answer could not be verified.
Keys
These touch only the key file. No node is asked anything.
| Command | What it does |
|---|---|
key new <USERNAME> | Makes an Ed25519 key and wraps it under a new password. Prints the username, the public key and the identity id, which is also the wallet address. |
key show | The key file's username, public key, identity id and creation date. Needs no password. |
key import <FILE> | Takes a recovery file exported by the client application as this tool's key, once its password opens it. |
key export <FILE> | Writes the key file where the client application can import it, still wrapped under its password. |
--force lets new, import and export replace a file that exists.
The key file is the client application's recovery file, byte for byte, so a key made in the terminal opens in the application and the reverse. The password is stretched together with the username (PBKDF2-HMAC-SHA256, 310,000 rounds) and split with HKDF into two values: a wrap key that opens the file and never leaves the machine, and a login proof that registration hands to the node. The labels differ, so the node cannot derive the wrap key from the proof it stores. The username is fixed when the key is made, in the form a node stores it (trimmed, lower case). The file is created for its owner alone: mode 0600 on Unix, an owner-only access list on Windows.
Acting with the key
Each of these opens the key file with its password, signs a session token for the identity (good for ten minutes), and goes through the tunnel as that identity.
| Command | Route | Notes |
|---|---|---|
identity register | POST /api/identity/register | Registers the key's identity under the key file's username. The request is signed by the key. --display-name <NAME> sets the shown name. |
identity login | POST /api/identity/challenge, /api/identity/login | Proves the key by signing a challenge the node issues, and prints what the node holds for the identity. |
wallet | GET /api/wallets | The identity's wallets and their balances. |
transfer <TO> <AMOUNT> | POST /api/transactions/prepare, /submit-signed | <TO> is a wallet address (64 hex) or @username; the amount is in whole units. --chain <HEX> sends on a child chain, --from-wallet <ADDRESS> from a sub-wallet. |
dns register <DOMAIN> | POST /api/dns/register | A .unp name for a 12-month term, paid by the key's wallet. --no-nft skips the name's token. |
dns renew <DOMAIN> | POST /api/dns/renew | Renews a name the key's wallet holds. |
stake <AMOUNT> | POST /api/operators/stake | Locks whole UNE as the identity's operator stake. The first stake must reach 1,000. |
unbond | POST /api/operators/unbond | The stake stops counting at once and can be withdrawn seven days later. |
withdraw | POST /api/operators/withdraw | Takes back an unbonded stake. |
tx sign <PATH> | any endpoint that has the identity sign | --body <JSON> is the request. See below. |
tx sign covers every other transaction a node prepares, in the three shapes the node's
endpoints take:
- One path. Governance, organizations, child chains, operators and names take both steps
on one path:
tx sign /api/operators/unbond. - Staged. A path ending in
/preparehas the node stage the transaction and take the signature on the/submit-signedbeside it. Chain spawns, element deploys and updates, token operations, contract calls and storage plan changes work this way:tx sign /api/tx/spawn-chain/prepare --body '{"name":"shop"}'. --submit <PATH>names another place for the signature. A change to a stored file is staged by the request that makes it and signed on/api/storage/commit.
What a signing command does
A node never holds your key. For every transaction you sign, it builds the transaction and answers with the exact bytes to sign: the transaction as JSON, then the chain id, then your nonce. The CLI:
- opens the key file (a wrong password opens nothing, and nothing is signed or sent);
- asks the node for the transaction;
- prints what the bytes say: the transaction, the chain, the nonce, any price and fee the node quoted, and the signer;
- asks
Sign and send? [y/N], unless--yesis given; - signs the bytes and sends the signature;
- computes the transaction's hash itself and refuses an answer that names a different one;
- waits up to 90 seconds for the receipt, on the chain the signed bytes name. A receipt that
is not
Successexits 3 with the chain's reason.
$ uninet-cli --yes transfer 2222…2222 7
transaction {"Transfer":{"amount":7000000000000,"from":"f4a9…89aa","to":"2222…2222","token_id":"a089…fa41"}}
chain 5a69…463f
nonce 1
signer f4a9…89aa (a65a…822d)
applied a988…f1d4
The first four lines go to stderr and the last to stdout. The amount is in base units, 10^12
to a whole token. Without --yes, the question Send 7 to 2222…2222? [y/N] comes before the
signature.
transfer also holds the node to the request. It reads the recipient, the amount, the chain
and the source wallet back out of the bytes, and signs nothing when one of them is not what
the command asked for. A recipient given as @username is the one thing it cannot check,
since only the node knows which wallet that name is; the address appears in the question.
dns register, dns renew, stake, unbond and withdraw likewise sign only the
transaction they stand for: its kind, and the name or the amount asked for.
Because the bytes come from the node, the CLI signs every kind of transaction without knowing how any of them is encoded. Because the bytes are readable, it does not sign blind.
A node operator's routes
A node's operator-only routes take a token signed with the node's own key. On the node's
host, uninet-node --operator-token prints one, good for ten minutes on that node only:
uninet-cli --token "$(uninet-node --data-dir ./data --operator-token)" relays
Exit codes
| Code | Meaning |
|---|---|
0 | The node answered and the answer was printed. |
1 | The node could not be reached, its identity or a response envelope failed to verify, the key file did not open, or an argument failed local validation. |
2 | Usage error, emitted by clap: unknown subcommand, bad flag. |
3 | The node answered with a refusal: an error field, HTTP 4xx/5xx, or a transaction the chain refused. |
Code 3 is deliberately not 2, so a script can tell "you typed the command wrong" from "the
node said no". That distinction matters more than it looks: get_block and get_receipt both
answer HTTP 200 with an error field, so status alone does not separate success from
failure. The envelope check runs under --json too: an error body prints and still exits 3.
Output
Formatted output is aligned key: value lines. Nothing is invented: a field the node did not
send renders as - and never as a plausible zero, an empty result prints an explicit
empty state, and keys appear in the alphabetical order serde_json's default map ordering
produces. Hex values print in full; the samples below abbreviate them only to fit the page.
$ uninet-cli status
block_time_ms: 2000
chain_id: 4f0c...
child_chains: {}
height: 142
max_block_size: 4194304
max_txs_per_block: 1000
max_validators: 1
multi_node: false
node_id: 9a3b...
state_root: 7c11...
tip_hash: b208...
validator_count: 1
block_time_ms, max_block_size and max_txs_per_block are shown at the defaults the
primary chain is built with: 2 s, 4 MiB and 1000 transactions. A spawned child chain carries
whatever it was created with.
multi_node is true only with real configured validators and a live p2p transport;
max_validators collapses to 1 otherwise, since a child chain cannot exceed the real count.
block renders height, tx_count and timestamp as numbers and hash, parent_hash,
state_root and proposer as hex. /api/blocks/{height} adds view and sequence;
/api/blocks/latest does not carry them.
A receipt is the TransactionReceipt struct serialized directly, which is why tx_hash
arrives as a 32-element byte array and not the hex string the block routes emit: that
handler hex-encodes nothing.
$ uninet-cli receipt 0xdeadbeef...64 hex characters in total...
error: -
events: []
gas_used: 21000
status: Success
tx_hash: [222,173,190,239,...]
tx_index: 0
Two commands render tables. chains lists the hierarchy, counting the validators
and children arrays without printing them; nodes pairs each capacity figure with the
node's declared total as free/total.
$ uninet-cli chains
CHAIN ID NAME TYPE HEIGHT VALIDATORS CHILDREN ACTIVE
----------------- --------- -------- ------ ---------- -------- ------
0123456789abcdef… Nexus Nexus 0 1 1 true
fedcba9876543210… alice-dlt Personal 142 1 0 true
When the node flags its own numbers, the CLI carries the flag through. /api/overview ships
estimated_fields: ["tps","score","latency_ms","assets","uptime"], and each named field is
tagged in the rendered output. Those five are fixed constants or proxies in the handler,
uptime being a binary 1.0 as soon as any time has elapsed since the genesis block and not
a measured availability ratio. The tag only covers what the node itself flagged:
containers_online and containers_total are not on that list, yet both are the chain's
validator count, not a count of running containers.
$ uninet-cli raw GET /api/overview
HTTP 200
assets: 1 (estimated by the node)
block_height: 142
containers_online: 1
…
The real output carries every key the handler sends, alphabetically; the sample is cut short.
What the CLI does not address
- Key custody beyond a password. The key is one file, wrapped under one password. Whoever
has both has the key, and losing either loses it: nothing else recovers it. There is no
hardware wallet, no seed phrase and no second factor.
UNINET_PASSWORDin a script's environment is readable by whatever else can read that environment. - What the node builds. The CLI shows the transaction it signs, decoded from the signed
bytes themselves, and asks. The dedicated commands are held to the request by the CLI
itself.
tx signis not: any transaction may come back there, the check is the reader, and with--yesnobody reads. - First-contact trust. Without a pin, the node's Ed25519 key comes from the same
/unp/resolveresponse that the key signs. An adversary positioned at the bootstrap from the first byte can present a key of its own, and every later verification will succeed against it.--network <file>pins the key to the validators a network file lists, and--node-key <hex>to one key, so a node presenting any other is refused before a request is made through it. Without either, the key protects the rest of the session but does not establish who you reached. - The relay overlay. The CLI implements the direct tunnel only: it builds no circuits,
and if
/unp/resolveever answered with arelay_descriptorit would abort and not fall back to a direct connection. The node you connect to sees your address and every request. - File upload. Chunking, encrypting and staging a file is the client application's. From
the terminal,
tx sign --submit /api/storage/commitsigns a change to a file the node already holds. - Transport metadata. Confidentiality comes from the tunnel, not from TLS: the client
writes HTTP/1.1 straight onto a plain
TcpStream, which is why anhttps://origin is refused and not downgraded. An observer on the path sees a TCP connection to the bootstrap listener, the resolve and handshake exchange in the clear, and then sealed frames whose sizes it can read. - The dev bypass.
--dev-insecureverifies nothing, because the listener it targets provides nothing to verify: no envelope signature, no session, and an injected operator identity for every caller. The CLI refuses the flag outright for any host that is not loopback, so it cannot be aimed across a network.
How it composes
The tunnel this client speaks reuses the primitives described under
private networking: the same X25519 exchange and AES-256-GCM the
onion layers use, with one hop where a circuit has several. Reaching a node is not permission
on it. The gateway turns a verified token into a trusted x-unp-identity header (a client
cannot set that header itself, because the gateway strips the reserved prefix off every
inbound tunnel header), and the routes that guard ownership compare the identity you claim in
the request against the one in that header. The identity behind the key is described under
identity, and what it is then allowed to do under
delegated authority.
The quickstart walks through these commands against a node you start yourself. What the commands report (height, tip, state root, validator count, registry capacity) is the state produced by the node described in running a node. Per-subsystem grades for everything named here are on build status.