// ========================================================================================= // AllowlistToken — the ZK13 token standard's optional gated tier, and exactly what it is. // // WHAT IT IS FOR // A token that may only be held by addresses an issuer has put on a list. Securities that // may only be held by accredited investors, vouchers redeemable by named parties, an // internal accounting unit that must not leave a known set of desks. This is the // implementation of docs/ZK13_TOKEN_STANDARD.md §3.5 — the optional domain-gated tier — and // the first thing this header has to do is correct that section's name. // // IT IS AN ISSUER'S LIST. IT IS NOT A DOMAIN PROOF, AND CANNOT BE MADE INTO ONE // `AresHost` exposes selfAddress, blockTimestampMs, blockHeight, balanceOf and transfer, and // no domain accessor of any kind. A contract CANNOT ask whether an address holds a proved // STR.domain, so "domain-gated" is not writable today and nothing in this file attempts it. // What this contract enforces is that an address appears in a map that one named key writes. // That is a real and useful property — it is what a transfer-restricted security needs — but // it verifies the ISSUER'S CLAIM about an address and nothing about the address itself. An // issuer who puts every address on the list has an unrestricted token with extra gas. // // THE POWER THIS TOKEN DELIBERATELY HAS, DECLARED AS §6.5 REQUIRES // **Removing an address from the list freezes it.** A removed holder can no longer send and // can no longer receive; their balance stays theirs, readable in `balanceOf`, and moves again // if they are re-listed. This is a SELECTIVE FREEZE — the one power the base standard forbids // unless it is declared — and it is declared here, in the ABI (`setAllowed`, `isAllowed`) and // in this header, because a token whose freeze is a surprise is a trap. If you do not need // it, deploy `zk13-token.ares` or `fixed-supply-token.ares` instead; do not deploy this and // leave the list open. // // What the compliance key CANNOT do, and there is no function for any of it: move a holder's // balance, burn a holder's balance, mint to itself without also listing itself (which is // visible), or change the supply cap. // // WHAT IT GUARANTEES // * Every credit and every debit of a balance passes the list. `mint` requires the recipient // listed; `transfer` requires BOTH parties listed. There is no path that moves a balance to // or from an unlisted address. // * `burn` is NOT gated by the list, deliberately. A holder may always destroy their own // tokens: blocking it takes nothing from anyone else and leaves a frozen holder with no // action at all, which is closer to confiscation than to compliance. // * The list is written by ONE named key, the compliance officer, which the owner appoints // and which is visible as `complianceOfficer()`. The owner may appoint itself; that is // what the ABI says and a holder should read it. // * `allowedCount()` is exact. It moves only when a flag actually changes, so listing an // already-listed address does not inflate it. // * The supply rules are the base standard's, unchanged: the cap bounds LIFETIME ISSUANCE // (`lifetimeIssued()`), minting closes permanently with `closeMinting()`, and no function // raises the cap. // * A revert leaves the ledger and the list byte-identical. Storage commits only on success. // // HOW IT IS TYPICALLY MISUSED // * Marketing the list as identity, KYC or accreditation verified on chain. Nothing is // verified on chain. The issuer asserts; the contract records the assertion. // * Forgetting to list the issuer's own address before `mint`. The mint reverts with // `not allowed`, which is correct and surprises everybody the first time. // * Removing an address to punish it and expecting the balance to come back. It does not. // Removal freezes; nothing in this contract transfers a frozen balance anywhere, and no // amendment to a deployed contract is possible — the bytecode is in the address preimage. // * Losing the compliance key. Nobody can then be listed or unlisted again; the owner can // appoint a new officer, and if the OWNER key is also lost the list is frozen as it stands, // forever. Hold both keys somewhere you will still have them in five years. // * 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: setAllowed 775 on a // real change and 190 when the flag is already what was asked for, mint 1,209, // transfer 965, burn 774, setCompliance 412, proposeOwner 412, acceptOwnership 612, // setPaused 363, setMinter 471, closeMinting 470. Declare a gas limit of at least 13,000. // // TO MAKE THIS YOUR TOKEN // Edit the four constants below and nothing else. The deployer is owner, minter AND // compliance officer, so the sequence after deployment is `setAllowed(holder, true)` and then // `mint(holder, amount)`. // ========================================================================================= contract AllowlistToken { // ---- edit these four ---------------------------------------------------------------- state tokenName: string = "Example Allowlist Token"; state tokenSymbol: string = "EAL"; // 13, like all seven native ledgers. A wallet showing two assets at different precision is // where somebody sends 10^5 times what they meant to. state tokenDecimals: uint = 13; // A ceiling on LIFETIME ISSUANCE, not on circulating supply. 0 means uncapped, which is what // this ships as — a template cannot invent a cap for a token it knows nothing about, and a // plausible-looking placeholder reads as a decision somebody made. state supplyCap: uint = 0; // ------------------------------------------------------------------------------------- state supply: uint = 0; // Everything `mint` has ever created, and it never goes down. This is what `supplyCap` // bounds; `supply` cannot be, because `burn` lowers it and mint-burn-mint would then issue // without limit against a cap that never appears to be breached. state issuedEver: uint = 0; // `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 on any of // these three roles. state contractOwner: address = caller; state pendingOwner: address = ""; state mintAuthority: address = caller; state complianceKey: address = caller; state frozen: bool = false; // The list. An address absent from it reads as `false`, which is the safe default: a map // entry nobody wrote is the zero of its declared type, so a new address is unlisted rather // than accidentally permitted. state allowed: map; state allowedTotal: uint = 0; state balances: map; // Transfer is the SINGLE record of every balance movement and there is no second one: a mint // is Transfer("", to, amount) and a burn is Transfer(from, "", amount). event Transfer(from: address, to: address, amount: uint); // Every change to the list, with the key that made it. This is the audit trail a restricted // token is usually required to be able to produce. event AllowedChanged(account: address, allow: bool, by: address); event ComplianceChanged(previous: address, next: address); 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, and the profile of it. The `+allowlist` // suffix is how a wallet learns, in one cheap read, that this token has a transfer // restriction — before it quotes a price to somebody who cannot receive. A self-declaration // that proves nothing; the check is `catalog(address)` and `isAllowed`. view fn standard() -> string { return "zk13-fungible/1+allowlist"; } view fn name() -> string { return tokenName; } view fn symbol() -> string { return tokenSymbol; } view fn decimals() -> uint { return tokenDecimals; } view fn totalSupply() -> uint { return supply; } view fn maxSupply() -> uint { return supplyCap; } view fn lifetimeIssued() -> uint { return issuedEver; } view fn balanceOf(account: address) -> uint { return balances[account]; } // The whole restriction, in one view a buyer can call before they accept a payment. False // means this address can neither send nor receive — including an address that already holds // a balance. view fn isAllowed(account: address) -> bool { return allowed[account]; } view fn allowedCount() -> uint { return allowedTotal; } view fn complianceOfficer() -> address { return complianceKey; } view fn owner() -> address { return contractOwner; } // "" means minting is closed permanently. view fn minter() -> address { return mintAuthority; } view fn paused() -> bool { return frozen; } view fn pendingOwnerIs() -> address { return pendingOwner; } // ---- the list --------------------------------------------------------------------------- // Lists or unlists one address. Unlisting is the declared selective freeze; see the header. fn setAllowed(account: address, allow: bool) { require(frozen == false, "paused"); require(caller == complianceKey, "not allowed"); require(len(account) > 0, "empty recipient"); // The count moves only on a real change, which is what makes `allowedCount()` exact rather // than "the number of times somebody called this". if (allowed[account] != allow) { allowed[account] = allow; if (allow) { allowedTotal = allowedTotal + 1; } else { allowedTotal = allowedTotal - 1; } emit AllowedChanged(account, allow, caller); } } // ---- movement --------------------------------------------------------------------------- fn transfer(to: address, amount: uint) -> bool { require(frozen == false, "paused"); // Unreachable through a call record — `validateArgs` checksum-validates every `address` // argument before the first opcode — and kept because it is the standard's rule. require(len(to) > 0, "empty recipient"); // BOTH sides. Checking only the recipient would let a de-listed holder empty their position // into a listed one, which is the restriction defeated in a single call. require(allowed[caller], "not allowed"); require(allowed[to], "not allowed"); require(to != caller, "self transfer"); // A balance at this contract's own address could never move again: a contract cannot be // `caller`. The list would not stop it either, since the owner could list this address. require(to != self, "self is not payable"); require(balances[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[caller] = balances[caller] - amount; balances[to] = balances[to] + amount; emit Transfer(caller, to, amount); return true; } // Not gated by the list, deliberately — see the header. The caller burns its OWN balance and // no other; there is no authority here by which anyone could burn somebody else's. fn burn(amount: uint) -> bool { require(frozen == false, "paused"); require(balances[caller] >= amount, "insufficient balance"); balances[caller] = balances[caller] - amount; supply = supply - amount; emit Transfer(caller, "", amount); return true; } fn mint(to: address, amount: uint) -> bool { require(frozen == false, "paused"); require(len(mintAuthority) > 0, "mint closed"); require(caller == mintAuthority, "not minter"); 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"); // A mint to an unlisted address would create a balance that cannot move, which is the // restriction producing a stranded position rather than enforcing anything. require(allowed[to], "not allowed"); // Refuses at the cap rather than truncating, and the cap is tested against LIFETIME // issuance and never against `supply`, which `burn` can lower. if (supplyCap > 0) { require(issuedEver + amount <= supplyCap, "supply cap"); } issuedEver = issuedEver + amount; supply = supply + amount; balances[to] = balances[to] + amount; emit Transfer("", to, amount); return true; } // ---- powers the deployer keeps, all of them visible in the ABI -------------------------- fn setCompliance(next: address) { require(caller == contractOwner, "not owner"); emit ComplianceChanged(complianceKey, next); complianceKey = next; } 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 = ""; } // Covers mint, transfer, burn and setAllowed — every write in the contract — and no view, so // a stopped token stays auditable at the moment somebody needs to audit it. 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. Lets the token PROVE a fixed supply rather than promise one. It does not close the // LIST — a restricted token still needs its restriction after issuance ends. fn closeMinting() { require(caller == contractOwner, "not owner"); require(len(mintAuthority) > 0, "mint closed"); emit MinterChanged(mintAuthority, ""); mintAuthority = ""; } }