Network
Domains and .unp naming
A .unp name is bought for a 12-month term with a transaction its buyer signs, recorded in chain state against the wallet that paid, renewable by that wallet, provable against a certified block, and resolved to a content chain id by a registry each node rebuilds from the chain.
What the domain registry is
The domain registry is a map from a normalized, human-readable name to a ChainId: the identifier of the chain whose content a visitor to that name should be shown. hello.unp is a key. 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 paid for by the term, held by the wallet that paid for as long as that wallet renews it, and provable against the chain by anyone, without asking the node that answered.
Two layers: the claim and the serving index
The authority on who holds a name is the primary chain's state. A successful registration writes the wallet that paid, and the end of the name's term, under the key domainclaim:<name>, on every validator, from the same block. That claim is what an ownership proof proves.
Each node also keeps a serving index: the name, its chain id, its owner, the time it was granted and the end of its term, which the index takes from the chain, held with the content the node serves under the name and persisted beside the node's data. The index follows the chain. A name enters it only when this node applies the block that paid for it, the same block every other node applies, and at boot the node drops every entry the chain does not back. No API call writes it directly.
primary chain state each node
─────────────────── ─────────
domainclaim:hello.unp → wallet, end serving index: hello.unp → chain id, owner, term
▲ content store: what this node serves under it
│ applied block
RegisterDomain / RenewDomain (signed by the holder)
The node still describes this as a DNS chain, a child of the Nexus chain with three derived member ids, but it has no blocks or consensus of its own. The registrations are primary-chain transactions, and the index and content store are this node's.
Registration, and who pays
POST /api/dns/register requires an authenticated caller. The payer is the caller's own wallet, and the payment is a RegisterDomain transaction the caller signs, in two requests:
- With
"prepare": true, the node builds the transaction and returnspayload_hex(the bytes to sign), anonce, the price and the most the fee can be (fee_une). Nothing is charged. - With
"signature"and"nonce", the node submits it. It holds the transaction it built between the two requests, so the signature is consent to what the node built, never to a transaction of the caller's devising.
A keyed caller who sends neither gets 428 with those instructions, rather than being charged by a key that is not theirs. The node's operator identity has no key of its own; it is the node, so the node signs with its own key and its own wallet pays, in one request.
Before anything is staged, the node refuses a name that is already taken, in its index or claimed on chain, or whose registration is already waiting in its mempool (409), and a caller who cannot pay the price plus the largest fee the transaction could carry (402).
Every validator executes the transaction the same way, and checks everything before it writes anything. The name must be given in canonical form and must not be held by anyone, in its term or its grace period; when an NFT is asked for, its id must never have existed. Then the executor burns the price from the signer (debited, credited to no one), records the signer's wallet and the end of the term as the name's claim, and mints the NFT when one was asked for. Two nodes can no longer each take money for the same name: the second claim to reach a block is refused and costs only its fee. The mempool drops a registration its signer could never pay before it is ordered into a block.
The name is granted when that block is applied, on every node, never when the payment was merely submitted. On a single-node chain the block is applied before the response returns (status: "registered"); on a multi-node chain the response says "pending", and the name, the right to bind it and any inline content sent with the registration all wait for the block.
Every name costs 50 UNE, whatever its length, and the quote comes from the same constant the executor charges. Names are canonicalized by trimming and ASCII lowercasing, and refused when empty, longer than 253 bytes, not ASCII (an internationalized name registers in its xn-- form), or containing whitespace or control characters. The registry does not require a .unp suffix; the suffix is a convention of its callers. The content chain id is BLAKE3 over the canonical name.
Terms and renewal
A registration holds a name for 12 months of block time: the claim records when the term ends, one term after the registering block's timestamp. When the term ends the name stops resolving everywhere, and nothing new can be bound to it. For the 30 days after that, the grace period, the name is still its holder's: only they can renew it, and nobody else can register it. After the grace period anyone may register it, and the new holder's claim replaces the old one; every node then drops the old holder's content along with the old entry. Because a block's time sets the term, a leader cannot backdate one to cut a registration short: every validator refuses a block dated before its parent, and votes for a newly proposed block only when it is dated within five minutes of its own clock.
POST /api/dns/renew renews a name for another 12 months, for 50 UNE, burned, in the same two signed steps as a registration, and only the holder may. The new term runs on from the end of the old one, not from the renewal, so renewing early loses nothing and renewing in the grace period keeps the date. A renewal waits for its block like a registration, and the answer carries the new expires_at once it lands. A ttl_secs field that older clients send is ignored: the chain sets the term.
Proving who holds a name
GET /api/dns/proof?domain=hello.unp answers with a Merkle proof of the name's claim key against the state_root in a block header: the tip's by default, or block height's when that parameter is given. uninet_net::registry::verify_domain_owner checks it and needs no node state, so anyone who trusts that root can confirm which wallet holds the name, and until when, without trusting the node that served the proof. It takes the time to judge the term at, and answers expired once the term has ended.
A root is trustworthy because of the block's commit certificate: a quorum of the chain's validators signed the block, header included, and each of them checked that root against its own state before voting. On a single-validator chain the root is only as trustworthy as that one validator. The node serves the certificate too: GET /api/blocks/:height/signed returns the exact bytes the validators signed and their signatures. uninet-cli dns-verify <name> --genesis <config> checks everything on the caller's machine: the certificate against the validator keys the config lists, that the certified block is the primary chain's and the one the proof is anchored at, the proof for that name against its state root, and the name's term. The one thing left to trust is the validator set in that config. A held name checks out from any certified block, since a claim whose term runs cannot have changed hands; an ended term counts only from a recent one, since a renewal since could have changed it. A name nobody holds cannot be checked at all, since only a held name's path is served (below). Names are held on the primary chain only, and a child chain refuses them.
A header commits to the state its block executes against, which is the state after the previous block. A registration or a renewal therefore becomes provable from the first block after the one that carried it, and until that block exists the answer says pending.
Only a held name's path is served. A path that proves a name absent passes beside the names that are present, so served for any name asked, one probe after another would list every claimed name and its payer's wallet. A held name's path still carries the hashes of the sibling slots it passes, which shows which of them are occupied and gives a hash that a guessed name and wallet could be checked against, but it spells out no name other than the one requested and the names it begins with.
Resolution
parse_unp_url accepts three forms, and the resolver applies a different rule to each.
unp://hello.unp | unp://@alice | unp:// + 64 hex | |
|---|---|---|---|
| Path taken | index lookup | username check, then index lookup of alice.unp | parsed directly |
| Refused when | the name is not in this node's index | alice is not a registered username, or alice.unp is not registered | 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 parses it, the string cannot fail to parse; anything else falls through to the domain branch and is looked up as a name.
The handle form derives the canonical domain alice.unp 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 resolver 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
A node serves a hosted name in two places on its public listener. The path gateway serves every site under /unp/<domain>/ on the node's own origin. The subdomain gateway serves each site at the root of an origin of its own, <label>.<gateway domain>, where the label encodes the name: with the default gateway domain localhost, chain-hello.unp is served at chain--hello-unp.localhost (each - becomes -- and each . becomes -). A site on its own origin cannot read another site's cookies or storage, and the root-absolute URLs a framework such as Next.js emits land on the site rather than on the node. A name past its term is served nowhere: both gateways and /unp/resolve judge it by the term, on the node's clock.
Either way the visit dispatches on the content type stored against the chain id. A container reference is streamed to the app's port on the node, with the sub-path and query passed through byte for byte. A site reference serves a folder materialized on disk, by path: an extensionless route with no file falls back to index.html, a missing asset is a real 404, and _next/static files are cached for a year. Anything else is a single blob from the content store, with a five-minute cache header.
A site's responses are signed with the node's key over status, headers and body (X-Unp-Signature, scope full); an app's streamed responses are signed over status and headers only (scope head), because the body is still being produced when the headers go out. Both have the node's internal header names stripped first. A single blob is not signed. Sub-paths containing .. (decoded or %2e-encoded), a NUL, a backslash or a drive colon are refused, and the joined path is checked against the site's root before any file is read.
Binding content to a name
POST /api/dns/bind points a name at a stored file, a folder (serving its index.html), a folder served as a whole static site (serve_mode: "site"), or a container. Only the name's owner may: an identity whose id is the wallet that paid, or, for a name the node itself registered, the operator. An end-to-end-encrypted file is refused, because the node holds only its ciphertext. The answer carries two changes for the owner to sign: commit records the binding on the bound file or folder, which is what the owner's storage views show, and route_commit records it in the owner's route-binding record, the on-chain answer to where the name is served.
A folder served as a site can be bound hidden (hidden: true). The route-binding record then names a service key and up to three relays the site is met at, and no node: the node that took the bind holds the service secret, keeps no public content for the name, and is reached only through those relays. The answer carries hidden: {service_key, meeting} beside the two commits.
POST /api/dns/update-content replaces what the name serves with an inline string or a stored file, owner only, and rewrites the route-binding record the same way. When the owner has published a site element for the name, a single document must be the one that element attests, and a site deployed as immutable serves nothing else at all. Once a name's term has ended, both answer 409 until it is renewed.
Across validators
With cross-node routing on (the node's unp_routing setting, on a real multi-validator network), a visit to a name that another validator serves is fetched over a circuit of one to three relays: as many as the tier the name's owner sets in its route binding, or a stronger one the visitor asks for. The node the visitor reached resolves the name to its host from the service records validators sign and gossip, authorizes that claim against its own copy of the index and chain state, and carries the request sealed end to end to the host. The client app's desktop shell resolves the name itself instead, from the same records and proofs of the name's claim and route binding, checked against the validators' keys, so the visitor's node never learns the name. A user's name routes to another node only while the owner's route-binding record names that node and a folder served as a static site, so a rebind stops routing to the old host at the next resolve. A user's app is served only by the node that runs it. A site bound hidden has no host to resolve: a visit to it goes over a circuit to one of the relays its binding names, where the host's own circuit meets it, and GET /unp/resolve answers UNP_HIDDEN_SITE for it on every node. Apart from a hidden site's host, none of this is confidential against the validator set; private networking sets out what each party learns.
Every endpoint
The /api/* routes are reachable through the UNP tunnel at POST /unp, or on loopback through a development bind that needs both the --dev-insecure-api flag and UNINET_ALLOW_INSECURE=1. The /unp/:domain routes are on the public listener.
| Method | Path | Returns |
|---|---|---|
POST | /api/dns/register | With prepare: status: "awaiting_signature", payload_hex, nonce, price_une, fee_une, plus element_id when an NFT will be minted. With signature: domain, chain_id, owner, price_une, fee_une, total_une, tx_hash and status (registered or pending), and expires_at and renewable_until once granted. 401 unauthenticated, 402 unfunded, 409 taken or already queued, 428 unsigned. |
POST | /api/dns/renew | The same two steps. With prepare: payload_hex, nonce, price_une, fee_une, the current expires_at and renewed_until. With signature: status (renewed or pending), total_une, tx_hash, and the new expires_at once renewed. 403 not the holder, 404 nobody holds the name, 409 past the grace period or already queued, 402 unfunded. |
GET | /api/dns/lookup?domain= | domain, chain_id, registered_at, expires_at (the end of the term), renewable_until (the end of the grace period), active, owner, paid_by (the wallet the chain records, null until the registration is final) and site (the element the owner published the site under, or null). What this node says, not a proof. |
GET | /api/dns/proof?domain=&height= | domain, owner, expires_at, renewable_until, height, block_hash, state_root, pending and proof, or path_withheld saying why no proof is given. |
GET | /api/blocks/:height/signed | bare_hex (the exact bytes the block's commit certificate covers), signatures, and hash, state_root and commit_digest for convenience. |
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 nothing resolves or nothing is stored. |
GET | /api/dns/available?domain= | domain, available, price_une, term_secs, fee_une. A name someone still holds, in its term or its grace period, is not available. |
GET | /api/dns/domains | The names the authenticated caller holds, in their term or grace period, each with expires_at, renewable_until and active. 401 without a session. |
POST | /api/dns/bind | Binds a name to a file, a folder, a folder served as a site, or a container, owner only, with commit and route_commit for the owner to sign. |
POST | /api/dns/update-content | Replaces the served content, owner only, with route_commit when the owner has a route-binding record. |
any | /unp/:domain, /unp/:domain/, /unp/:domain/*rest | The hosted site or app, at its root or a sub-path. |
Ownership, and the NFT
A registration records one owner: the wallet that paid. The claim in chain state, the owner in every node's index, the right to bind the name and the right to change what it serves all name that wallet.
With mint_nft, the registration also mints an NFT to the payer: an element with immutability: Full, accessibility: Public, asset_class: domain and traits carrying domain_binding, domain_name and the content chain id, held with a balance of one. Its id is BLAKE3 over domain-nft-{name}-{time}, the time being the node's clock when the registration was prepared.
Names are not transferable, and that is a decision for now rather than a missing feature: a name stays with the wallet that paid for it, and so does its NFT, which is soul-bound to that wallet, so every transfer of it is refused. When a lapsed name is registered by someone else, the old NFT stays with its first holder as a record of that term.
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 | no transaction revokes a claim; a node can refuse to serve a name's content, but cannot take the name, which passes on only when its holder stops renewing it |
| What the intermediary sees | a registrar holds the registrant's legal identity, contact details and payment instrument | a name, a chain id, a time and the payer's wallet |
| What a name costs | a recurring fee that must keep being paid to retain the name | 50 UNE for each 12-month term, burned, plus the transaction fee |
| What expiry means | the name returns to the available pool and anyone may buy it | the name stops resolving; its holder has 30 days to renew it, and then anyone may register it |
| What transfer costs | a registrar-mediated process with its own fee and authorization codes | not offered, by decision: a name stays with the wallet that paid for it, and its NFT is soul-bound |
| What you hold afterwards | a contract with a registrar | a claim in chain state with an end of term, provable against a certified block, and optionally a soul-bound token recording it |
What this design does not give you
- A proof is as good as the validator set you pin.
dns-verifychecks the certificate against the validators the config you give it lists; pin the wrong set and the check proves nothing. - A registration or a renewal becomes provable only at the next block. Until a block after the one that carried it exists, the proof endpoint says
pending. - Serving judges the term by the node's clock, the chain by block time. The two agree to within the clock skew the chain allows, so a name can stop resolving on a node a few seconds before or after its term ends on chain.
- What a name serves is each node's own. The claim is replicated to every validator; the content served under the name is the copy on the node that holds it. Another node reaches it only by routing, and only for a static site.
- Handle derivation is not an ownership check. Resolution requires that the username exist, then resolves
alice.unpto whoever registered it. The owner ofalice.unpis never compared with the identity that owns the username. - Visit signatures are unverified. Nothing reads
X-Unp-Signaturetoday, and a single-blob response carries none.
How it composes
The wallet that pays for a name is the wallet an identity resolves to, so the owner recorded for a name is an identity's wallet rather than an account held at a registrar. The price, the 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 the node that holds the content, that chain id becomes a reachable workload locally: a folder materialized on disk, or an app on a port of its own. Carrying a visit to a static site on a different validator is the job of private networking, over a circuit of relays, and it is authorized against the owner's route-binding record rather than taken on the host's word.
Binding and content updates are authorized against the name's owner and require an authenticated identity, never network position, which is the rule authority states generally. Holding a route to hello.unp confers nothing over it.