// ========================================================================================= // 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; // 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; // 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; state ownedAt: map>; state slotOf: map; // 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 = ""; } }