StateCell Schema Hashes: Representing the Structure of Values
Suppose we store an address and the number 30 as a sequence of bytes. The reader needs to know where the address ends and the integer begins. If more fields are added, or the same record is repeated in an array, we also need an agreed way to interpret that combination.
In Part 1, I explained why Nigo represents asset outputs and smart contract state in a common unit called StateCell. Once that unit was defined, the next question was how to describe the structure of the values it would contain. It needed to accommodate structs, arrays, and mappings as well as individual integers.
Nigo Protocol separates a value from its structure and identifies that structure with a schema hash. This post starts with a small spending-limit record and explains how primitive types combine into composite structures, and how those structures connect to state stored in cells.
Values change, but their structure remains
Suppose A has authorized user S to spend up to 30 units of an asset on A's behalf. First, let us simplify that record to two fields.
User: S's address
Limit: 30
The first field is represented by ADDRESS, an address type, and the second by UINT256, an unsigned
256-bit integer. A definition specifying the types of values and how they combine is called a schema.
Structure:
STRUCT(ADDRESS, UINT256)
Value:
(S's address, 30)
Expressions such as STRUCT(...) in this post are pseudonotation intended to make the structure easy
to read. The field names, user and limit, are explanatory labels. The example retains only these two
fields so we can focus on how structures combine.
If S spends 20, the remaining limit is 10. The record changes to (S's address, 10), but its structure,
STRUCT(ADDRESS, UINT256), stays the same. A limit of 20 for another user, U, can use the same structure.
Here, the limit describes the scope of spending permission; it is not a separately deposited asset balance.
I wanted the storage representation to reflect this distinction. Changing the contents should not require a new type definition every time. A schema hash is an identifier calculated from the structure used to interpret a value, rather than a hash of the value 30 or 10 itself.
From primitive types to composite structures
The starting point is a primitive type: a basic data type that is not divided into smaller fields. Integers, addresses, booleans, byte sequences, and strings provide these building blocks.
| Category | Types |
|---|---|
| Unsigned integers | UINT8, UINT64, UINT256 |
| Signed integers | INT64, INT256 |
| Addresses and booleans | ADDRESS, BOOL |
| Fixed-length byte sequences | BYTES4, BYTES32 |
| Variable-length byte sequences and strings | BYTES, STRING |
ADDRESS and UINT256 are each a schema. A struct combines schemas into an ordered list of fields.
The limit record above is a struct whose first field is an address and whose second field is an integer.
A field can also contain another struct or an array. Instead of continually adding a new primitive type for each composite structure, smaller schemas can combine to express larger structures.
Now let us represent the records before any spending—S=30 and U=20—in several structures. The examples
below compare ways of storing the same information. An array represents repeated values with the same
structure. If we call the earlier limit record Grant, we can write it like this:
Grant = STRUCT(ADDRESS, UINT256)
Structure: ARRAY(Grant, length 2)
Value:
[
(S's address, 30),
(U's address, 20)
]
Here, Grant is an explanatory name. The array actually refers to the element schema,
STRUCT(ADDRESS, UINT256), rather than that name.
For a fixed array, length is part of the type. An array of length 2 and an array of length 3 have different schemas. A dynamic array instead includes an indication of dynamic length in its schema, while its current element count is managed separately as a state value. Growing a dynamic array from 2 elements to 3 changes its value, not its schema.
Looking up a position or looking up a key
To find S's limit in the array above, a program needs to locate S's position. If the program mainly asks, “What is the limit for this address?”, a mapping keyed by address may be a more direct representation. A mapping is a structure that associates keys with values.
Structure:
MAPPING(ADDRESS → UINT256)
S's address → 30
U's address → 20
The array and mapping represent the same limits for S and U, but organize them differently. An array has an order and an element count; a mapping locates the value associated with a particular key. The address serves as the key instead of being stored again inside the value. The mapping schema itself does not contain the list of currently registered users, S and U.
In this design, the value side of a mapping is another schema. To record both a limit and an active flag for each user, we can combine those two values into a struct and use it as the mapping's value.
Policy = STRUCT(UINT256, BOOL)
Structure:
MAPPING(ADDRESS → Policy)
S's address → (30, true)
U's address → (20, true)
The two fields of Policy are the limit and the active flag, in that order. The user's address becomes
the key, and the value under that key is a struct. To keep a separate collection for each authorizing
owner, we can extend the design by placing another mapping around it.
Owner's address
→ (User's address → Policy)
The key idea is to avoid defining a new kind of mapping for every complex case. Structs combine field schemas, arrays use an element schema, and mappings combine a key type with a value schema. The aim is to express complex state through a recursive structure that applies the same composition rules repeatedly.
Calculating a hash from the schema's structure
Different programs can use the same structure while giving their types different names. Calculating an identifier from the structure itself lets those programs refer to the same definition on common terms. Instead of recording a long structural definition every time, we keep a fixed-length identifier and use it to look up the definition when needed.
That identifier is the schema hash. A hash function called SHA-256 turns the structure into a 32-byte value. Its inputs include a marker distinguishing the kind of structure and the information that defines it.
| Schema kind | Information included in the hash calculation |
|---|---|
| Primitive | The PRIMITIVE: marker and the type name |
| Struct | The STRUCT: marker and child schema hashes in field order |
| Array | The ARRAY: marker, fixed length or dynamic-length indicator, and element schema hash |
| Mapping | The MAPPING: marker, key type name, and value schema hash |
For the earlier struct, we first calculate the hashes of ADDRESS and UINT256, then combine them in
order to produce the struct's own hash. An array of that struct refers to the struct hash as its element
schema. The identifiers of smaller structures become inputs for identifying larger ones.
The calculation can be written briefly as follows. H is SHA-256, and || concatenates bytes in order.
The quoted strings are UTF-8 bytes. addressHash and amountHash are each 32-byte hashes, not
hexadecimal (base-16) strings.
addressHash =
H("PRIMITIVE:ADDRESS")
amountHash =
H("PRIMITIVE:UINT256")
grantHash = H(
"STRUCT:"
|| addressHash
|| amountHash
)
The same structure produces the same hash under the same rules. Changing a value from 30 to 10 or renaming a program's class does not affect the calculation. Types and their composition provide the common reference point, rather than the name assigned by the program that defined the structure.
This choice has a clear boundary. Changing STRUCT(ADDRESS, UINT256) to STRUCT(UINT256, ADDRESS)
changes the hash input. But swapping the application-level names of two UINT256 fields still produces
the same hash if the sequence of types stays the same.
A schema hash therefore identifies structure. It cannot determine application meaning—such as “this integer is a limit and that integer is a balance”—or compatibility on its own.
Using the hash together with the schema definition
Recording a 32-byte hash in a cell does not put the struct definition inside it. The field list cannot be reconstructed by reversing the hash. The reader needs the schema definition corresponding to that hash.
When definitions are registered by hash, a reading program can look up field order and types, array element structure, and other structural information in the definition. It finds the child definitions referenced by a composite schema in the same way.
Reading a value with a known schema also involves checking that the actual value matches the definition.
For example, a string where the limit field expects UINT256, or three values supplied to a two-field
struct, would not match the structure. Validation when reading a leaf cell checks the expected schema
hash against the cell's schema hash, along with the actual primitive type and value.
This does not establish whether A has the right to authorize a limit of 30. Whether a number fits its type and whether someone has permission to record that number are separate questions. The schema provides a common representation for the former; the program handling the state and the ledger's rules govern the latter.
Connecting composite structures to state stored in cells
So far, we have discussed logical structures. A StateCell contains id, owner, schemaHash, and
value, and its value uses a primitive representation. Where, then, should the earlier two-field
struct be stored?
One way to distribute a composite structure across cells is to follow the structure down and place
each leaf value in its own cell. Consider a single record, (S's address, 30), using the Grant
structure. Let R be the base ID that distinguishes this record. The reading program starts with R,
which identifies the record, and Grant, which describes its full structure.
In this layout, R is a reference point for locating the two fields, rather than an additional cell containing the entire struct. The fields are laid out as follows:
| Logical location | Cell's schemaHash | Cell's value |
|---|---|---|
| Field 0 of R | Hash of ADDRESS | S's address |
| Field 1 of R | Hash of UINT256 | 30 |
To read the first field, the program combines R, the schema hash of Grant, and field number 0 to
calculate that cell's ID. The definition of Grant says its first field must be an address. The program
therefore checks that the cell's schema hash corresponds to ADDRESS and that the value has the address
type. It locates the second field using number 1 and checks the UINT256 value of 30. Together, the
two values form the record (S's address, 30).
The hash of the full structure participates in the rule for locating a field; the leaf cell's schema hash is
used to check the value at that location. Records with base IDs R and Q are different pieces of state
even when both use the same Grant structure. The schema hash distinguishes “which structure,” while
the base ID and path distinguish “which state using that structure.”
Arrays locate elements in the same way, using an element index. Splitting a fixed array containing two
Grant records produces two fields per element, for a total of four leaf cells. A dynamic array with
the same contents has one additional UINT64 cell recording its current length of 2. A dynamic array's
length cell uses a different kind of ID from its element cells.
A mapping entry's ID is derived from the mapping instance ID, the schema, and the key's type and value. The program can therefore calculate the location of S's entry from S's key without reading all the entries as one block. A mapping containing composite values follows the same idea: from the location found through the key, the program follows the value schema to continue along paths to fields or array elements. In the earlier nested mapping, the limit that A authorized for S can be expressed by this path:
Base ID of the approval collection
→ Authorizing owner A's key
→ User S's key
→ Limit field of Policy
→ Value 30
A mapping uses a key to determine the next location, and a struct uses a field number. A nested structure is a path formed by applying these rules in sequence.
This connects the way we interpret a structure with the way we locate its state. It provides common rules between the composite data used by programs and the small units of state handled by the ledger.
Choosing to store several fields in one cell
The same Grant record can also be stored in a single cell. The address and limit are encoded in an
agreed format and combined into one BYTES value.
Here, BYTES is the physical representation of the value stored in the cell. That byte sequence
contains two logical fields: the address and the limit. The cell's value is BYTES, and its
schemaHash points to the definition of the Grant struct used to interpret those bytes.
This is how a single stored byte sequence can represent a composite structure.
The two layouts for the same (S's address, 30) can be compared as follows:
| Layout | Cell's schema hash | Unit replaced in a cell update |
|---|---|---|
| Split into two fields | Separate hashes of ADDRESS and UINT256 | Address or limit |
Combined into one BYTES value | Hash of the full Grant | Entire record |
The program handling the state and the storage conventions determine which layout to use. The reader also follows those conventions to find cells and interpret their values. The schema describes what is stored; the layout and encoding conventions determine which cells hold it and how.
Separate cells are useful when individual fields often change independently. Values that are usually read and changed together can instead be combined into one cell. State spread across several cells can still be validated and changed together in one transaction. Part 3 will examine atomicity, which groups changes that must succeed together.
EVM storage has its own layout rules as well. Solidity structs, arrays, and mappings are expressed as
underlying slots according to the compiler and EVM storage rules. Nigo stores each slot as a StateCell
with a UINT256 value, rather than splitting it again into Solidity fields. This preserves the existing
EVM storage semantics while using the same cell-based foundation.
What remains after identifying structure
With schema hashes, the ledger's shared representation no longer depends on a particular program's class names. Primitive types and composition rules identify structures, the same structure can be reused across different states, and stored values can be checked against their expected types.
Structural changes also become explicit. Adding a BOOL field to the earlier limit record means
calculating a schema hash for a new structure. But the new hash does not automatically migrate old data
or fill in defaults. Separate transition rules must determine which previous state to read, what to
record next, and which versions a program will accept.
The next question is permission to change state. Even if a limit of 30 is represented with the correct structure, an arbitrary person must not be able to create spending permission over someone else's assets. An authorized user, however, should be able to act within the approved scope without the asset owner signing each transaction directly.
Part 3 explores that relationship. Using
an approval and transfer example, it examines what a StateCell's owner means, the rules connecting
ownership information to permission to change state, and the division of responsibilities among the
protocol core, Native Programs, and EVM contracts.
Part 2 of the StateCell design series. The spending-limit record is an example used to explain schema composition and state layout.