height 23745 of 23745 on the network. 9 peer(s) connected, 77 in its peer directory; 1041 block(s) followed and 865 pulled in by sync since this follower started. Caught up: this follower holds every block any peer has claimed, so every panel below is the whole chain. Genesis 1cf8655125cc1bbb…, producers genesis.
What this follower is, locally
Following 7 seed(s) — their addresses are not published here, for the reason the peer table gives. Its store is held under its own Cerberus lease — no other node’s store is opened, touched or stopped.
ZK13NFT for your fork
- Status
- a draft, not deployed nothing here is on any chain; never send value to a draft
- Profile
- ZK13 non-fungible, the reference collection
- Holds
- its own ownership map
- The property that matters
- metadata is written at mint and no function changes it
- On Asgard
- the same template in the catalogue
Edit the draft
Set the constants the template marks for editing, and name a deployer to see where it would land.
The draft as shipped
- Contract
ZK13NFT- Compiler
areslang-1.3.0- Bytecode SHA-256
eec491a197be1282ade029e393f078e67efb2e7d6132c98673714b0403b44611- Bytecode
- 3,453 bytes
- Source SHA-256
43250b04b64b10b33d5a96ee9c8dfcf860ff137811bde9e1b35687de5e5772ae- The catalogue text?
- Yes, byte for byte: matches the published vector
- Ready to deploy?
- not yet a deployment refuses while these remain: collectionName = "Example ZK13 Collection", collectionSymbol = "EZC"
- Would land at
- name a deployer to see it
Deploy it to your fork
Only the last step signs, and only it reaches a node.
- Save the draft. Download it above, or start from the catalogue text with
strblk contract template zk13-nft > zk13-nft.aresand edit the marked constants yourself. - Dry-run it on your machine.
strblk wallet deploy zk13-nft.ares --dry-run --address <your address on the fork>compiles it locally and prints its digest and address. They must match this page; if they do not, the file is not this draft. - Find your fork's RPC. It is the
rpc (Iris, wallets)line of the fork's banner, and therpcUrlof each node in<data root>/fork.json. - Deploy it.
strblk wallet deploy zk13-nft.ares --node <your fork's RPC>, from a wallet whose account holds a proved STR.domain on your fork. Before it signs, the wallet prints the network: the realm reads unregistered and the genesis is your fork's hash. Check that hash; it is what the signature binds.
Measured gas
Charged by the STARW VM under areslang-1.3.0, the same on your fork. Measured, not estimated.
| Entry point | Gas |
|---|---|
| transferToken | 2,373 |
| burnToken | 2,081 |
| mint | 2,063 |
| setMinter | 471 |
| closeMinting | 470 |
| setPaused | 363 |
- Declare at least
- 23,730 ten times the most expensive call
Functions
| Kind | Name | Inputs | Outputs | Mutability |
|---|---|---|---|---|
| function | standard | string | view | |
| function | name | string | view | |
| function | symbol | string | view | |
| function | contractUri | string | view | |
| function | ownerOf | tokenId: uint | address | view |
| function | tokenUri | tokenId: uint | string | view |
| function | balanceOf | account: address | uint | view |
| function | tokenOfOwnerByIndex | account: address, index: uint | uint | view |
| function | totalMinted | uint | view | |
| function | totalBurned | uint | view | |
| function | totalSupply | uint | view | |
| function | maxSupply | uint | view | |
| function | exists | tokenId: uint | bool | view |
| function | owner | address | view | |
| function | minter | address | view | |
| function | paused | bool | view | |
| function | pendingOwnerIs | address | view | |
| function | mint | to: address, uri: string | uint | nonpayable |
| function | transferToken | to: address, tokenId: uint | bool | nonpayable |
| function | burnToken | tokenId: uint | bool | nonpayable |
| function | proposeOwner | next: address | nonpayable | |
| function | acceptOwnership | nonpayable | ||
| function | setPaused | next: bool | nonpayable | |
| function | setMinter | next: address | nonpayable | |
| function | closeMinting | nonpayable | ||
| event | Transfer | from: address, to: address, tokenId: uint | ||
| event | TokenUri | tokenId: uint, uri: string | ||
| event | OwnerProposed | current: address, pending: address | ||
| event | OwnerChanged | previous: address, next: address | ||
| event | PausedChanged | paused: bool, by: address | ||
| event | MinterChanged | previous: address, next: address |
Your draft's source
templates/zk13-nft.ares, as shipped
// =========================================================================================
// ZK13NFT — the reference non-fungible collection for SourceLess.
//
// WHAT IT IS FOR
// One contract, many distinct tokens, each owned by exactly one address, each carrying its
// own metadata pointer. This is the reference implementation of docs/ZK13_NFT_STANDARD.md:
// every view, every mutating function, every event and every `require` message in that
// document is here, spelled the way the standard spells it, so a wallet can recognise a
// refusal without parsing prose.
//
// HOW IT DIFFERS FROM nft-collection.ares, WHICH IS ALSO IN THIS CATALOGUE
// `nft-collection.ares` is the minimal object: ownership and a count, no metadata and no
// enumeration. This one is the STANDARD: it adds a per-token metadata pointer fixed at mint,
// and it adds `tokenOfOwnerByIndex`, so a holder's tokens can be listed from chain state
// rather than reconstructed from events by an indexer. Both are conforming; this one costs
// more gas per move (MEASURED below) and answers more questions. Pick the minimal one if
// your tokens have no metadata and nobody needs to enumerate a holder.
//
// THIS IS NOT A DOMAIN WNFT, AND IT CANNOT BE MADE INTO ONE
// The native WNFT is one per STR.domain, minted by the domain registry, backed by 1000 STR
// locked, and NON-TRANSFERABLE, because transferring it would transfer an identity. Nothing
// here is any of that. A DOMAIN-BOUND NFT IS NOT WRITABLE TODAY: `AresHost` exposes
// selfAddress, blockTimestampMs, blockHeight, balanceOf and transfer and no domain accessor,
// so a contract cannot ask whether an address holds a proved STR.domain. Storing a domain
// string and calling it a binding verifies nothing — and case folding (STR.Treasury beside
// STR.treasury) is an impersonation the contract cannot even detect, because
// `normalizeStrDomain` is not reachable from AresLang. This template therefore stores no
// domain at all. Do not use "WNFT" in your collection name, symbol or marketing.
//
// WHAT IT GUARANTEES
// * Only the holder of a token can move it. `transferToken` reads `tokenOwner[id]` and
// compares it to `caller`; there is no `from` argument, no approval and no operator, so
// there is no second authority that could move a token.
// * An id is minted at most once, and ids are assigned by the contract in sequence. No
// function writes an owner for an id that was never minted.
// * A token's metadata pointer is written once, at mint, and NO FUNCTION CHANGES IT. There
// is no `setTokenUri` here, and adding one is the most common way a collection becomes a
// rug: the art is replaced after the sale. What this cannot promise is that the bytes the
// pointer names stay the same — that is a property of whatever serves the URI, not of this
// chain. An immutable pointer to mutable content is still mutable content; point it at a
// content-addressed location if you want the content fixed too.
// * `tokenOfOwnerByIndex` is exact and costs the same at any collection size. The holder's
// list is maintained by swapping the last entry into the vacated slot, which is a fixed
// number of map writes — it never iterates, so no holder can grow a position into one that
// is too expensive to move.
// * The owner cannot take a token from a holder. There is no such function. The owner can
// pass the minter role, close minting, pause, and hand over ownership in two steps.
// * An emergency stop that freezes every write and no view, so a stopped collection stays
// auditable at the moment somebody needs to audit it.
// * A revert leaves the collection byte-identical. Storage commits only on success.
//
// WHAT IT DELIBERATELY DOES NOT HAVE
// No approve, no operator, no transferFrom. Those exist so another CONTRACT can move a
// token on your behalf, and AresLang has no cross-contract calls: they would be stored
// permissions nothing could use, waiting to become live on the day a call opcode ships.
// No royalty declaration. A royalty is enforced by a marketplace, a marketplace is another
// contract, and this chain has no cross-contract calls — so a `royaltyBps()` here would be a
// number nothing reads and nothing obeys. The standard says that in its own words rather
// than shipping a field that looks like a right.
// No `tokenByIndex`. Ids ARE the index: they run 1..totalMinted() with no gaps, because
// `mint` assigns them in sequence and `burnToken` does not renumber. `ownerOf(id) == ""` is
// how a burned id reads.
//
// HOW IT IS TYPICALLY MISUSED
// * Sending a token to a contract address. A contract cannot be `caller`, so a token owned
// by a contract can never move again — PERMANENT LOSS. This template refuses the one case
// an address check can detect (`to != self`) and cannot detect any other, because an
// address gives no hint of what is behind it.
// * Treating `ownerOf` as a person, or as a verified user. Holding is not identity.
// * Expecting the gas meter to stop a long URI. It will not, and this is the one number in
// this file that surprises people: the VM prices a storage write by the SLOT NAME it has
// to build, not by the value it stores, so minting with a 60,000-character URI costs the
// same 2,054 gas as minting with a 41-character one — while adding 60,000 characters to
// state that every node keeps forever. `maxUriLength` is therefore a policy bound the
// contract enforces because the meter does not. 512 is a decision, not a machine limit;
// the machine's limit is 65,535 UTF-16 code units.
// * Under-declaring the gas limit on the call record. Post-mining covers only
// min(gasUsed, gasLimit / 10), so the network silently absorbs the rest, forever, on every
// call. MEASURED, on the v2 gas table, with 53-character addresses and a 38-character
// metadata URI, whose length the mint cost rises with: mint 2,063,
// transferToken 2,373, burnToken 2,081, proposeOwner 412, acceptOwnership 612,
// setPaused 363, setMinter 471, closeMinting 470. Declare a gas limit of at least 24,000.
//
// TO MAKE THIS YOUR COLLECTION
// Edit the five constants below and nothing else. Changing them changes the bytecode and
// therefore the deployed address, which is correct: a different collection is a different
// contract. The deployer is owner and minter, so the first call after deployment is
// `mint(holder, "https://...")`.
// =========================================================================================
contract ZK13NFT {
// ---- edit these five -----------------------------------------------------------------
state collectionName: string = "Example ZK13 Collection";
state collectionSymbol: string = "EZC";
// Collection-level metadata, for a wallet rendering the collection itself rather than one
// token. Set once here; there is no setter, for the same reason there is no `setTokenUri`.
state collectionUri: string = "";
// 0 means uncapped. A cap is the only way "there will only ever be N of these" is a fact
// rather than a promise, because `closeMinting` depends on the owner still being reachable
// and a cap does not.
state maxTokens: uint = 10000;
// A policy bound on the metadata pointer, in UTF-16 code units. Not a machine limit — see
// the header. Every URI is stored forever in state every node keeps.
state maxUriLength: uint = 512;
// -------------------------------------------------------------------------------------
// `caller` inside a state initializer is the DEPLOYER: Sindri runs the compiler-generated
// initializer with the authenticated deployer as caller. That is why this collection needs
// no `claimOwnership` call and has no window in which a stranger could claim it.
state contractOwner: address = caller;
state pendingOwner: address = "";
state mintAuthority: address = caller;
state frozen: bool = false;
// Ids are handed out from here and never reused. `mintedCount` is therefore both the count
// of ids ever issued and the highest id in existence, which is what makes the bounds check
// in every mutating function a single comparison.
state mintedCount: uint = 0;
state burnedCount: uint = 0;
state tokenOwner: map<uint, address>;
// Written once, at mint. Survives a burn deliberately: the record of what an id WAS is
// worth keeping, and `ownerOf(id) == ""` is the authoritative answer to whether it exists.
state tokenUriOf: map<uint, string>;
// The holder's list, and the inverse of it. `ownedAt[holder][i]` is the i-th token that
// holder owns, for i in 0..ownedCount[holder]-1; `slotOf[id]` is the i at which `id` sits in
// its current owner's list. The pair is what makes removal O(1): the last entry is swapped
// into the vacated slot and the tail is cleared, so no function here ever walks a list whose
// length a holder controls. A contract that iterated instead would become uncallable at a
// size discovered in production, and the code is immutable.
state ownedCount: map<address, uint>;
state ownedAt: map<address, map<uint, uint>>;
state slotOf: map<uint, uint>;
// Transfer is the SINGLE record of every ownership movement and there is no second one: a
// mint is Transfer("", to, id) and a burn is Transfer(from, "", id). An indexer replaying
// Transfer must arrive at exactly the owners in storage.
event Transfer(from: address, to: address, tokenId: uint);
// Metadata, not movement, so it double-counts nothing for an indexer summing Transfer.
// Emitted once, at mint, because the pointer is written once and never changes.
event TokenUri(tokenId: uint, uri: string);
event OwnerProposed(current: address, pending: address);
event OwnerChanged(previous: address, next: address);
event PausedChanged(paused: bool, by: address);
event MinterChanged(previous: address, next: address);
// ---- views ----------------------------------------------------------------------------
// Which standard this contract was written against. It is a SELF-DECLARATION and proves
// nothing — any contract can return any string — but it tells a client which set of rules
// the issuer INTENDED, which the function catalogue alone cannot when two standards' required
// sets overlap. A client that needs certainty checks `catalog(address)` against the
// standard's required surface; this is the cheap first question, not the answer.
view fn standard() -> string {
return "zk13-nft/1";
}
view fn name() -> string {
return collectionName;
}
view fn symbol() -> string {
return collectionSymbol;
}
view fn contractUri() -> string {
return collectionUri;
}
// "" means the id was never minted, or was burned. Those two are distinguishable: an id
// above `totalMinted()` was never minted, one at or below it was burned.
view fn ownerOf(tokenId: uint) -> address {
return tokenOwner[tokenId];
}
view fn tokenUri(tokenId: uint) -> string {
return tokenUriOf[tokenId];
}
view fn balanceOf(account: address) -> uint {
return ownedCount[account];
}
// The i-th token of a holder, in no promised order: a transfer or a burn moves the last
// entry into the vacated slot, so positions shift. What IS promised is that the indices
// 0..balanceOf(account)-1 enumerate exactly that holder's tokens, each once, at any instant.
// A client paging through them while the holder is moving tokens can see one twice or miss
// one, exactly as it can on any chain; read them within one block or accept that.
view fn tokenOfOwnerByIndex(account: address, index: uint) -> uint {
require(index < ownedCount[account], "no such index");
return ownedAt[account][index];
}
view fn totalMinted() -> uint {
return mintedCount;
}
view fn totalBurned() -> uint {
return burnedCount;
}
// How many exist right now. `totalMinted` never falls, so this is the one to show a buyer.
view fn totalSupply() -> uint {
return mintedCount - burnedCount;
}
view fn maxSupply() -> uint {
return maxTokens;
}
view fn exists(tokenId: uint) -> bool {
return len(tokenOwner[tokenId]) > 0;
}
view fn owner() -> address {
return contractOwner;
}
// "" means minting is closed permanently.
view fn minter() -> address {
return mintAuthority;
}
// Required even on a collection that never pauses. A missing function is indistinguishable,
// to a wallet, from a contract whose answer is inconvenient.
view fn paused() -> bool {
return frozen;
}
view fn pendingOwnerIs() -> address {
return pendingOwner;
}
// ---- movement ---------------------------------------------------------------------------
fn mint(to: address, uri: string) -> uint {
require(frozen == false, "paused");
require(len(mintAuthority) > 0, "mint closed");
require(caller == mintAuthority, "not minter");
// Unreachable through a call record — `validateArgs` checksum-validates every `address`
// argument before the first opcode — and kept because it is the standard's rule and
// because a future caller path that does not run it would need the guard.
require(len(to) > 0, "empty recipient");
// A position at this contract's own address could never move again: a contract cannot be
// `caller`, and the value ledger refuses a contract that pays itself. Every `transfer` in
// this catalogue has refused this since it was written; the ISSUANCE path did not, so the
// guard sat on the call a holder makes and was absent from the call an operator makes.
// MEASURED 2026-10-07: minting here succeeded and produced a position nothing can spend.
require(to != self, "self is not payable");
require(len(uri) <= maxUriLength, "uri too long");
if (maxTokens > 0) {
require(mintedCount < maxTokens, "supply cap");
}
let id = mintedCount + 1;
mintedCount = id;
tokenOwner[id] = to;
tokenUriOf[id] = uri;
// Append to the recipient's list: the new token takes the slot one past the end.
let slot = ownedCount[to];
ownedAt[to][slot] = id;
slotOf[id] = slot;
ownedCount[to] = slot + 1;
emit Transfer("", to, id);
emit TokenUri(id, uri);
return id;
}
fn transferToken(to: address, tokenId: uint) -> bool {
require(frozen == false, "paused");
require(len(to) > 0, "empty recipient");
require(tokenId > 0 && tokenId <= mintedCount, "no such token");
// The whole security property of this contract is on this line.
require(tokenOwner[tokenId] == caller, "not the token owner");
require(to != caller, "self transfer");
// A token at this contract's own address could never move again, because a contract
// cannot be `caller`. It is the one permanent-loss case an address check can detect.
require(to != self, "self is not payable");
tokenOwner[tokenId] = to;
// Remove from the sender's list. The last entry is swapped into the vacated slot and the
// tail is cleared, which is correct in the degenerate case too: when the token being moved
// IS the last entry, `moved` is `tokenId`, the swap writes it back to its own slot, and
// the clear that follows removes it. Fixed cost, no iteration, no hole in the list.
let vacated = slotOf[tokenId];
let last = ownedCount[caller] - 1;
let moved = ownedAt[caller][last];
ownedAt[caller][vacated] = moved;
slotOf[moved] = vacated;
ownedAt[caller][last] = 0;
ownedCount[caller] = last;
// Append to the recipient's list. This has to come after the removal: a holder moving a
// token to themselves would have the two halves fight, which is why `to != caller` is
// refused above rather than handled here.
let slot = ownedCount[to];
ownedAt[to][slot] = tokenId;
slotOf[tokenId] = slot;
ownedCount[to] = slot + 1;
emit Transfer(caller, to, tokenId);
return true;
}
// The holder destroys their own token and nobody else's. There is no authority in this
// contract by which anyone could burn somebody else's, and inventing one is confiscation.
fn burnToken(tokenId: uint) -> bool {
require(frozen == false, "paused");
require(tokenId > 0 && tokenId <= mintedCount, "no such token");
require(tokenOwner[tokenId] == caller, "not the token owner");
tokenOwner[tokenId] = "";
let vacated = slotOf[tokenId];
let last = ownedCount[caller] - 1;
let moved = ownedAt[caller][last];
ownedAt[caller][vacated] = moved;
slotOf[moved] = vacated;
ownedAt[caller][last] = 0;
// After the swap, so that burning the last entry clears its own slot rather than the slot
// of whatever was moved on top of it.
slotOf[tokenId] = 0;
ownedCount[caller] = last;
burnedCount = burnedCount + 1;
emit Transfer(caller, "", tokenId);
return true;
}
// ---- powers the deployer keeps, all of them visible in the ABI --------------------------
fn proposeOwner(next: address) {
require(caller == contractOwner, "not owner");
pendingOwner = next;
emit OwnerProposed(contractOwner, next);
}
// Two steps, because a one-step handover to a mistyped address is permanent: there is no
// administrator anywhere in SourceLess who can reach into a contract's slots.
fn acceptOwnership() {
require(caller == pendingOwner, "not the pending owner");
emit OwnerChanged(contractOwner, caller);
contractOwner = caller;
pendingOwner = "";
}
fn setPaused(next: bool) {
require(caller == contractOwner, "not owner");
frozen = next;
emit PausedChanged(next, caller);
}
fn setMinter(next: address) {
require(caller == contractOwner, "not owner");
require(len(mintAuthority) > 0, "mint closed");
emit MinterChanged(mintAuthority, next);
mintAuthority = next;
}
// One-way. This is the function that lets a collection PROVE it is complete rather than
// promise it. There is no argument because an `address` argument cannot be "": the ABI
// check refuses it before the first opcode.
fn closeMinting() {
require(caller == contractOwner, "not owner");
require(len(mintAuthority) > 0, "mint closed");
emit MinterChanged(mintAuthority, "");
mintAuthority = "";
}
}