UniNet

Core model

Elements and tokens

An element is one addressable record carrying its own mutability and behaviour rules, and a token is an element whose declared traits the chain executor enforces on every transfer, merge and dissolution.

AvailableElement and token modelAvailableSmart-contract executionAvailableWallet and signed transfers

What an element is

An element is the single kind of thing UniNet stores. A token, a contract, a domain, a chain's hosting record and an identity are all elements: one 32-byte identifier, one opaque payload of bytes, and a set of declarations made at creation that say how the record may change and how it behaves when it is transferred, split, merged or destroyed.

The declarations travel with the element and are evaluated by the block executor rather than by each contract author. That matters because the executor is the one point every route into the chain converges on - the HTTP API, the mempool, an arriving peer's block, and block replay during sync. A check that lives only in an HTTP handler is not a rule; a transaction reaches the executor by several routes that never pass through one.

Who can hold one

Ownership, as the chain actually records it, is a 32-byte wallet address. The deploy path writes the signer's address under elemowner: and every later gate compares those 32 bytes exactly. That comparison can stand in for "the signer is this identity" because a chain wallet address and a digital identity id are both BLAKE3 of the same Ed25519 public key - WalletAddress::from_public_key and DigitalIdentityId::from_hash(public_key) produce the same 32 bytes.

uninet-primitives also declares a richer vocabulary for this - CreatorId (human, robot profile, or another element) and EntityId (identity, profile, element, chain) - together with an Element trait exposing creator(), accessibility() and management(). Nothing constructs those types and nothing implements that trait: outside the file that defines them they appear only in the crate's re-export list. Treat them as a declared shape, not as a second ownership model running alongside the wallet one.

What a deploy actually records

A DeployElement transaction carries the element id, the payload, an immutability class, an accessibility type, a management type, a TokenTraits declaration, and an optional initial supply. The executor writes the payload under the element id and the enforceable declarations into side keys beside it, never inside the payload, because API readers consume the payload verbatim.

   element id ──┬─▶ payload       opaque bytes, returned as written
                ├─▶ elemimmut:    immutability class
                ├─▶ elemtraits:   declared token traits (written only if any)
                ├─▶ elemowner:    the wallet that deployed it
                ├─▶ elemchain:    the chain it was deployed on
                ├─▶ elemparent:   the authority element it is a child of
                ├─▶ elembound:    the wallet it is soul-bound to
                └─▶ elemtomb:     dissolved; this id is spent forever

   (wallet, element id) ─▶ balance

Balance is keyed by wallet and element, so every element carries a balance of the same shape. One asymmetry survives that: the executor holds a single native_token element id, and every transaction's fee is debited in that token regardless of which token the transaction moves.

Supply is minted in the executor and nowhere else. The checked addition runs before any write, and the element id is one-shot - element_exists plus the dissolution tombstone - so an element's supply can be created exactly once in its lifetime. The field carries a long comment in transaction.rs explaining that supply used to be credited by the HTTP handler after the block had already been applied, so only the node serving that request performed the write and its state root diverged from every peer's.

Immutability is enforced on UpdateElement. ImmutabilityClass::Full rejects every field, Hybrid consults a per-field mask, Centralized allows everything. The mask fails closed: FieldMask::is_mutable treats a path absent from the mask as immutable. A FieldMask::validate exists that rejects duplicate or blank paths, but FieldMask::new does not call it and neither does any production path - it is an opt-in check whose only callers today are its own unit tests.

Two things fail open for elements the executor did not see deployed: an element with no recorded immutability class defaults to Centralized, and an element with no recorded home chain passes the LocalChain scope check. An element with no recorded owner gives nobody an owner's rights (since v0.1.9): the node seeds the native token and the genesis records with no owner, and the native token also refuses every owner's operation outright.

The node's own deploy API narrows what a user can actually declare. It maps the request to Full or Centralized only, and hard-codes the accessibility and management fields. Hybrid masks are built by the identity route, which deploys an identity as an element with username, display_name, verified, password_hash and the wrapped private-key blob mutable, and identity_id, public_key and created_at immutable.

Token traits

A token is an element with one or more traits declared. Each trait is an Option field on TokenTraits; an element that declares none is a plain element and no trait rule applies to it. These are the six that exist, and what the executor does with each. All six are reachable through the node's deploy API.

TraitDeclaresEnforced as
metamorphichow the token splits: a fractionalization spec with min and max partsunder the NFT rules (below), only a unique NFT carries it, as its split rule alone, and a fungible token never does; before them, MetamorphicOperation checked every source's MergeCondition and held a split inside the declared bounds
profile_bindingwhen the token binds to a walletBindTrigger::OnCreation binds at deploy; OnCondition binds the moment its condition element exists, evaluated on read; a bound token is refused by the transfer path. Under the NFT rules an NFT takes none: its holder binds its own copy
dissolutionthe conditions under which the token may be destroyedDissolveToken requires the owner and one satisfied condition; declaring the trait with an empty condition list makes the token permanently indestructible, and the executor says so in the refusal
authoritya ChildManagementPolicy over child elementsunder FullControl, or a Conditional whose condition elements all exist, only the parent's owner may update, dissolve, merge or fractionalize a child
network_propertythe chain that manages the tokenthe token cannot be transferred by a user at all, and may only be modified on its managing chain
executivean execution trigger, a scope, and whether approval is requiredExecuteContract refuses an element scoped to another chain, refuses a manual call to an OnReceive or OnTransfer token, and refuses WithinAo outright because the chain executor carries no AO context and cannot verify that scope

Two properties hold across all of them. The transfer gate is a single function, transfer_block_reason, consulted both from validate_transaction, so a bad transfer never enters a block from the mempool, and from the transfer branch of execute_tx_logic, so one arriving inside a peer's block is still refused. And value is conserved arithmetically rather than by convention: a merge sums the source balances with a checked add before debiting anything and credits exactly that sum; a split divides in raw units and gives the remainder to the first fraction, so integer division cannot quietly destroy dust; a dissolve burns the holder's balance, deletes the element, and tombstones the id so the same token can never be redeployed to recreate its supply.

Before the NFT rules, derived ids were computed, never supplied: a merge target was BLAKE3 over a version-tagged seed followed by the sorted source ids, and a fraction id BLAKE3 over the source id and the fraction index. Under them, the id of whatever an operation makes travels in its transaction and is refused if anything took it first, so nothing can be squatted in advance.

NFTs

From a block time that the release shipping them sets (v0.1.9 sets it to 2026-10-11 14:00 UTC, a day after the devnet's upgrade to it), the chain keeps NFTs by rules of their own (crates/chain/src/nft.rs, executor/nft_ops.rs). Every block before that time replays under the older rules, and none may carry the new transactions: the pool refuses them, a leader leaves them out, and a validator votes for no block that holds one, since a validator still on an older release could not read it.

KindMade byMoves
Unique NFTDeployNft with one copywhole
EditionDeployNft with 2 to 10⁹ copieswhole copies
CollectionDeployCollection (up to 10⁶ items)holds no balance
Collection itemMintCollectionItems (up to 100 per transaction)whole
Fraction tokensplitting a unique NFTwhole units
Merged tokenrunning a merge recipean NFT whole, a fungible output in any amount
  • Splitting. Only a unique NFT whose traits carry a split rule splits, by its holder, into one fraction token of N units; the NFT is locked out of every wallet until someone holds all N and reassembles it. A collection may let each item carry its own rule. A fungible token never splits: it is divisible already.
  • Merging. Different NFTs merge only through a recipe: an on-chain record naming two or more exact NFTs (as many as one merge of them fits in a block: 1,992 at the default gas limit) and the token definition that comes out (a unique NFT, an edition, or an amount of a new fungible token). Each NFT says at its creation whether it may be an input (never, only in its creator's recipes, or in anyone's), and its creator can fix the set of recipes naming it for good. A reversible recipe locks its inputs inside the output until all of the output is gathered and unmerged; an irreversible one retires them. An operation whose work grows with the NFTs or items it names (a collection mint, a merge, an unmerge, publishing or withdrawing a recipe) pays the gas price for that work, on top of its bytes.
  • Binding. A holder binds its own copy, or a copy is offered to a wallet, at mint or later, and binds when that wallet accepts it; offers do not expire and may be declined. Whether the creator may revoke a bound copy, by burning it or by taking it back, is set when the NFT is made.
  • Older operations. MetamorphicOperation and BindToken are refused for every token, and DeployElement makes no NFT. An NFT made before the rules keeps its older rules.
  • Locks. A locked NFT is in no wallet: it is not moved, burned, dissolved, updated, given a picture, bound, offered, split or merged.

Amounts

Amount is a u128 of base units with twelve decimal places fixed in the type: one whole token is 10¹² base units. There is no floating point in the type, and every balance on the chain is in those same units. A deployed element's JSON payload may advertise a decimals value, but nothing in the arithmetic path reads it - the wallet API publishes it as display precision only.

Behaviour
Arithmeticchecked_add, checked_sub, checked_mul, checked_div only; overflow and underflow return None rather than wrapping
Parsingthousands separators in the whole part are validated by grouping, so 1,234 parses and 1,2,3,4 is rejected rather than silently stripped; a second decimal point is rejected; more than twelve fractional digits is rejected as too many decimal places rather than truncated
Displaythe whole part is grouped with commas, fractional trailing zeros are trimmed, and a whole value renders with an explicit .0

The module registry, and what calls it

uninet-primitives carries a module registry: what is installed on one chain, and a governed path to changing it - propose against the installed baseline, submit a conformance review, approve with a proof, stage, activate once the rollback window has elapsed, retire after retention. It is worth reading for one design decision.

The approval for a swap is split into two types that are deliberately not interchangeable. GovernanceApprovalEnvelope is the wire type: public fields, Deserialize and BorshDeserialize, and it asserts nothing. GovernanceProof has private fields, no public constructor, and derives Serialize and BorshSerialize only - so a proof can be written into a log and those bytes can never round-trip back into one. The single way to obtain the type is GovernanceApprovalEnvelope::verify, which enforces, in order: the policy is a usable authority; now is inside the validity window; the envelope carries no more signatures than the signer set has members; every key is in the authoritative set; no key is counted twice; every signature verifies against the canonical message; and the count of valid, distinct, in-set signatures meets the threshold. A failure inside the crypto provider aborts as CryptoFailed rather than being skipped or counted as a pass.

The message every approver signs is a Borsh encoding behind a domain-separation tag, binding the chain id, the signer-set epoch, the proposal id, the expiry, the threshold, the byte-sorted signer set and the full migration plan. The policy half of that - chain, epoch, signer set, threshold - is supplied by the verifier from its own state and is never read from the envelope, so an envelope cannot talk a verifier into accepting a committee of the sender's choosing. The crate backs the typestate with compile_fail doctests asserting that a struct literal and a serde_json::from_str both fail to compile, plus a companion doctest that must compile.

Which authority may approve depends on the chain type, and ModuleRegistry::new refuses to be constructed with a mismatch.

Chain typeAuthorityWhat it means
Nexus, Realm, SystemLibrary, InteroperabilityNetworkWideat least min_approvers distinct keys
PersonalChainOwnerset equality against one key, not a floor: one extra approver means somebody else had a say, and the swap is refused
OrganizationAoGovernancethe AO's own model and quorum; a single-authority model may not claim to need more than one approver
Child, ComputingPoolnoneconstruction is refused with UnspecifiedAuthority rather than inventing a rule nobody agreed to

ModuleRegistry is Serialize but not Deserialize, for the same reason as the proof: a stored blob would otherwise reconstitute a Personal chain governed by a network-wide vote. Persist the record, rebuild through new.

Nothing outside crates/primitives calls any of this. ModuleRegistry, GovernanceProof and GovernanceApprovalEnvelope appear in no other crate, no binary and no integration test. The chain's own ModuleSwapProposal transaction does not reach the registry: its executor branch writes a JSON record holding the new module id and the byte length of the migration plan, and stops there.

What the element model does not address

  • The three declarations are not equally enforced. The deploy path persists the immutability class and the token traits. It does not persist the AccessibilityType or the ManagementType carried in the same transaction, so no executor rule consults them. AccessCondition::TokenGated and ManagerSpec are never constructed outside the file that defines them, and the validate methods on AccessibilityType and ManagementType have no callers outside their own unit tests. Authorization that must hold is carried by authority, not by these fields.
  • Two declared trait fields are never read. MetamorphicTrait::reversible and ProfileBindingTrait::revocable are stored with the rest of the traits and consulted by nothing. Under the NFT rules, whether a merge is reversible is the recipe's, and whether a bound copy may be revoked is the NFT's own setting.
  • AuthorizationProof is inert data. It bundles a claimed entity, a signature and a delegation chain, and the crate that defines it has no cryptographic provider with which to check any of them. Its own documentation instructs a consumer to treat it as unverified and fail closed. Nothing outside that crate constructs one.
  • A binding acceptance proof was not verified. Before the NFT rules, BindToken required the token's owner to sign and a non-empty acceptance proof it did not check cryptographically. Under them it is refused: a copy binds only by its holder's own signature, or when the wallet it was offered to accepts it.
  • A migration plan's steps are prose. The registry validates their count and length and refuses a zero-length rollback window or one beyond ninety days. Nothing in it can check that the steps are correct, and nothing in it can run a conformance suite: the review report is an input from whoever can run one. Retirement is likewise answered per chain - references_module says whether this chain still points at a module, and nothing aggregates that across chains - and a staged roll has no watcher, so something outside the registry must notice an anomaly and call rollback.

How it composes

Every other page in Core model is built on this one. An identity is deployed as an element, with a Hybrid field mask separating what its holder may change from what they may not.

Authority supplies the verification this layer deliberately does not: the delegation chain walk, the revocation cascade, and the signature checks that turn a claimed proof into an authorization decision.

Assets and payments are this model viewed through the balance key. A transfer moves an Amount between wallets for one element id, and every trait rule above - soul-bound, network property, dissolved - is a reason that transfer is refused before it ever reaches a block.

Execution is what the executive trait points at: a contract is an element whose payload is code, invoked under a gas limit, with scope and trigger checked before anything runs.