SourceLessExplorer · SourceLess AsgardSourceLess Asgard · chain 1313
LiveHeight23,775Head age1 sLast interval1 minFinality23,764Behind−11Pending0 / 8,192Tx in head0
XPlorer following the chain as its own read-only node

height 23775 of 23775 on the network. 9 peer(s) connected, 77 in its peer directory; 1071 block(s) followed and 894 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.

ZK13MultiToken

Status
template — not deployed A template is a draft, not a deployed contract. Nothing here is on the chain: it has no address until somebody deploys it, and where it lands depends on who deploys it and with which salt. Never send value to a template.
File
templates/zk13-multi-token.ares
Profile
ZK13 multi-token, the reference
Holds
many ledgers, one per id
The property that matters
a per-id cap bounds lifetime issuance; a type’s terms are immutable
Compiler in force
areslang-1.3.0
Bytecode
3,556 bytes
Bytecode SHA-256
3e091d78e9888cfb9817831558726bedc225d52696c94cd34629201556f0fa01
Source SHA-256
9dbeb93dcc0494e59b3c0516d27a4d3d4432c41d322a6d936fe410f22599481f
Published vector
matches the published vector spec/conformance-vectors.json pins 3e091d78e9888cfb… under areslang-1.3.0

Before you deploy

The header marks these for editing before you deploy. The values below are the examples it ships with; deployed unedited, they are what every holder sees for the life of the contract.

ConstantTypeShipped as
catalogueNamestring"Example ZK13 Multi-Token"
catalogueSymbolstring"EZM"
maxUriLengthuint512

Where your deployment would land

Name a deployer to see where its deployment of this template, unedited, would land.

The chain derives a contract’s address from its name, its bytecode, the deployer and the salt. This is that function over the bytecode above, so it holds only for the template exactly as shown and only while the chain runs the same compiler. Edit one constant and the address moves, which is correct: different code is a different contract. Nothing is at this address until somebody deploys there.

Measured gas

What the STARW VM charged for each call on a real chain, under areslang-1.3.0, and asserted exactly by the catalogue test: a figure that moved would fail the build. Measured, not estimated. A call that stores a string costs more for a longer one; those were measured with ipfs://QmExampleTokenMetadataDocument01.

Entry pointGas
mint1,469
createType1,276
transfer904
burn872
closeType570
Declare at least
14,690 ten times the most expensive call; post-mining covers only a tenth of the declared limit

ABI

KindNameInputsOutputsMutability
functionstandardstringview
functionnamestringview
functionsymbolstringview
functiontypeCountuintview
functionexistsid: uintboolview
functionuriid: uintstringview
functionmaxSupplyid: uintuintview
functiondecimalsid: uintuintview
functiontotalSupplyid: uintuintview
functionlifetimeIssuedid: uintuintview
functiontypeClosedid: uintboolview
functionbalanceOfaccount: address, id: uintuintview
functionowneraddressview
functionminteraddressview
functionpausedboolview
functionpendingOwnerIsaddressview
functioncreateTypemetadataUri: string, cap: uint, decimalsValue: uintuintnonpayable
functionmintid: uint, to: address, amount: uintboolnonpayable
functiontransferid: uint, to: address, amount: uintboolnonpayable
functionburnid: uint, amount: uintboolnonpayable
functioncloseTypeid: uintnonpayable
functionproposeOwnernext: addressnonpayable
functionacceptOwnershipnonpayable
functionsetPausednext: boolnonpayable
functionsetMinternext: addressnonpayable
functioncloseMintingnonpayable
eventTransferid: uint, from: address, to: address, amount: uint
eventTypeCreatedid: uint, cap: uint, decimals: uint, uri: string
eventTypeClosedid: uint, lifetimeIssued: uint
eventOwnerProposedcurrent: address, pending: address
eventOwnerChangedprevious: address, next: address
eventPausedChangedpaused: bool, by: address
eventMinterChangedprevious: address, next: address

From the command line

Print the source to a file, edit the marked constants, dry-run it to see the digest and your address, then deploy. Only the last command signs, and it is the only one that needs your key and a node.

strblk contract template zk13-multi-token > zk13-multi-token.ares
strblk contract template zk13-multi-token --info
strblk wallet deploy zk13-multi-token.ares --dry-run --address <your address>
strblk wallet deploy zk13-multi-token.ares --node <your node's RPC>

Source

templates/zk13-multi-token.ares, as embedded in this build

// =========================================================================================
// ZK13MultiToken — the reference multi-token contract for SourceLess.
//
// WHAT IT IS FOR
//   Many assets in ONE contract, each with its own id, its own supply, its own cap and its own
//   metadata. A game's items, a ticketing run, an issuer's whole catalogue of stablecoins —
//   anything where deploying one contract per asset is the wrong shape. This is the reference
//   implementation of docs/ZK13_MULTI_TOKEN_STANDARD.md.
//
//   An id whose cap is 1 and whose decimals are 0 is NON-FUNGIBLE, and the cap is what makes
//   that true rather than a convention: `mint` refuses the second unit. An id whose cap is 0
//   is an uncapped fungible balance. Both live in the same balance map and the same event.
//
// WHY ONE CONTRACT RATHER THAN MANY
//   Not gas — deployment here is cheap and unmetered (`applyDeploy` returns no `gasUsed`, so
//   post-mining skips it: stated because it is true, not because it is good). It is that ONE
//   CONTRACT IS ONE SET OF RULES. The cap, the pause, the minter and the ownership handover
//   are written once and cover every id, so an issuer cannot accidentally deploy the tenth
//   asset from a different version of the source than the first nine.
//
//   The cost of that, stated plainly: the pause is GLOBAL. One `setPaused(true)` freezes every
//   id in the contract, including ids held by people with no interest in whatever prompted it.
//   If your assets need independent emergency stops, they need independent contracts — this
//   template will not pretend otherwise by shipping a per-id flag that the owner could set for
//   every id anyway.
//
// WHAT IT GUARANTEES
//   * A balance moves only by its holder's own call. `transfer` debits `balances[id][caller]`
//     and there is no `from` argument, no approval and no operator anywhere in the ABI.
//   * Supply per id moves only through `mint` and `burn`; `transfer` moves it by exactly zero.
//   * A per-id cap bounds LIFETIME ISSUANCE (`lifetimeIssued(id)`), never the circulating
//     `totalSupply(id)` that `burn` lowers. A cap tested against a burnable number is defeated
//     by mint, burn, mint — the issuance is unbounded and the cap never appears to break.
//   * A type's metadata pointer, cap and decimals are written once at `createType` and NO
//     FUNCTION CHANGES ANY OF THEM. An issuer who can raise a cap after the sale has no cap.
//   * `closeType(id)` is one-way and per id, so "this edition is complete" becomes a fact
//     about one asset without stopping the rest of the catalogue.
//   * The owner cannot move, freeze or take any holder's balance. There is no `setBalance`, no
//     `adjust`, no `transferFrom` and no per-account freeze.
//   * A revert leaves every balance in the contract byte-identical. Storage commits only on
//     success.
//
// WHAT IT DELIBERATELY DOES NOT HAVE
//   No batch anything. ERC-1155's `safeBatchTransferFrom` and `balanceOfBatch` take arrays,
//   AresLang has no array type and no argument that is not a scalar, so a batch call CANNOT BE
//   EXPRESSED HERE. The honest substitute is one record per movement: n transfers are n call
//   records, which cost n signatures and n nonces, and are atomic individually rather than
//   together. If you need "all of these or none", this contract cannot give it to you and no
//   arrangement of the code can.
//   No approve, no operator, no transferFrom — they exist so another CONTRACT can spend on a
//   holder's behalf, and there are no cross-contract calls on this chain.
//   No receive hook. ERC-1155 calls `onERC1155Received` so a contract recipient can refuse a
//   token; nothing here can call anything, so a token sent to a contract address is stuck with
//   no hook that could have prevented it. The header below says what that means.
//
// HOW IT IS TYPICALLY MISUSED
//   * Sending to a contract address. A contract cannot be `caller`, so a balance recorded at a
//     contract address can never move again. PERMANENT LOSS. This template refuses the one
//     case it can detect — its own address — and cannot detect any other.
//   * Treating id 0 as an asset. There is no id 0: ids start at 1, so that "the type does not
//     exist" and "the caller passed nothing" are the same answer.
//   * Creating a type per user, or per order, and expecting the contract to stay cheap. It
//     does — every function here is a fixed number of map operations — but each type is a
//     permanent addition to the state every node keeps. `typeCount` only ever grows and there
//     is no delete.
//   * Reading `decimals(id)` as a promise about anything but display. It is metadata the
//     issuer declared at creation; no arithmetic in this contract consults it.
//   * 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: createType 1,276,
//     mint 1,345 into an uncapped id and 1,469 into a capped one, transfer 904, burn 872,
//     closeType 570, proposeOwner 412, acceptOwnership 612, setPaused 363, setMinter 471,
//     closeMinting 470. Declare a gas limit of at least 15,000.
//
// TO MAKE THIS YOUR CATALOGUE
//   Edit the three constants below and nothing else; everything else about an asset is chosen
//   per id, at `createType`, after deployment. The deployer is owner and minter, so the first
//   call is `createType("https://...", cap, decimals)`.
// =========================================================================================

contract ZK13MultiToken {
  // ---- edit these three ----------------------------------------------------------------
  state catalogueName: string = "Example ZK13 Multi-Token";
  state catalogueSymbol: string = "EZM";
  // A policy bound on each type's metadata pointer, in UTF-16 code units. The VM prices a
  // storage write by the slot name it builds and NOT by the value it stores, so a 60,000
  // character URI costs the same gas as a short one while adding 60,000 characters to state
  // every node keeps forever. The meter will not stop that; this will.
  state maxUriLength: uint = 512;
  // -------------------------------------------------------------------------------------

  // `caller` inside a state initializer is the DEPLOYER: Sindri runs the compiler-generated
  // initializer with the authenticated deployer as caller, so there is no claim race.
  state contractOwner: address = caller;
  state pendingOwner: address = "";
  state mintAuthority: address = caller;
  state frozen: bool = false;

  // Ids are handed out from here, start at 1, and are never reused.
  state typeCount: uint = 0;

  // Everything about a type. All four of these are written once, by `createType`, and no
  // function in this contract writes any of them again.
  state uriOf: map<uint, string>;
  state capOf: map<uint, uint>;
  state decimalsOf: map<uint, uint>;

  // Circulating, which `burn` lowers.
  state supplyOf: map<uint, uint>;
  // Everything ever minted for this id, which never falls. THIS is what the cap bounds; see
  // the header for what happens to an issuer who bounds the other one.
  state issuedOf: map<uint, uint>;
  // One-way per id.
  state closedOf: map<uint, bool>;

  // id -> holder -> amount. The nesting order is deliberate: a holder's whole position is not
  // enumerable either way, and putting the id first keeps every slot name for one asset under
  // one prefix, which is the order an auditor reads storage in.
  state balances: map<uint, map<address, uint>>;

  // Transfer is the SINGLE record of every balance movement for every id and there is no
  // second one: a mint is Transfer(id, "", to, amount) and a burn is Transfer(id, from, "",
  // amount). An indexer summing Transfer per id must arrive at exactly the balances in storage.
  event Transfer(id: uint, from: address, to: address, amount: uint);
  event TypeCreated(id: uint, cap: uint, decimals: uint, uri: string);
  event TypeClosed(id: uint, lifetimeIssued: uint);
  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. A SELF-DECLARATION that 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 overlap. A client
  // that needs certainty checks `catalog(address)` against the standard's required surface.
  view fn standard() -> string {
    return "zk13-mt/1";
  }

  view fn name() -> string {
    return catalogueName;
  }

  view fn symbol() -> string {
    return catalogueSymbol;
  }

  // How many ids have been created. Ids are 1..typeCount(), with no gaps and no deletions, so
  // this is also how a client enumerates the catalogue.
  view fn typeCount() -> uint {
    return typeCount;
  }

  view fn exists(id: uint) -> bool {
    return id > 0 && id <= typeCount;
  }

  view fn uri(id: uint) -> string {
    return uriOf[id];
  }

  // 0 means uncapped for this id.
  view fn maxSupply(id: uint) -> uint {
    return capOf[id];
  }

  // Display only. No arithmetic in this contract reads it.
  view fn decimals(id: uint) -> uint {
    return decimalsOf[id];
  }

  view fn totalSupply(id: uint) -> uint {
    return supplyOf[id];
  }

  // Everything ever minted for this id, burned or not — the number the cap is enforced
  // against. A holder comparing the two learns exactly how much of this id has been burned.
  view fn lifetimeIssued(id: uint) -> uint {
    return issuedOf[id];
  }

  // True means this id can never be minted again, by anyone, ever.
  view fn typeClosed(id: uint) -> bool {
    return closedOf[id];
  }

  view fn balanceOf(account: address, id: uint) -> uint {
    return balances[id][account];
  }

  view fn owner() -> address {
    return contractOwner;
  }

  // "" means minting is closed permanently for the WHOLE catalogue.
  view fn minter() -> address {
    return mintAuthority;
  }

  view fn paused() -> bool {
    return frozen;
  }

  view fn pendingOwnerIs() -> address {
    return pendingOwner;
  }

  // ---- the catalogue ----------------------------------------------------------------------

  // Creates one asset and returns its id. Everything an id IS gets decided here, once: after
  // this call no function in this contract can change the cap, the decimals or the URI.
  //
  // `cap` of 0 means uncapped. `cap` of 1 with `decimalsValue` of 0 is how a non-fungible id
  // is declared, and the cap enforces it — `mint` refuses the second unit.
  fn createType(metadataUri: string, cap: uint, decimalsValue: uint) -> uint {
    require(frozen == false, "paused");
    require(len(mintAuthority) > 0, "mint closed");
    require(caller == mintAuthority, "not minter");
    require(len(metadataUri) <= maxUriLength, "uri too long");
    // A display exponent past this is not a token anyone can render; it is a mistake that
    // would be permanent. The seven native ledgers are all 13.
    require(decimalsValue <= 30, "decimals too large");
    let id = typeCount + 1;
    typeCount = id;
    uriOf[id] = metadataUri;
    capOf[id] = cap;
    decimalsOf[id] = decimalsValue;
    emit TypeCreated(id, cap, decimalsValue, metadataUri);
    return id;
  }

  fn mint(id: uint, to: address, amount: uint) -> bool {
    require(frozen == false, "paused");
    require(len(mintAuthority) > 0, "mint closed");
    require(caller == mintAuthority, "not minter");
    require(id > 0 && id <= typeCount, "no such type");
    require(closedOf[id] == false, "type closed");
    // 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");
    // Refuses at the cap rather than truncating: a truncated mint gives the minter less than
    // they asked for and tells nobody. Tested against lifetime issuance, never against the
    // burnable circulating supply.
    if (capOf[id] > 0) {
      require(issuedOf[id] + amount <= capOf[id], "supply cap");
    }
    issuedOf[id] = issuedOf[id] + amount;
    supplyOf[id] = supplyOf[id] + amount;
    balances[id][to] = balances[id][to] + amount;
    emit Transfer(id, "", to, amount);
    return true;
  }

  fn transfer(id: uint, to: address, amount: uint) -> bool {
    require(frozen == false, "paused");
    require(id > 0 && id <= typeCount, "no such type");
    require(len(to) > 0, "empty recipient");
    // Measured on the sibling token template, and the reason is worth keeping in both places:
    // the two-statement debit-then-credit below is ALREADY safe when to == caller, because
    // MSTORE writes through to the working storage and the credit reads the already-debited
    // value. The inflation appears the moment somebody caches both balances in locals before
    // writing either. This guard costs a few gas and makes that future edit harmless.
    require(to != caller, "self transfer");
    // A balance at this contract's own address could never move again: a contract cannot be
    // `caller`. It is the one permanent-loss case an address check can detect.
    require(to != self, "self is not payable");
    require(balances[id][caller] >= amount, "insufficient balance");
    // uint - uint compiles to the CHECKED subtract, which reverts rather than producing a
    // negative that every later comparison would read as "plenty".
    balances[id][caller] = balances[id][caller] - amount;
    balances[id][to] = balances[id][to] + amount;
    // A zero-amount transfer is permitted and still emits: refusing it breaks batch payers
    // that send a list including zeros.
    emit Transfer(id, caller, to, amount);
    return true;
  }

  // The caller burns its OWN balance and no other. With no allowance mechanism there is no
  // authority by which anyone could burn somebody else's, and inventing one is confiscation.
  fn burn(id: uint, amount: uint) -> bool {
    require(frozen == false, "paused");
    require(id > 0 && id <= typeCount, "no such type");
    require(balances[id][caller] >= amount, "insufficient balance");
    balances[id][caller] = balances[id][caller] - amount;
    supplyOf[id] = supplyOf[id] - amount;
    emit Transfer(id, caller, "", amount);
    return true;
  }

  // One-way, for one id. This is what lets an edition PROVE it is complete rather than promise
  // it, without stopping the rest of the catalogue. It does not pause transfers and takes
  // nothing from a holder: a closed type still moves and still burns.
  fn closeType(id: uint) {
    require(caller == contractOwner, "not owner");
    require(id > 0 && id <= typeCount, "no such type");
    require(closedOf[id] == false, "type closed");
    closedOf[id] = true;
    emit TypeClosed(id, issuedOf[id]);
  }

  // ---- 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 = "";
  }

  // GLOBAL. It freezes createType, mint, transfer and burn for every id in the contract, and
  // freezes no view, so a stopped catalogue stays auditable. See the header: if your assets
  // need independent stops, they need independent contracts.
  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, and for the whole catalogue: no new types and no new units of any existing type,
  // ever. 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 = "";
  }
}