UniNet

Start here

Quickstart

Build the node and the CLI from the Rust workspace, start a node, read its chain, then make a key, register its identity, send a transfer and register a name, all from the terminal.

AvailableDistributable client (CLI / SDK)AvailableUNP private addressing and routingAvailablePublic network access for external developers

What this page does

You build two binaries from the workspace, start a node on your own machine, and use uninet-cli against it: first to read what the node computed, then to act with a key of your own. Everything below is reproducible from source alone, and every block of output on this page was produced by running the command above it. Hashes are abbreviated to fit.

You are not connecting to a network someone else operates. No public network is hosted yet; the last section says what that leaves out.

uninet-cli is, before anything else, a UNP tunnel client: a node's /api/* routes are not served over plain HTTP. The external router binds only /unp/* (the three bootstrap routes the CLI uses, /unp/resolve, /unp/handshake and /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.

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

The commands below are written as uninet-node and uninet-cli; both are in ./target/release/.

Start a node

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 encrypted at rest, so a restart restores the same chain and does not re-run genesis.

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 the signature over all of them        │
       │                                               │
       │  POST /unp/handshake  (ephemeral X25519)      │
       │ ────────────────────────────────────────────▶ │
       │ ◀──────── session id, server nonce,           │
       │           signature over the exchange         │
       │  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       │

The GCM nonce is a per-direction counter, so requests go one at a time. A response envelope whose Ed25519 signature does not verify against the key pinned at resolve is discarded and never printed.

--node accepts http:// only. An https:// URL is refused with an explanation: confidentiality comes from the tunnel, not from TLS on the bootstrap listener.

Read the chain

$ uninet-cli status
block_time_ms:      2000
chain_id:           5a69…463f
child_chains:       {}
height:             0
max_block_size:     4194304
max_txs_per_block:  1000
max_validators:     1
multi_node:         false
node_id:            3e83…1c41
state_root:         68f4…b558
tip_applied_at_ms:  -
tip_hash:           5c7b…2ed7
validator_count:    1

A node you just started is at height 0, and it stays there until something writes: in single-node mode the node builds and applies one block per transaction, so blocks are never produced on a timer and never empty after genesis. A field the node did not send prints as -, never as a plausible zero.

chain_id is not per-install: the primary chain is built from the default configuration, so it is blake3("default-chain") on every node started without a --config that overrides it. multi_node is false and max_validators is 1 on a standalone node.

$ uninet-cli block latest
hash:         5c7b…2ed7
height:       0
parent_hash:  0000…0000
proposer:     3e83…1c41
state_root:   7270…e3d8
timestamp:    1790864535
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 one status prints: the node writes further elements into the trie on first boot, after genesis has been committed.

$ 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, however many chains the hierarchy holds. raw reaches anything else: uninet-cli raw GET /api/overview prints a route no subcommand covers, with the HTTP status above the body.

Make a key

$ export UNINET_PASSWORD='correct horse battery staple'
$ uninet-cli key new Alice
key file    uninet-key.json
username    alice
public key  8919…7817
identity    ae68…b400  (also its wallet address)
Keep the file and the password: nothing else recovers this key.

The commands on this page were run with the password in UNINET_PASSWORD, which is how a script supplies it, and the signing ones with --yes. Without them the CLI asks for the password (twice for a new key) and asks before it signs.

No node was asked anything. The key is Ed25519, the identity id is the BLAKE3 hash of the public key, and that id is also the address of the identity's wallet. The username is stored as a node stores it (trimmed, lower case).

The key file holds the key wrapped under the password:

$ cat uninet-key.json
{
  "created_at": "2026-10-01T14:22:18.000Z",
  "encrypted": true,
  "kdf": "pbkdf2-sha256-310000/hkdf-sha256:uninet-wrap-v1",
  "network": "uninet",
  "public_key": "8919…7817",
  "username": "alice",
  "version": 3,
  "wrapped_private_key": "v3:1892…84c3:7a4f…f8cc"
}

This is the client application's recovery file, byte for byte: import it there and you are the same identity, and uninet-cli key import takes a file exported from the application.

Register the identity

$ uninet-cli --yes identity register
registered  alice
identity    ae68…b400
transaction c7c3…7451

The request carries the public key and a signature made with the key, over SHA-256(username ‖ public_key ‖ profile_data_hash). The node verifies it before it writes, because the identity's id is derived from that key and an unverified signature would let anyone pre-register someone else's. The node also receives the wrapped key and a login proof derived from the password, so the client application can sign in with the username and password alone. The node cannot open the wrapped key: the proof it stores and the key that opens the file are two separate HKDF branches of the stretched password.

$ uninet-cli wallet
WALLET     NAME            BALANCE
---------  --------------  -------
ae68…b400  alice's Wallet  0.0

Fund the wallet

The wallet is empty. On a node you run yourself you are also its operator, and the node's own wallet is funded at genesis. A node's operator routes take a token signed with the node's key, which uninet-node --operator-token prints for whoever can read the node's data directory. It is good for ten minutes, on that node only.

$ uninet-cli --token "$(uninet-node --data-dir ./data --operator-token)" \
    raw POST /api/tx/transfer --body '{"to":"ae68…b400","amount":"1200"}'
HTTP 200
block_hash:    ebe2…ad65
block_height:  2
gas_used:      21000
receipt:       {"error":null,"events":[],"gas_used":21000,"status":"Success","tx_hash":[157,69,136,187,…],"tx_index":0}
state_root:    1a9f…3fd2
tx_hash:       9d45…deae

$ uninet-cli wallet
WALLET     NAME            BALANCE
---------  --------------  -------
ae68…b400  alice's Wallet  1200.0

That transfer was signed by the node, with its key. Everything from here is signed by yours.

Send a transfer

$ uninet-cli --yes transfer 2222…2222 7
transaction  {"Transfer":{"amount":7000000000000,"from":"ae68…b400","to":"2222…2222","token_id":"a089…fa41"}}
chain        5a69…463f
nonce        1
signer       ae68…b400 (8919…7817)
applied    daa2…76ca

The node never holds your key. It builds the transaction and answers with the exact bytes to sign: the transaction as JSON, the chain id and your nonce. The CLI prints what those bytes say, checks that the recipient and the amount in them are the ones you typed (amounts are in base units, 10^12 to a whole token), asks unless --yes is given, signs, and sends the signature. It computes the transaction's hash itself and waits for the receipt.

$ uninet-cli receipt daa2…76ca
error:     -
events:    []
gas_used:  21000
status:    Success
tx_hash:   [218,162,5,158,…]
tx_index:  0

A receipt is serialized straight from the node's receipt struct, which is why tx_hash comes back as a byte array and not the hex string you asked with.

A transfer of more than the wallet holds is the chain's refusal, not a transport failure:

$ uninet-cli --yes transfer 2222…2222 999999
…
error: node returned an error for /api/transactions/submit-signed: insufficient balance: need 999,999.0000010905, have 1,192.9999989098

and the command exits 3.

Register a name

$ uninet-cli --yes dns register Hello.UNP
transaction  {"RegisterDomain":{"domain":"hello.unp","nft_id":"1e31…b784"}}
chain        5a69…463f
nonce        2
price_une    50
fee_une      0.0000010727
signer       ae68…b400 (8919…7817)
applied    c980…4749

$ uninet-cli wallet
WALLET     NAME            BALANCE
---------  --------------  ---------------
ae68…b400  alice's Wallet  1142.9999978423

A .unp name is held for twelve months for 50 UNE, in lower case however it was typed. The wallet is down by the 7 sent, the 50 for the name, and two fees.

The other signing commands follow the same steps: stake, unbond and withdraw for an operator's stake, and tx sign <path> for any other transaction a node prepares. They are in the CLI reference.

Exit codes

CodeMeaning
0The node answered and the answer is printed.
1The node could not be reached, its identity or a response failed to verify, the key file did not open, or an argument failed local validation.
2Usage error from clap: unknown subcommand or bad flag.
3The node answered with a refusal: an unknown block, an unknown receipt, a transaction the chain refused, a 4xx.

Three is deliberately not two. Several handlers return HTTP 200 with an error field, so the CLI checks the envelope as well as the status, and --json still exits non-zero on an error body. A wrong password exits 1 and sends nothing:

$ UNINET_PASSWORD=wrong uninet-cli wallet
error: wrong password, or a damaged key file

What this quickstart does not address

  • A network. One node is one validator: it signs its own blocks, and nothing here shows agreement between machines. The repository's devnet and testnet scripts build a multi-node network, and a hosted testnet of five validators runs: a node joins it as in running a node, and the faucet funds a wallet on it.
  • Key custody. The key is one file under one password. Whoever has both has the key, and losing either loses it. There is no hardware wallet, no seed phrase and no second factor.
  • 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. For an out-of-band source of truth, pass --network <file> (the validators a network file lists) or --node-key <hex> (one key): a node presenting another key is refused.
  • What the node builds. The CLI checks a transfer's recipient and amount, and that a dedicated command got the transaction it stands for. With tx sign any transaction may come back, and the reader is the check.
  • 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 node computes every answer and can log every question.
  • Files and sites. Uploading files and deploying a site are done from the client application. The terminal signs a change to a file the node already holds.

How it composes

The transport this page uses is the direct case of the addressing layer described in private networking: the 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 key you made is an identity: an id derived from a key you hold, and a signature the node checks before it writes. What that identity is then permitted to do is authority, carried with a request and not inferred from having reached the node. The name you registered is described under domains, and the assumptions behind all of it are collected in the threat model.