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.
ZK13MultiToken for your fork
- Status
- a draft, not deployed nothing here is on any chain; never send value to a draft
- 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
- 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
ZK13MultiToken- Compiler
areslang-1.3.0- Bytecode SHA-256
3e091d78e9888cfb9817831558726bedc225d52696c94cd34629201556f0fa01- Bytecode
- 3,556 bytes
- Source SHA-256
9dbeb93dcc0494e59b3c0516d27a4d3d4432c41d322a6d936fe410f22599481f- The catalogue text?
- Yes, byte for byte: matches the published vector
- Ready to deploy?
- not yet a deployment refuses while these remain: catalogueName = "Example ZK13 Multi-Token", catalogueSymbol = "EZM"
- 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-multi-token > zk13-multi-token.aresand edit the marked constants yourself. - Dry-run it on your machine.
strblk wallet deploy zk13-multi-token.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-multi-token.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 |
|---|---|
| mint | 1,469 |
| createType | 1,276 |
| transfer | 904 |
| burn | 872 |
| closeType | 570 |
- Declare at least
- 14,690 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 | typeCount | uint | view | |
| function | exists | id: uint | bool | view |
| function | uri | id: uint | string | view |
| function | maxSupply | id: uint | uint | view |
| function | decimals | id: uint | uint | view |
| function | totalSupply | id: uint | uint | view |
| function | lifetimeIssued | id: uint | uint | view |
| function | typeClosed | id: uint | bool | view |
| function | balanceOf | account: address, id: uint | uint | view |
| function | owner | address | view | |
| function | minter | address | view | |
| function | paused | bool | view | |
| function | pendingOwnerIs | address | view | |
| function | createType | metadataUri: string, cap: uint, decimalsValue: uint | uint | nonpayable |
| function | mint | id: uint, to: address, amount: uint | bool | nonpayable |
| function | transfer | id: uint, to: address, amount: uint | bool | nonpayable |
| function | burn | id: uint, amount: uint | bool | nonpayable |
| function | closeType | id: uint | nonpayable | |
| function | proposeOwner | next: address | nonpayable | |
| function | acceptOwnership | nonpayable | ||
| function | setPaused | next: bool | nonpayable | |
| function | setMinter | next: address | nonpayable | |
| function | closeMinting | nonpayable | ||
| 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 |
Your draft's source
templates/zk13-multi-token.ares, as shipped
// =========================================================================================
// 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 = "";
}
}