UniNet

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.

AvailableDistributable client (CLI / SDK)AvailablePublic network access for external developers

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.

FlagEnvDefaultEffect
--node <ORIGIN>UNINET_NODEhttp://127.0.0.1:3001Node 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_ADDRunp://self.node/The UNP address resolved on that node.
--network <FILE>UNINET_NETWORKnoneA 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_KEYnoneThe one node key to expect, for a node outside the validator set.
--key-file <FILE>UNINET_KEY_FILEuninet-key.jsonThe key file the signing commands use.
--token <TOKEN>UNINET_SESSION_TOKENnoneSent 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.
--yesnoneoffSign and send without asking first. What is signed is still printed.
--no-waitnoneoffReturn once the node has taken a transaction, without waiting for its receipt.
--jsonnoneoffPrint the node's raw JSON body, envelope included, in place of the formatted view.
--timeout <SECS>none10Per-request connect, read and write timeout. A value of 0 is raised to 1.
--dev-insecurenoneoffBypass the tunnel and speak plain HTTP to a node's --dev-insecure-api loopback listener.
-v, --verbosenoneoffPrint 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

CommandArgumentsRouteAuth
statusnoneGET /api/statusnone
healthnoneGET /api/healthnone
block <HEIGHT>height, or the literal latestGET /api/blocks/{height}, or /api/blocks/latestnone
receipt <TX_HASH>64 hex characters, optional 0xGET /api/receipt/{tx_hash}none
chainsnoneGET /api/chainsnone
nodesnoneGET /api/nodes/registry--token required
relaysnoneGET /api/relaysthe node's operator
raw <METHOD> <PATH>--body <JSON>whatever you namedepends 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.

CommandWhat 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.

CommandWhat 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 showThe 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.

CommandRouteNotes
identity registerPOST /api/identity/registerRegisters 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 loginPOST /api/identity/challenge, /api/identity/loginProves the key by signing a challenge the node issues, and prints what the node holds for the identity.
walletGET /api/walletsThe 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/registerA .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/renewRenews a name the key's wallet holds.
stake <AMOUNT>POST /api/operators/stakeLocks whole UNE as the identity's operator stake. The first stake must reach 1,000.
unbondPOST /api/operators/unbondThe stake stops counting at once and can be withdrawn seven days later.
withdrawPOST /api/operators/withdrawTakes 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 /prepare has the node stage the transaction and take the signature on the /submit-signed beside 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:

  1. opens the key file (a wrong password opens nothing, and nothing is signed or sent);
  2. asks the node for the transaction;
  3. prints what the bytes say: the transaction, the chain, the nonce, any price and fee the node quoted, and the signer;
  4. asks Sign and send? [y/N], unless --yes is given;
  5. signs the bytes and sends the signature;
  6. computes the transaction's hash itself and refuses an answer that names a different one;
  7. waits up to 90 seconds for the receipt, on the chain the signed bytes name. A receipt that is not Success exits 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

CodeMeaning
0The node answered and the answer was printed.
1The 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.
2Usage error, emitted by clap: unknown subcommand, bad flag.
3The 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_PASSWORD in 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 sign is not: any transaction may come back there, the check is the reader, and with --yes nobody reads.
  • First-contact trust. Without a pin, the node's Ed25519 key comes from the same /unp/resolve response 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/resolve ever answered with a relay_descriptor it 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/commit signs 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 an https:// 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-insecure verifies 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.