Network
Domains and .unp naming
A .unp name is bought with UNE, owned by the wallet that paid, optionally minted as an NFT, and resolved to a content chain id by a registry the node keeps as a flat hash over every entry.
What the domain registry is
The domain registry is a map from a normalized human-readable name to a ChainId, together
with the timestamps and the owner that name was sold under. hello.unp is a key; the value
it carries is the identifier of the chain whose content a visitor should be shown. Nothing in
the record names a machine, a port or an address — resolution ends at a chain id, and what
serves that chain id is decided separately.
The problem it addresses is that a conventional name is a lease from a registry that can end
the lease. A .unp name is bought once, recorded against the wallet that paid, and — when the
caller asks for it — minted as a token that wallet holds. Renting and owning are different
relationships, and this registry implements the second one.
The DNS chain, as it actually runs
The node builds a chain hierarchy rooted at a Nexus chain and registers the DNS chain as a
direct child of it, alongside the company chain and the Chain of Tools. The hosting node set
is three NodeId values derived at boot as BLAKE3 over dns-chain-node-0, -1 and -2,
and DnsChain::new refuses any set smaller than DNS_CHAIN_MIN_NODES = 3.
Read that constructor carefully, because the shape is not what "chain" usually implies. A
DnsChain is a struct holding a DomainRegistry, a content store keyed by chain id, and a
domain-to-element-id map. It has no blocks, no transaction log and no consensus of its own.
The node holds one behind a Mutex and persists it to a dns_domains.json sidecar, not into
the state trie that carries balances and elements.
Nexus chain
|-- UniNet Technologies Ltd (AoDlt)
|-- Chain of Tools (Realm, 12 derived node ids)
|-- DNS chain (Dns, 3 derived node ids)
\-- Personal chain (UserDlt, via the company chain)
DNS chain = DomainRegistry + content store + nft element map
(one Mutex in the node process; snapshot on disk)
The three node ids are recorded membership, not three processes reaching agreement. The registry's authority is the process that holds the mutex.
Registration, and who pays for what
POST /api/dns/register requires an authenticated caller. The identity is resolved to its
primary wallet, and that wallet — never the operator's — is the payer and the recorded owner.
Price is a pure function of the base name, the part before the first dot: one to three
characters costs 100 UNE, four to five costs 50, six or more costs 10. The handler computes
it unconditionally; the client-supplied mint_nft flag decides only whether a token is
minted on top, never whether payment happens.
The fee arrangement is worth reading in full because it is unusual. The chain executor
charges whoever signed a transaction, and the NFT mint is signed by the node key, so the
operator would otherwise front the fee for every user's registration. Instead the handler
builds the mint transaction first, prices a probe copy of it carrying a 64-byte 0xff
signature and a u64::MAX nonce to obtain an upper bound on the fee, reserves price plus that
bound from the buyer, and once the block applies credits the operator the real fee and refunds
the buyer the surplus. The operator's net movement on a user's registration is zero, and a
regression test asserts exactly that across an unauthenticated call, a caller with no balance,
a funded caller and a duplicate.
Availability check, debit and registry insert happen inside one critical section that takes
the chain lock before the DNS lock — the same order the node's persistence path uses, so the
two cannot deadlock. Holding both across the sequence is what makes it impossible for a name
to be consumed by a registration whose payment failed, or taken by a racing caller between
the check and the insert. A caller with insufficient UNE receives 402 and the name stays
free.
Names are normalized by trimming and lowercasing, and rejected when empty or longer than 253
characters. The registry does not require a .unp suffix; the suffix is a convention of its
callers, not a rule of the data structure. The content chain id is BLAKE3 over the name as
the client typed it, before normalization.
The request carries a ttl_secs field that defaults to zero, and zero is stored as
expires_at: 0, which the registry reads as "never expires". A registration is permanent
unless the caller asks for a term.
Resolution
parse_unp_url accepts three forms, and resolve_target_chain_id applies a different rule to
each.
| | unp://hello.unp | unp://@alice | unp:// + 64 hex |
| --- | --- | --- | --- |
| Path taken | registry lookup | username check, then registry lookup | parsed directly |
| Expiry applied | yes | yes | not applicable |
| Refused when | the name is unregistered or lapsed | alice is not a registered username, or alice.unp is unregistered | never, in practice |
The third column is narrower than it looks. parse_unp_url only classifies a host as the
direct form when it is exactly 64 ASCII hex characters, so by the time the resolver calls
ChainId::from_hex the string cannot fail to parse; anything else falls through to the domain
branch and is looked up as a name. There is no address-shaped input that reaches the direct
form and is then rejected.
The handle form derives the canonical domain alice.unp by string concatenation at resolution
time. The derivation is a lookup convention: nothing reserves alice.unp when the username
alice is registered, and nothing registers it on that identity's behalf. The handler first
requires the username to exist in chain state and refuses otherwise, which stops an
unregistered handle from resolving to a name someone else took.
Serving a visit
/unp/:domain and /unp/:domain/*rest resolve the name, then dispatch on the content type
stored against the chain id. application/x-unp-container-ref reads the container's opaque id
out of the stored marker, looks its host port up in the node's port-forward table, and forwards
the request over a loopback TCP connection to that port, threading the sub-path and query
through so a multi-route app is reachable. application/x-unp-site-ref serves a materialized
folder tree from disk by path, with year-long caching for _next/static, an index fallback for
extensionless routes and a real 404 for a missing asset. Anything else is served as a single
blob, straight out of the content store with a five-minute cache header.
A container response is first scrubbed of the node's internal IP, its host port and the
container's opaque id, in header names and in surviving header values alike. The container and
site paths then end at one shared finalizer, which strips internal header names again and signs
the response with the node key under X-Unp-Signature. The single-blob path does not go through
that finalizer, so a blob response carries no signature headers. Request sub-paths are refused
outright when they contain .., %2e, %2f, a NUL, a backslash or a drive colon, and the
joined path is re-checked against the site root before any disk read.
Every endpoint
The /api/* routes live on the inner router, which is not bound to the public listener — they
are reachable through the UNP tunnel dispatcher at POST /unp, or on loopback via a
development bind that requires both the --dev-insecure-api flag and UNINET_ALLOW_INSECURE=1
in the same shell. The /unp/:domain routes are on the external router.
| Method | Path | Returns |
| --- | --- | --- |
| POST | /api/dns/register | domain, chain_id, registered_at, owner, price_une, fee_une, total_une, status; plus element_id and tx_hash when an NFT was minted. 401 unauthenticated, 402 unfunded, 409 taken. |
| GET | /api/dns/lookup?domain= | domain, chain_id, registered_at, expires_at, owner, registry_root, hex-encoded where they are byte values. |
| GET | /api/dns/resolve?url= | The stored bytes for the resolved chain id, under their stored content type. 400 on an unparseable URL, 404 when the name resolves but nothing is stored. |
| GET | /api/dns/available?domain= | domain, available, price_une. |
| GET | /api/dns/domains | The registry entries whose owner is the authenticated caller's wallet. 401 without a session. |
| POST | /api/dns/bind | Binds a name to a file_id, a folder_id (resolving index.html inside it), a folder_id with serve_mode: "site" (materializing the tree), or a container_id. Authenticates the caller against the identity_id it claims, and checks that identity owns the container; it checks only that the domain exists. Refuses an end-to-end-encrypted asset, which the node holds only as ciphertext. |
| POST | /api/dns/update-content | Replaces the stored content from an inline string or a stored file. 403 unless the caller's wallet is the registry owner — or the recorded owner is the node itself, in which case the check passes for any caller. |
| any | /unp/:domain | The hosted site or app at its root. |
| any | /unp/:domain/ | The same, as an explicit route so a page's relative requests resolve under it. |
| any | /unp/:domain/*rest | A sub-path of the hosted site or app, threaded through to the file or container. |
Ownership, and the NFT
A registration records an owner built from the payer's wallet bytes. With mint_nft, the
handler also deploys an element with immutability: Full, accessibility: Public, an
asset_class of domain, and traits carrying domain_binding, domain_name and the chain
id, then credits a balance of one directly to the payer's wallet. The element id is BLAKE3
over the string domain-nft-{name}-{registration timestamp}, so the same name registered a
second later yields a different token id.
Ownership is therefore recorded in two independent places. The registry entry's owner field is
what /api/dns/domains filters on and what update-content authorizes against. The NFT
balance is chain state, moved by the ordinary Transfer transaction like any other token.
What .unp changes, compared with DNS
| | ICANN DNS | .unp registry |
| --- | --- | --- |
| Who can take a name away | the registrar, the registry operator, or a court order reaching either | whoever controls the node process holding the registry and its JSON snapshot |
| What the intermediary sees | a registrar holds the registrant's legal identity, contact details and payment instrument | a name, a chain id, two timestamps and a 32-byte wallet-derived owner |
| What a name costs | a recurring fee that must keep being paid to retain the name | 100, 50 or 10 UNE once, by base-name length, plus the mint fee |
| What expiry means | the name returns to the available pool and anyone may buy it | expires_at stops /api/dns/resolve answering; the entry keeps its key, and the /unp gateway keeps serving it |
| What transfer costs | a registrar-mediated process with its own fee and authorization codes | a token transfer's transaction fee, which moves the NFT and leaves the registry entry unchanged |
| What you hold afterwards | a contract with a registrar | a token in a wallet, and a registry row that does not reference that token |
The last row is the one to sit with. The node does keep a domain-to-element-id map, but it is written only so the snapshot can be saved and restored — no authorization path reads it, and no code compares who holds the token against the owner recorded in the row.
What this design does not give you
- The inclusion proof proves nothing.
DomainRegistry::lookupreturns aprooffield built atcrates/net/src/registry/domain.rs:107, where the whole line readslet proof = vec![leaf_hash]; // Simplified — in production this is a full path. A vector containing only the leaf is not a Merkle path; checking it against the root means recomputing the root, which means holding every other entry. There is no tree to walk —recompute_rootsorts the entries by name, concatenates each leaf and hashes the whole buffer once. Do not design anything that treats a domain lookup as independently verifiable, and do not offer one to a third party as evidence of anything. - The root does not commit to ownership or expiry. A leaf is computed over the domain name and the chain id only. The owner and both timestamps sit outside the digest, so even a reader holding every entry cannot check who owns a name against the root.
- The registry owner has no mutator, and neither has the chain id. The owner is set at
registration, and the only operation
DomainRegistryoffers that could rewrite it isrestore_entry, the path that replays the JSON snapshot at boot.update— which changes a registered name's chain id, owner-checked — is never called from the node binary. Moving the domain NFT changes a balance in chain state and leaves the registry row pointing at the original wallet, and that row is whatupdate-contentauthorizes against. - Expiry is a resolution-time predicate, not a lifecycle event.
lookup_activetreats a lapsed entry as absent, and/api/dns/resolveis the only live caller.register,available,lookup,bindand the/unp/:domaingateway all call the plainlookup, which does not consider the clock — so a lapsed name keeps serving its content over the public gateway.cleanup_expired, which would free the key, is never called outside the registry's own tests, so a lapsed entry also keeps blocking a new registration of the same name. binddoes not check who owns the domain.update-contentcompares the caller's wallet against the registry entry's owner;binddoes not. It authenticates the caller against theidentity_idin the request and verifies that identity owns the container being bound, then looks the domain up only to obtain its chain id. Any authenticated identity can therefore repoint any registered name at content it controls. The source marks the missing on-chain ownership check in a comment next to that lookup.- Handle derivation is not an ownership check. Resolution requires that the username
exist, then resolves
alice.unpto whoever registered it. The registry owner of the derived name is never compared against the identity that owns the username. - The registry is not in the state trie. It is a process-local structure snapshotted to JSON. Nothing about a registration is carried by the Merkle-backed state that carries balances and elements, and a restore replays the sidecar rather than verifying a root.
How it composes
The wallet that pays for a name is the wallet an identity resolves to, so the owner recorded in the registry is an identity's wallet address rather than an account held at a registrar. The price, the mint fee and the NFT balance all move through the settlement mechanism described in assets and payments.
A name resolves to a chain id, never to a host. On this node that chain id is turned into a reachable workload locally — a materialized folder on disk, or a container on a loopback port. Carrying it to a workload on a different node is the job of private networking, and that path is not reachable here: the UNP gateway decides a registered domain is served by this node and never consults the relay resolver, and the relay branch it would consult only accepts the domain form, which never reaches it.
Content updates are authorized against the registry owner, and both updates and bindings
require an authenticated identity — never network position, which is the rule
authority states generally. Holding a route to hello.unp confers
nothing over it. Read the bind caveat above before treating that as the whole story.
What is not finished
The inclusion proof the registry returns contains only the leaf itself rather than a Merkle path, so a third party cannot verify domain ownership from it. Do not design around verifiable domain proofs.
The allocation and dependency model are implemented, but consensus bootstrap for a new chain is not, and the node binary never invokes the spawner. Chains you can use are the ones created at genesis.