// ========================================================================================= // 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; state capOf: map; state decimalsOf: map; // Circulating, which `burn` lowers. state supplyOf: map; // 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; // One-way per id. state closedOf: map; // 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>; // 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 = ""; } }