Custom Modules
Anchoring Module
A genesis-installed EVM contract on NVNM Chain used to manage registries, records, and role-based access for anchoring off-chain artifacts on-chain.
The Anchoring module lets EVM callers create registries, anchor off-chain artifacts (documents, files, data) on-chain via checksummed records, and manage access through a role-based permission system.
Core Concepts
- Registry — A named container for records, always referenced canonically by its auto-incrementing
id. Any user can create one and automatically becomes its admin. Registrynameis not unique — multiple registries may share the same name. - Record — An anchored reference to off-chain data, identified by a checksum, scoped to a
registryId. Records are versioned: submitting the same checksum to the same registry increments the version (index) while preserving therecordId. - Roles — Two roles govern access:
admin(full control, can grant/revoke roles) andeditor(can add records and update status). Roles are evaluated Record → Registry, with no chain-level fallback for these operations. - Registry Name Index — Registries are addressed on-chain by
idonly; there is no on-chain name index. An opt-in, per-node, off-chain RocksDB index can be enabled to support fuzzy name search — see Registry Name Index below.
Info
Contract Address: 0x0000000000000000000000000000000000000A00
Not a precompile
This is not a native EVM precompile — it's ordinary Solidity bytecode (Anchoring.sol, the source repo for all of this chain's genesis-installed contracts), placed at this address either in the genesis allocation (when a chain is built with the --anchoring flag to generate-genesis) or installed by protocol consensus at the Nvnm1 hardfork boundary, overwriting whatever code the genesis alloc had while leaving its storage untouched. A native precompile was tried first and reverted — a precompile pre-empts any contract code at the same address, so it would have permanently shadowed this contract. Functionally this is invisible to a caller: the address, ABI, and behavior are the same as calling any other contract.
A chain that never sets --anchoring and never schedules Nvnm1Time has no code at this address at all — calls there behave like calling an empty account.
Functions
Transactions
| Function | Description |
|---|---|
addRegistry(string name, string description, string metadata) | Creates a new registry. Returns the registryId. Caller becomes admin. |
addRecord(Record record) | Adds a record to record.registryId. If the checksum already exists in that registry, a new version is created. |
updateRecordStatus(uint64 registryId, uint64 recordId, uint64 index, string status) | Updates the status of a specific record version. |
grantRole(uint64 registryId, string checksum, address account, string role) | Grants a role to an address. Pass an empty checksum for registry-level scope. |
revokeRole(uint64 registryId, string checksum, address account, string role) | Revokes a role from an address. |
Queries
| Function | Description |
|---|---|
records(uint64 registryId, string checksum, uint64 recordId, uint64 index, PageRequest pagination) | Fetches records. All filter parameters are optional — pass empty/zero to ignore. |
registries(uint64 registryId, PageRequest pagination) | Fetches registries by id. registryId = 0 lists all registries. |
registriesByName(string name, uint8 matchMode, PageRequest pagination) | Exact match only (matchMode 0 or 1). Prefix/suffix/contains revert on-chain — see Registry Name Index for the off-chain path. |
Note
records, registries, and registriesByName (exact mode) are read-only and do not require any permissions, and can be called via a normal transaction or eth_call.
Data Structures
Record
struct Record {
string uri; // Link to off-chain data (e.g. IPFS URI)
string checksum; // Cryptographic hash of the data
string checksumAlgo; // Algorithm used (e.g. "sha256")
string metadata; // JSON metadata string — "{}" is rejected as empty
string timestamp; // Set by the chain — ignored on input
string status; // User-defined (e.g. "active", "removed")
uint64 recordId; // Set by the chain — use 0 on input
uint64 index; // Version index — set by the chain
bool isLatest; // Latest version flag — set by the chain
uint64 registryId; // The registry this record belongs to
}Registry
struct Registry {
uint64 id;
string name;
string description;
string creator; // Bech32 address of the creator (HRP "nvnm"), even though the caller is an EVM address
string createdAt; // e.g. "2026-09-24 03:15:16 +0000 UTC" — whole seconds only, no fractional part
string metadata; // JSON metadata string
}PageRequest / PageResponse
struct PageRequest {
bytes key;
uint64 offset;
uint64 limit;
bool countTotal;
bool reverse;
}
struct PageResponse {
bytes nextKey;
uint64 total;
}Default page limit is 50, max is 200.
Permission Model
The module uses hierarchical role-based access control. When a permission check runs, it evaluates roles in order — Record → Registry — and permits the action at the first match. There is no chain-level (global) fallback for addRecord, updateRecordStatus, grantRole, or revokeRole — except the module-admin break-glass path below.
| Operation | Required Role |
|---|---|
addRegistry | Any EOA |
addRecord | admin or editor |
updateRecordStatus | admin or editor |
grantRole | Registry admin, or the module admin (registry-scoped admin role only — see below) |
revokeRole | Registry admin only |
| Queries | None |
Note
Every state-changing function requires the caller to be an EOA (msg.sender == tx.origin, no contract code — an EIP-7702 delegation is fine). A contract cannot call these on a user's behalf.
Module admin break-glass
_moduleAdmin is a fixed address configured at genesis (not settable through any function on this contract). It can seed a registry-scoped admin role directly via grantRole, skipping both the normal RBAC check and the EOA gate — intended as recovery if a registry's admin role is otherwise unreachable. It cannot grant any other role, and cannot bypass revokeRole's checks.
Role Scoping
- Record-level — Applies to a single checksum within a registry. Grant with a specific
checksumvalue. - Registry-level — Applies to all records in a registry. Grant with an empty
checksum.
Record-level roles take precedence over registry-level roles. A user with editor at the registry level but admin on a specific record will have admin permissions for that record only.
Events
Topic0 is the full 32-byte keccak256(<event signature>); topic1 is the indexed caller address (msg.sender). Values the chain assigns during execution — registryId from addRegistry, recordId from addRecord — can be decoded from the transaction receipt directly, without an eth_call dry-run.
| Event signature | Topic0 |
|---|---|
AddRegistry(address indexed caller, uint64 registryId, string name) | 0x181791bc379acedd3615cf065d3c275dfa6a3c4614c9065d54c98773f576108d |
AddRecord(address indexed caller, uint64 registryId, uint64 recordId, uint64 index, string checksum) | 0x1a3295fa8cc0e28c95d21912c9e6958f3bc740231781f7640ad885c972a352fd |
UpdateRecordStatus(address indexed caller, uint64 registryId, uint64 recordId, uint64 index, string status) | 0xd7b75457d41293eab4829975c951ce8c53106866f0c429d175fc6c91cdad5ade |
GrantRole(address indexed caller, uint64 registryId, string checksum, address account, string role) | 0x0f49e365baf90deb7d1f63e576637907e12d1ddc75d1ac68894a2bcd192b6ddb |
RevokeRole(address indexed caller, uint64 registryId, string checksum, address account, string role) | 0x8236b76cce80eaf69b54d89268d00fda3dec9e5e054f1548ebbb1f8b20b3b08b |
Note
Only the caller is indexed. registryId/recordId live in the log data, so they can be decoded from a receipt but cannot be used as an eth_getLogs topic filter.
Registry Name Index
Registries are addressed by id on chain; names are not unique and have no on-chain index. Exact-match name lookup is on-chain, through registriesByName (matchMode 0 or 1). Prefix, suffix, and contains are off-chain only — calling registriesByName with matchMode 2/3/4 reverts on-chain with "only exact match is on chain; search prefix/suffix/contains off chain".
Fuzzy search is served by an opt-in, per-node RocksDB index (a reth Execution Extension watching the contract's registry count and names directly out of state — not out of event logs, so it also indexes registries loaded via a state dump rather than a transaction) over a dedicated JSON-RPC namespace:
-
Enable: pass
--anchoring.name-indexto the node (optionally--anchoring.name-index.path <PATH>to relocate it; defaults toanchoring-name-index/under the node's datadir). Off by default. -
Query:
anchoring_searchRegistriesByNamecast rpc anchoring_searchRegistriesByName \ '{"name":"Test","mode":"prefix","offset":0,"limit":50}' \ --rpc-url http://127.0.0.1:8545modeis one ofexact(the default),prefix,suffix, orcontains, matched case-insensitively (ASCII case-folded).containsrequires at least 3 characters. Response:{"registries":[{"id":1,"name":"Test Registry","description":"...","creator":"nvnm1...","createdAt":"...","metadata":"{}"}]} -
Status:
anchoring_nameIndexStatus— reports how far the index has caught up:{"lastId":1,"registryCount":1,"blockNumber":203,"blockHash":"0x..."} -
Consistency: the index is not consensus state — it's derived from the on-chain registry collection, nodes need not run it, and two nodes can briefly differ while one catches up after a restart or reorg.
Usage Examples
Creating a Registry
ANCHORING = ContractAsync.from_abi(ANCHORING_ABI)
ANCHORING_ADDRESS = "0x0000000000000000000000000000000000000A00"
receipt = await ANCHORING.fns.addRegistry(
"my-registry",
"A registry for document anchoring",
'{"owner": "NVNM Foundation"}', # metadata (JSON string)
).transact(w3, admin_account, to=ANCHORING_ADDRESS, gas=100_000)
# registryId is only returned directly from an eth_call/dry-run;
# from a mined tx, decode it from the AddRegistry log in the receipt.Equivalent with cast:
cast send 0x0000000000000000000000000000000000000A00 \
"addRegistry(string,string,string)" \
"my-registry" "A registry for document anchoring" '{"owner":"NVNM Foundation"}' \
--private-key $KEY --rpc-url $RPC_URLAdding a Record
record = (
"ipfs://QmABC123...", # uri
"abc123def456", # checksum
"sha256", # checksumAlgo
'{"document": "..."}', # metadata (JSON string) — "{}" is rejected
"", # timestamp (chain sets this)
"active", # status
0, # recordId (chain sets this)
0, # index (chain sets this)
False, # isLatest (chain sets this)
registry_id, # registryId — the registry this record belongs to
)
receipt = await ANCHORING.fns.addRecord(record).transact(
w3, admin_account, to=ANCHORING_ADDRESS, gas=100_000
)Querying Records
pagination = (b"", 0, 100, False, False)
records, page_response = await ANCHORING.fns.records(
registry_id, # filter by registryId (0 = ignore)
"", # filter by checksum (empty string = all)
0, # recordId (0 = ignore)
0, # index (0 = ignore)
pagination
).call(w3, to=ANCHORING_ADDRESS)Querying Registries by Name
Exact match — on-chain:
# matchMode 0 or 1 only; 2/3/4 revert on-chain
registries, page_response = await ANCHORING.fns.registriesByName(
"my-registry",
1, # EXACT
pagination
).call(w3, to=ANCHORING_ADDRESS)Prefix/suffix/contains — off-chain, only on a node running the name index:
cast rpc anchoring_searchRegistriesByName \
'{"name":"my-reg","mode":"prefix"}' \
--rpc-url $RPC_URLGranting a Role
# Grant registry-level editor role (empty checksum = registry scope)
await ANCHORING.fns.grantRole(
registry_id,
"", # empty = registry-level
user_address,
"editor"
).transact(w3, admin_account, to=ANCHORING_ADDRESS)
# Grant record-level admin role (specific checksum)
await ANCHORING.fns.grantRole(
registry_id,
"abc123def456", # specific document checksum
user_address,
"admin"
).transact(w3, admin_account, to=ANCHORING_ADDRESS)Error Handling
| Scenario | Result |
|---|---|
Non-admin calls grantRole or revokeRole | Transaction reverts (missing admin role) |
addRecord/updateRecordStatus without admin or editor | Transaction reverts (unauthorized) |
addRecord with a non-existent registryId | Transaction reverts |
addRecord with metadata empty or "{}" | Transaction reverts ("metadata cannot be empty") |
| Caller is a contract (not an EOA / EIP-7702 delegation) | Transaction reverts ("sender not an eoa") |
registriesByName with matchMode 2/3/4 (prefix/suffix/contains) | Transaction reverts ("only exact match is on chain; search prefix/suffix/contains off chain") |
anchoring_searchRegistriesByName called on a node without --anchoring.name-index | RPC method not found — the namespace isn't registered |
| Query for a non-existent registry or checksum | Returns empty result |
| Granting an already-held role | Succeeds (idempotent) |
| Calling this address on a chain that never installed the contract | Behaves like calling an empty account — no code, no revert reason |