SourceLessExplorer · SourceLess AsgardSourceLess Asgard · chain 1313
LiveHeight23,745Head age7 minLast interval4 minFinality23,734Behind−11Pending0 / 8,192Tx in head0
XPlorer following the chain as its own read-only node

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

Edit the draft

Set the constants the template marks for editing, and name a deployer to see where it would land.

string, shipped as "Example ZK13 Collection"

string, shipped as "EZC"

string, shipped as ""

uint, shipped as 10000

uint, shipped as 512

The form asks this explorer to compile your draft and show it back. It signs nothing, stores nothing and reaches no node.

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.

  1. Save the draft. Download it above, or start from the catalogue text with strblk contract template zk13-nft > zk13-nft.ares and edit the marked constants yourself.
  2. 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.
  3. Find your fork's RPC. It is the rpc (Iris, wallets) line of the fork's banner, and the rpcUrl of each node in <data root>/fork.json.
  4. 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 pointGas
transferToken2,373
burnToken2,081
mint2,063
setMinter471
closeMinting470
setPaused363
Declare at least
23,730 ten times the most expensive call

Functions

KindNameInputsOutputsMutability
functionstandardstringview
functionnamestringview
functionsymbolstringview
functioncontractUristringview
functionownerOftokenId: uintaddressview
functiontokenUritokenId: uintstringview
functionbalanceOfaccount: addressuintview
functiontokenOfOwnerByIndexaccount: address, index: uintuintview
functiontotalMinteduintview
functiontotalBurneduintview
functiontotalSupplyuintview
functionmaxSupplyuintview
functionexiststokenId: uintboolview
functionowneraddressview
functionminteraddressview
functionpausedboolview
functionpendingOwnerIsaddressview
functionmintto: address, uri: stringuintnonpayable
functiontransferTokento: address, tokenId: uintboolnonpayable
functionburnTokentokenId: uintboolnonpayable
functionproposeOwnernext: addressnonpayable
functionacceptOwnershipnonpayable
functionsetPausednext: boolnonpayable
functionsetMinternext: addressnonpayable
functioncloseMintingnonpayable
eventTransferfrom: address, to: address, tokenId: uint
eventTokenUritokenId: uint, uri: string
eventOwnerProposedcurrent: address, pending: address
eventOwnerChangedprevious: address, next: address
eventPausedChangedpaused: bool, by: address
eventMinterChangedprevious: 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 = "";
  }
}