// ========================================================================================= // WrappedToken — a bridge-custodied 1:1 wrapper, as a ZK13 fungible token. // // WHAT IT IS FOR // A fungible asset minted 1:1 by a CUSTODIAN — the bridge authority — when an underlying // asset is locked on another chain, and burned by a holder to request that the underlying be // released back to them there. It is the `+wrapped` profile of docs/ZK13_TOKEN_STANDARD.md: // every view, every mutating function, every event and every `require` message the base // standard spells is here, spelled exactly, with two additions and no subtractions — // `underlying()` names the bridged asset and `unwrap(amount, ref)` carries the withdrawal // destination `ref` out to the relayer. This is what docs/BRIDGE_INTEGRATION.md §5 means by a // crossed bridge asset becoming an AresLang/ZK13 token contract. // // The CUSTODIAN IS THE MINT AUTHORITY. `minter()` is unchanged from the base standard and is // the authority a holder reads; `custodian()` is an alias for the same address, named for what // it does here — mint happens only when the underlying is locked, so the party that can mint is // the party that holds the lock. // // WHAT IT GUARANTEES // * Supply moves only through `mint`, `burn` and `unwrap`. `transfer` moves it by exactly zero. // `unwrap` burns exactly like `burn` — it is a burn that also announces where the underlying // should go — so an indexer summing `Transfer` still reconstructs every balance. // * `maxSupply` bounds LIFETIME ISSUANCE — `lifetimeIssued()`, which only ever goes up — and // not the circulating `totalSupply`, which `burn` and `unwrap` lower. `maxSupply() == 0` // means UNCAPPED, which is what this template ships as: a wrapper's cap is the amount of the // underlying that can ever be locked, which this contract cannot know and must not guess. // * Minting closes permanently: `closeMinting` is one-way and `setMinter` refuses to reopen it. // For a wrapper this is the custody being wound down — no more of the underlying will be // accepted — and it is a promise the code keeps rather than a claim. // * The owner can pause and can hand over ownership in two steps. The owner CANNOT move // anybody else's balance: there is no `setBalance`, no `adjust`, no `transferFrom`. // * A revert leaves the ledger byte-identical. Storage commits only on success. // * A self-transfer is refused, so the classic debit-then-credit inflation bug cannot be // reached at all. // // HOW THE UNWRAP PATH IS READ, AND WHAT IT IS NOT // `unwrap(amount, ref)` burns the caller's own balance and emits `Unwrapped(caller, amount, // ref)` alongside the standard `Transfer(caller, "", amount)`. `ref` is an opaque string the // relayer interprets as the destination on the underlying chain — an address, a memo, an // invoice id. THE CONTRACT DOES NOT AND CANNOT VALIDATE IT: there is no cross-chain oracle in // AresLang, and a contract cannot see the other chain. A malformed `ref` burns the holder's // wrapped balance and produces a withdrawal the relayer cannot route. The event is EVIDENCE for // an off-chain relayer and nothing on this chain reads it back (§4.3): the release of the // underlying is the bridge operator's action, performed off this chain, and this contract's // only role in it is to have destroyed the wrapped units first. // // HOW IT IS TYPICALLY MISUSED // * Believing the wrapped balance is the underlying. It is a claim on a custodian's promise to // have locked the underlying, no stronger than that custodian. This contract proves that the // wrapped supply was minted by the mint authority; it proves nothing about what backs it. Do // not assert a peg (§7.4) — no mechanism here enforces one. // * Calling `burn` when you meant `unwrap`. `burn` destroys wrapped units and announces NO // destination, so the relayer has nothing to act on and the underlying is not released. Use // `unwrap` to withdraw; `burn` is for destroying units you do not want back. // * Treating "holds a balance" as "is a verified user". Receiving a ZK13 token is not gated by // STR.domain and cannot be — there is no domain accessor in AresLang. // * 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 token has no recovery path and // cannot be given one. The one case it can detect — its own address — it refuses. // * 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: mint 1,144, // transfer 835, burn 774, unwrap 879, proposeOwner 412, acceptOwnership 612, setPaused 363, // setMinter 471, closeMinting 470. `unwrap`'s cost does not move with the length of `ref` — // the event is not stored, so a 1-character ref and a 200-character one both cost 879. // Declare a gas limit of at least 12,000. // // WHAT IT DELIBERATELY DOES NOT HAVE // No `approve` and no `transferFrom`: an allowance exists so another CONTRACT can spend on a // holder's behalf, and AresLang has no cross-contract calls. See ZK13_TOKEN_STANDARD.md §4.2. // No upgrade path and no proxy. What you deploy is what holders get, forever. // // TO MAKE THIS YOUR TOKEN // Edit the five constants below and nothing else. Changing them changes the bytecode and // therefore the deployed address, which is correct: a different token is a different contract. // `underlyingAsset` ships as "unset" and should name the bridged asset — a contract address, // a chain-qualified symbol, whatever the relayer and your holders will recognise. Supply // starts at zero; the deployer is owner and custodian, so the first call after deployment is a // `mint(holder, amount)` made when the matching lock lands on the underlying chain. // ========================================================================================= contract WrappedToken { // ---- edit these five ---------------------------------------------------------------- state tokenName: string = "Example Wrapped Token"; state tokenSymbol: string = "wEXA"; // 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. A wrapper is a candidate to differ, so // the underlying can be matched unit for unit — but if you change it, say so in your own // documentation and make sure the relayer locks and releases at the same precision. state tokenDecimals: uint = 13; // A ceiling on LIFETIME ISSUANCE, not on circulating supply — see the header. 0 means // uncapped, and must be declared as 0 rather than as a very large number that looks like a // cap. SHIPPED AT 0: a wrapper cannot invent a cap, because the real bound is how much of the // underlying can ever be locked, which lives on the other chain and not in this contract. If // the custody is itself capped, set this to the real number before you deploy. state supplyCap: uint = 0; // The asset this token wraps, as the relayer and your holders will recognise it. Editable, and // shipped as "unset" rather than as a plausible placeholder that would read as a real binding. // It is a LABEL: nothing in this contract verifies that the named asset exists or that a lock // of it stands behind any wrapped unit. `underlying()` reads it back. state underlyingAsset: string = "unset"; // ------------------------------------------------------------------------------------- state supply: uint = 0; // Everything `mint` has ever created, and it never goes down. This is what `supplyCap` bounds; // `supply` cannot be, because `burn` and `unwrap` lower it, and mint -> unwrap -> 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. That is why this token 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; 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 and an unwrap are both Transfer(from, "", amount). // An indexer summing Transfer must arrive at exactly the balances in storage. event Transfer(from: address, to: address, amount: uint); // Emitted by `unwrap` ALONGSIDE the Transfer burn, never instead of it. It carries the // withdrawal destination `ref` on the underlying chain, which the relayer reads to release the // locked asset. It is evidence, not state: nothing on this chain reads it back. event Unwrapped(from: address, amount: uint, ref: 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, and the profile of it. The `+wrapped` // suffix is how a wallet learns, in one cheap read, that this token is a bridge wrapper with an // `unwrap` withdrawal path. A self-declaration that proves nothing; the check is // `catalog(address)` against the standard's required surface. view fn standard() -> string { return "zk13-fungible/1+wrapped"; } 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; } // Everything ever minted, burned or not. `totalSupply` answers "how much exists"; this answers // "how much was ever issued", and it is the one the cap is enforced against. view fn lifetimeIssued() -> uint { return issuedEver; } view fn balanceOf(account: address) -> uint { return balances[account]; } // The asset this token wraps, exactly as the deployer labelled it. A LABEL and not a proof: // nothing here verifies the named asset or that a lock backs any wrapped unit. view fn underlying() -> string { return underlyingAsset; } view fn owner() -> address { return contractOwner; } // "" means minting is closed permanently. Required even though this token does mint: a holder // must be able to ask "who can mint this?" and get an answer from the ABI. view fn minter() -> address { return mintAuthority; } // The custodian is the mint authority: an alias for `minter()`, named for what the address does // in a bridge wrapper. It returns the same value as `minter()`, including "" once minting is // closed. `minter()` stays the standard's name; this is the wrapper's name for the same party. view fn custodian() -> address { return mintAuthority; } // Required even though a token might never pause. A missing function is indistinguishable, to a // wallet, from a token whose answer is inconvenient. view fn paused() -> bool { return frozen; } view fn pendingOwnerIs() -> address { return pendingOwner; } // ---- movement ------------------------------------------------------------------------- fn transfer(to: address, amount: uint) -> bool { require(frozen == false, "paused"); // Unreachable through a call record — the ABI check refuses a malformed `address` argument // before the first opcode runs — and kept because it is the standard's rule. require(len(to) > 0, "empty recipient"); // The guard costs 8 gas and makes a future caching edit harmless; see zk13-token.ares §6.6. require(to != caller, "self transfer"); // A balance recorded at a contract address can never move, per standard section 7.3. This is // the one case of it the token can cheaply refuse outright. 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; // A zero-amount transfer is permitted and still emits: refusing it breaks batch payers that // send a list including zeros. emit Transfer(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. // This destroys wrapped units and announces NO destination: use `unwrap` to withdraw the // underlying, and `burn` only to destroy units you do not want back. 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; } // Burns the caller's own balance exactly as `burn` does, and ALSO emits `Unwrapped` so the // bridge relayer learns where to release the underlying. The burn and the announcement are one // atomic call: there is no state in which the wrapped units are destroyed and the withdrawal is // unannounced, or announced and the units still live. `ref` is opaque to this contract — the // destination on the underlying chain — and is not validated, because a contract cannot see the // other chain. A malformed `ref` still burns the balance; the relayer, not this code, decides // what it can route. fn unwrap(amount: uint, ref: string) -> bool { require(frozen == false, "paused"); require(balances[caller] >= amount, "insufficient balance"); balances[caller] = balances[caller] - amount; supply = supply - amount; // The standard's single movement record, identical to a plain burn, so an indexer summing // Transfer still reconstructs the ledger without knowing unwrap from burn. emit Transfer(caller, "", amount); // The withdrawal instruction, for the relayer only. emit Unwrapped(caller, amount, ref); 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. require(to != self, "self is not payable"); // Refuses at the cap rather than truncating. The cap is tested against LIFETIME ISSUANCE and // never against `supply`, which `burn` and `unwrap` 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 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"); // Minting, once closed, stays closed. A fixed supply that can be un-fixed is not one. require(len(mintAuthority) > 0, "mint closed"); emit MinterChanged(mintAuthority, next); mintAuthority = next; } // One-way. For a wrapper this winds the custody down: no more of the underlying will be // accepted. 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 = ""; } }