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.

Three things fail open for elements the executor did not see deployed: an element with no recorded immutability class defaults to Centralized, an element with no recorded owner passes require_owner, and an element with no recorded home chain passes the LocalChain scope check.

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.

| Trait | Declares | Enforced as | | --- | --- | --- | | metamorphic | merge conditions and a fractionalization spec with min and max parts | MetamorphicOperation checks every source's MergeConditionSameCreator, SameType, RequiresBothOwners, Custom — and holds a split inside the declared bounds; a metamorphic trait with no fractionalization spec cannot be split at all | | profile_binding | when the token binds to a wallet | BindTrigger::OnCreation binds at deploy; OnCondition binds the moment its condition element exists, evaluated on read; a bound token is refused by the transfer path | | dissolution | the conditions under which the token may be destroyed | DissolveToken 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 | | authority | a ChildManagementPolicy over child elements | under FullControl, or a Conditional whose condition elements all exist, only the parent's owner may update, dissolve, merge or fractionalize a child | | network_property | the chain that manages the token | the token cannot be transferred by a user at all, and may only be modified on its managing chain | | executive | an execution trigger, a scope, and whether approval is required | ExecuteContract 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.

Derived ids are computed, never supplied, so every node names the same result: a merge target is BLAKE3 over a version-tagged seed followed by the sorted source ids, and a fraction id is BLAKE3 over the source id and the fraction index.

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 | | --- | --- | | Arithmetic | checked_add, checked_sub, checked_mul, checked_div only; overflow and underflow return None rather than wrapping | | Parsing | thousands 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 | | Display | the 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 type | Authority | What it means | | --- | --- | --- | | Nexus, Realm, SystemLibrary, Interoperability | NetworkWide | at least min_approvers distinct keys | | Personal | ChainOwner | set equality against one key, not a floor: one extra approver means somebody else had a say, and the swap is refused | | Organization | AoGovernance | the AO's own model and quorum; a single-authority model may not claim to need more than one approver | | Child, ComputingPool | none | construction 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: there is no transaction that unmerges a token or releases a binding.
  • 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 is not verified. BindToken requires the token's owner to sign, and requires the acceptance proof to be non-empty; it does not check that proof cryptographically, because the transaction does not carry the target wallet's public key. Consent at this layer is a non-empty byte string, not a signature.
  • An element with no recorded owner has no owner gate. require_owner refuses a signer who is not the recorded owner, but returns success when no owner was ever recorded. Ownership is established by the deploy path, so the elements this affects are the ones a node seeded outside 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.