๐ท๏ธ Chapter 3: Build a Name Service
The Builder's Journey -- Chapter 3 of 6
Chapter 1: prove you know a secret. Chapter 2: the secret unlocks your coins. Now: the secret proves you own a name -- and the name resolves to whatever you want.
A name service maps human-readable names to public keys. ENS does this on Ethereum. We will do it on Neptune -- private, quantum-safe, and provable. The name itself is a unique asset. Owning it means knowing the secret behind its auth hash. Chapter 1 again.
๐ก Names as Unique Assets
A coin (TSP-1) is fungible. One coin of the same denomination is identical to another. A name is not. The name "cyber" is distinct from the name "neptune" -- there is exactly one of each, and ownership matters.
Neptune represents unique assets using the TSP-2 pattern. Where a coin leaf has 5 meaningful fields and pads the rest with zeros, a uniq leaf uses all 10:
| Field | Coin (TSP-1) | Name (TSP-2) |
|---|---|---|
| 1 | account id | asset_id = hash of the name string |
| 2 | balance | owner_id |
| 3 | nonce | nonce |
| 4 | auth_hash | auth_hash (Chapter 1 -- the ownership secret) |
| 5 | lock_until | lock_until |
| 6 | 0 | collection_id (the name registry) |
| 7 | 0 | metadata_hash (what the name resolves to) |
| 8 | 0 | royalty_bps |
| 9 | 0 | creator_id |
| 10 | 0 | flags (TRANSFERABLE + UPDATABLE = 5) |
The key insight: metadata_hash is the resolver. It holds the hash of the
public key (or any record) that the name points to. When you "resolve" a name,
you look up its leaf and read the metadata. When you "update" a name, you prove
ownership and swap in a new metadata hash.
๐ The Name Leaf
Every name is a leaf in a Merkle tree. The leaf is the hash of all 10 fields:
fn hash_leaf(
asset_id: Field,
owner_id: Field,
nonce: Field,
auth_hash: Field,
lock_until: Field,
collection_id: Field,
metadata_hash: Field,
royalty_bps: Field,
creator_id: Field,
flags: Field
) -> Digest {
hash(
asset_id,
owner_id,
nonce,
auth_hash,
lock_until,
collection_id,
metadata_hash,
royalty_bps,
creator_id,
flags
)
}
For a name called "cyber" that resolves to public key pk:
asset_id = hash("cyber")[0]-- the content hash of the name stringowner_id-- identifies the current ownernonce-- incremented on every state change (prevents replay)auth_hash = hash(owner_secret)[0]-- the ownership proof (Chapter 1)lock_until = 0-- no time lockcollection_id-- identifies which name registry this belongs tometadata_hash = hash(pk)[0]-- what the name resolves toroyalty_bps = 0-- no royalties for this tutorialcreator_id-- who registered the name originallyflags = 5-- TRANSFERABLE (bit 0) + UPDATABLE (bit 2)
The flags value of 5 means: the name can be transferred to a new owner and its resolver can be updated. It cannot be burned or locked. These flags are immutable after mint.
๐ Authorization
Authorization uses verify_auth from Chapter 2 -- divine a secret, hash it, assert the hash matches.
โก Minting a Name
Registration is minting. You create a new unique asset in the tree.
fn mint() {
let old_root: Digest = pub_read5()
let new_root: Digest = pub_read5()
let old_count: Field = pub_read()
let new_count: Field = pub_read()
let asset_id: Field = pub_read()
let metadata_hash: Field = pub_read()
let collection_id: Field = pub_read()
let config: Digest = pub_read5()
// Config authority check (who can mint names?)
let cfg_mint_auth: Field = divine()
let cfg_other: Field = divine()
let computed_config: Digest = hash(
cfg_other, 0, 0, cfg_mint_auth, 0,
0, 0, 0, 0, 0
)
assert_digest(computed_config, config)
verify_auth(cfg_mint_auth)
// Supply accounting: exactly one new name
let expected_count: Field = old_count + 1
assert_eq(new_count, expected_count)
// New owner's credentials
let owner_id: Field = divine()
let auth_hash: Field = divine()
let creator_id: Field = divine()
let flags: Field = 5
// Create the leaf: nonce = 0, lock_until = 0, royalty = 0
let new_leaf: Digest = hash_leaf(
asset_id,
owner_id,
0,
auth_hash,
0,
collection_id,
metadata_hash,
0,
creator_id,
flags
)
// Verify the leaf was inserted into the tree
let new_leaf_expected: Digest = divine5()
assert_digest(new_leaf, new_leaf_expected)
reveal NameMint {
asset_id: asset_id,
collection_id: collection_id,
metadata_hash: metadata_hash,
}
reveal SupplyChange {
old_count: old_count,
new_count: new_count,
}
}
The verifier sees: the old tree root, the new tree root, the name's asset ID, and the resolver hash. The verifier does not see who owns the name or what secret protects it. The proof guarantees the leaf was correctly formed and inserted.
๐ Resolving a Name
Resolution is read-only. It does not require a ZK program.
Given a name's Merkle proof, anyone can verify that name X resolves to key Y:
- Look up the leaf for
asset_id = hash("cyber")[0] - Read
metadata_hashfrom the leaf - Verify the Merkle proof against the current root
No proof generation needed. The Merkle tree is publicly committed (the root is on-chain). The leaf data is available to anyone with the authentication path. Resolution is cheap -- one Merkle verification, no proving cost.
But changing what the name resolves to requires a proof. That is the next section.
โก Updating the Resolver
The owner wants "cyber" to point to a new public key. This requires proving ownership -- then swapping the metadata hash.
fn update() {
let old_root: Digest = pub_read5()
let new_root: Digest = pub_read5()
let asset_id: Field = pub_read()
let new_metadata_hash: Field = pub_read()
let config: Digest = pub_read5()
// Verify config
let cfg_admin: Field = divine()
let computed_config: Digest = hash(
cfg_admin, 0, 0, 0, 0,
0, 0, 0, 0, 0
)
assert_digest(computed_config, config)
// Current leaf (secret -- the prover knows the full leaf)
let leaf_asset_id: Field = divine()
let leaf_owner_id: Field = divine()
let leaf_nonce: Field = divine()
let leaf_auth_hash: Field = divine()
let leaf_lock_until: Field = divine()
let leaf_collection_id: Field = divine()
let leaf_metadata_hash: Field = divine()
let leaf_royalty_bps: Field = divine()
let leaf_creator_id: Field = divine()
let leaf_flags: Field = divine()
// Verify old leaf exists in the tree
let old_leaf: Digest = hash_leaf(
leaf_asset_id,
leaf_owner_id,
leaf_nonce,
leaf_auth_hash,
leaf_lock_until,
leaf_collection_id,
leaf_metadata_hash,
leaf_royalty_bps,
leaf_creator_id,
leaf_flags
)
let old_leaf_expected: Digest = divine5()
assert_digest(old_leaf, old_leaf_expected)
// Must be the right name
assert_eq(leaf_asset_id, asset_id)
// Prove ownership -- Chapter 1 again
verify_auth(leaf_auth_hash)
// New leaf: same everything except metadata_hash and nonce
let new_nonce: Field = leaf_nonce + 1
let new_leaf: Digest = hash_leaf(
leaf_asset_id,
leaf_owner_id,
new_nonce,
leaf_auth_hash,
leaf_lock_until,
leaf_collection_id,
new_metadata_hash,
leaf_royalty_bps,
leaf_creator_id,
leaf_flags
)
let new_leaf_expected: Digest = divine5()
assert_digest(new_leaf, new_leaf_expected)
reveal ResolverUpdate {
asset_id: leaf_asset_id,
old_metadata: leaf_metadata_hash,
new_metadata: new_metadata_hash,
}
}
The old leaf and the new leaf differ in exactly two fields: metadata_hash
(the resolver record) and nonce (incremented to prevent replay). Everything
else -- owner, flags, collection -- stays the same.
The verifier sees the name, the old root, the new root, and the new resolver hash. The verifier does not see the owner, the secret, or the old resolver. The proof guarantees the owner authorized the change.
โก Transferring a Name
Transfer is Chapter 2's pay pattern applied to a unique asset. Instead of moving a balance from one account to another, you move ownership of a name from one key to another.
fn pay() {
let old_root: Digest = pub_read5()
let new_root: Digest = pub_read5()
let asset_id: Field = pub_read()
let current_time: Field = pub_read()
let config: Digest = pub_read5()
// Verify config
let cfg_admin: Field = divine()
let cfg_pay_auth: Field = divine()
let computed_config: Digest = hash(
cfg_admin, cfg_pay_auth, 0, 0, 0,
0, 0, 0, 0, 0
)
assert_digest(computed_config, config)
// Current leaf (secret)
let leaf_asset_id: Field = divine()
let leaf_owner_id: Field = divine()
let leaf_nonce: Field = divine()
let leaf_auth_hash: Field = divine()
let leaf_lock_until: Field = divine()
let leaf_collection_id: Field = divine()
let leaf_metadata_hash: Field = divine()
let leaf_royalty_bps: Field = divine()
let leaf_creator_id: Field = divine()
let leaf_flags: Field = divine()
// Verify old leaf
let old_leaf: Digest = hash_leaf(
leaf_asset_id,
leaf_owner_id,
leaf_nonce,
leaf_auth_hash,
leaf_lock_until,
leaf_collection_id,
leaf_metadata_hash,
leaf_royalty_bps,
leaf_creator_id,
leaf_flags
)
let old_leaf_expected: Digest = divine5()
assert_digest(old_leaf, old_leaf_expected)
// Must be the right name
assert_eq(leaf_asset_id, asset_id)
// Prove ownership -- Chapter 1 again
verify_auth(leaf_auth_hash)
// Config-level pay auth (0 = owner only, else dual auth)
if cfg_pay_auth == 0 {
} else {
verify_auth(cfg_pay_auth)
}
// Time-lock check: current_time >= lock_until
let lock_headroom: Field = sub(current_time, leaf_lock_until)
let _: U32 = as_u32(lock_headroom)
// New owner
let new_owner_id: Field = divine()
let new_auth_hash: Field = divine()
// New leaf: owner changes, nonce increments, everything else stays
let new_nonce: Field = leaf_nonce + 1
let new_leaf: Digest = hash_leaf(
leaf_asset_id,
new_owner_id,
new_nonce,
new_auth_hash,
leaf_lock_until,
leaf_collection_id,
leaf_metadata_hash,
leaf_royalty_bps,
leaf_creator_id,
leaf_flags
)
let new_leaf_expected: Digest = divine5()
assert_digest(new_leaf, new_leaf_expected)
// Nullifier prevents replay (sealed -- verifier sees commitment only)
seal Nullifier { asset_id: leaf_asset_id, nonce: leaf_nonce }
reveal NameTransfer {
asset_id: leaf_asset_id,
from_owner: leaf_owner_id,
to_owner: new_owner_id,
}
}
Compare this to Chapter 2's coin pay. The structure is identical:
- Read the old leaf, verify it exists in the tree
- Prove ownership with
verify_auth(Chapter 1) - Check time-lock constraints
- Build the new leaf with the new owner
- Emit a nullifier to prevent double-spend
The difference: a coin pay changes the balance. A name pay changes the owner. No balance field exists here -- there is nothing to split or merge. The entire asset moves as one indivisible unit.
๐ The Full Program
The complete program combines the functions above with an entry point that dispatches by opcode:
fn main() {
let op: Field = pub_read()
if op == 0 { pay() }
else if op == 2 { update() }
else if op == 3 { mint() }
}
โก Build It
trident build name.tri --target triton -o name.tasm
trident build name.tri --costs
trident build name.tri --hotspots
The transfer operation is the most expensive -- it verifies two leaves and checks dual authorization.
๐งฉ The Connection
The name you just built will be auctioned in Chapter 5 using a Vickrey auction with sealed bids. Nobody sees anyone else's bid until the auction closes. The bid commitment is a hash. The bid price is divine. Chapter 1 again.
The coin from Chapter 2 will be the payment currency. The liquidity strategy
from Chapter 4 will make that coin tradeable. And in Chapter 6, the DAO will
govern who can register names -- replacing the single cfg_mint_auth with
coin-weighted voting.
It all connects. Every chapter is the same primitive -- divine, hash, assert -- applied to a different problem.
โ What You Learned
- Unique assets (uniqs / TSP-2) use 10-field leaves where every field matters. Coins use 5 fields and pad the rest with zeros.
- A name is a uniq where
asset_idis the content hash of the name string andmetadata_hashis the resolver record. Resolution is a Merkle lookup -- no ZK program needed. - Flags (TRANSFERABLE + UPDATABLE = 5) are set at mint time and never change. They define what operations the asset supports at the protocol level.
๐ฎ Next
Chapter 4: Build a Liquidity Strategy -- Your coin has value. Your name has identity. Now you will make the coin tradeable -- a constant-product AMM where the pricing invariant is proven, not trusted.