// ========================================================================================= // ZK13Vault — the reference share-accounted vault for SourceLess. // // WHAT IT IS FOR // A pool of one asset, owned pro rata by holders of a SHARE. You deposit assets and receive // shares; the pool grows when anyone funds yield into it; you redeem shares for assets at // whatever the pool is worth then. This is the reference implementation of // docs/ZK13_VAULT_STANDARD.md — the ERC-4626 shape, written for a chain that has no // cross-contract calls. // // WHY THE ASSET AND THE VAULT ARE ONE CONTRACT, AND NOT TWO // Because they have to be. ERC-4626 is a wrapper: the vault holds a BALANCE IN ANOTHER // CONTRACT and calls `transferFrom` on it. AresLang has no cross-contract calls, so a // separate vault could never move a holder's token balance — and a balance recorded at a // vault contract's address could never move again at all, because a contract cannot be // `caller`. A vault over a ZK13 token is therefore only writable INSIDE the token contract. // This is that: the asset ledger and the share ledger are two maps in one contract. // // The same reasoning is why this does not vault native STR. A depositor would have to send // STR with a plain transfer, and THE CONTRACT CANNOT SEE WHO PAID IT — a call record carries // no value and there is no deposit hook. Every per-depositor native pattern is unwritable // for that one reason, and no amount of contract code fixes it. // // THE TWO LEDGERS, AND WHICH VIEW ANSWERS FOR WHICH // asset — `balanceOf`, `totalSupply`, `transfer`, `mint`, `burn`, `decimals`. The ZK13 // fungible token this contract issues. Deposited assets LEAVE the depositor's // balance and are held in the pool, which is `totalAssets()` and NOT a balance // entry: the invariant is // totalSupply() == (the sum of every balanceOf) + totalAssets() // and there is no address at which the pooled assets appear. // share — `shareBalanceOf`, `totalShares`, `transferShares`, `shareDecimals`. A claim on // the pool. Shares are transferable, so a share is a bearer claim: whoever holds // it can redeem it. // // WHAT IT GUARANTEES // * Every rounding goes to the pool, never to the caller. `deposit` and `redeem` round DOWN // what the caller gets; `withdraw` rounds UP the shares it burns and `mintShares` rounds UP // the assets it takes. A caller can therefore lose at most one minor unit to rounding per // call and can never gain one, which is the property that keeps a vault from being drained a // unit at a time by a loop. // * Redeeming is NOT gated by the pause, deliberately, and neither is withdrawing. The owner // can stop deposits, funding, asset transfers and minting; the one thing the owner cannot // do is stand between a holder and their own share of the pool. // * A share can only be created by `deposit` or `mintShares`, both of which take assets for // it, and only destroyed by `redeem`/`withdraw`. No function gives a share to anybody, // including the owner. There is no `setShares`. // * The pool can only grow by `deposit`, `mintShares` and `fundYield`, and can only shrink by // `redeem` and `withdraw`. The owner has no path to the pooled assets at all — no fee, no // skim, no sweep. Read the ABI: nothing moves `vaultAssets` anywhere but to a redeemer. // * `fundYield` refuses while no shares are outstanding, so a donation cannot land where // nobody can ever claim it. // * A revert leaves both ledgers byte-identical. Storage commits only on success. // // THE INFLATION ATTACK, AND WHAT THE OFFSET ACTUALLY BUYS // The classic attack on a share vault: be the first depositor with 1 unit, donate a large // amount straight into the pool, and every later depositor's share count rounds down to // zero or near it — their assets become yours. The defence here is the virtual offset: // pricing runs against `totalShares + virtualShares` over `totalAssets + 1`, so the pool // behaves as if it already held ten million phantom shares against one phantom unit. // // MEASURED on this contract. The attacker deposits 1 unit (10,000,000 shares), donates 1,000 // into the pool, and the victim then deposits 1,000 and receives 19,960,079 shares. Pool // 2,001 assets against 29,960,079 real shares. `maxWithdraw` afterwards: victim 999 against // the 1,000 deposited, attacker 501 against the 1,001 they put in. **The attack costs the // attacker 500 to deprive the victim of 1.** The missing 501 is not the attacker's either — // it backs the phantom shares, and no holder present at that moment can withdraw it. // // THE PRICE OF THE DEFENCE, MEASURED RATHER THAN GUESSED AT. The phantom shares claim a // fraction of every increase in the pool, and only of the increase: // * NO YIELD FUNDED — a full exit returns the whole pool. Deposit 1,000, redeem every // share, receive 1,000, `totalAssets()` 0. The offset cancels exactly, because it sits // on both sides of the ratio. // * YIELD FUNDED — the phantom shares keep `virtualShares / (totalShares + virtualShares)` // of it. Deposit 1,000, fund 500 of yield, redeem every share: receive 1,499 and ONE // UNIT stays in a pool with no shares outstanding. That is the whole of the leakage at // this offset — a thousandth of the yield, not of the principal. // * DONATION-INFLATED — whatever the attack above bought. 501 of 2,001 in that run, which // is the attacker's money and is exactly why the attack does not pay. // The residue is not destroyed: it prices into the pool for whoever deposits next. What it // is not is claimable by the holders who were there when it appeared. If your accounting // needs the pool to reach exactly zero after any yield, this is not the contract for it. // // The claim above is a measurement of this code, not a proof: it says the cheap version of // this attack is self-defeating here, not that no pricing attack exists. An attacker willing // to donate ten million times a victim's deposit can still round that victim down, and would // be paying ten million times the victim's deposit to do it. // // HOW IT IS TYPICALLY MISUSED // * Reading a share count as an asset count. They are different units at different scales — // `shareDecimals()` is `decimals()` plus seven, because the opening price is ten million // shares to one asset unit. Quote a position with `convertToAssets`, never with the raw // share number. // * Expecting yield. Nothing in this contract generates any. `fundYield` is the ONLY way the // pool grows other than deposits, and it is a voluntary payment by whoever calls it. A // vault whose yield nobody funds is a vault that pays exactly what was put in, minus // rounding. Do not deploy this and call it an interest-bearing account. // * Sending assets or shares to a contract address. A contract cannot be `caller`, so either // is a PERMANENT LOSS. This template refuses the one case an address check can detect — // its own address — and cannot detect any other. // * Forgetting that shares are transferable. Whoever holds a share can redeem it; there is // no lock, no vesting and no recovery. If you need a non-transferable claim, remove // `transferShares` before you deploy — the address changes with it, which is correct. // * 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: deposit 1,484, // mintShares 1,582, withdraw 1,531, redeem 1,488, fundYield 836, transferShares 835, // mint 1,144, transfer 835, burn 774, proposeOwner 412, acceptOwnership 612, // setPaused 363, setMinter 471, closeMinting 470. Declare a gas limit of at least 16,000. // // TO MAKE THIS YOUR VAULT // Edit the constants below and nothing else. Supply starts at zero and the deployer is owner // and minter, so the sequence after deployment is `mint(holder, amount)` and then the holder // calls `deposit(amount)`. // ========================================================================================= contract ZK13Vault { // ---- edit these ---------------------------------------------------------------------- state tokenName: string = "Example ZK13 Vault Asset"; state tokenSymbol: string = "EZV"; // 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 of the ASSET, not on circulating supply, and not on the // pool. 0 means uncapped, which is what this ships as. state supplyCap: uint = 0; // ------------------------------------------------------------------------------------- // THESE TWO MUST AGREE: virtualShares is ten to the power of shareOffsetDigits. AresLang has // no exponentiation, so the value and its exponent are two constants, and nothing in the // machine checks that they match. If you change one, change the other — the first is what // the pricing uses and the second is only ever reported to a wallet, so a mismatch does not // break the arithmetic. It misprices every display of every position, which is worse, // because it is invisible from inside the contract. state virtualShares: uint = 10000000; state shareOffsetDigits: uint = 7; state supply: uint = 0; // Everything `mint` has ever created, and it never goes down. This — not `supply`, which // `burn` lowers — is what `supplyCap` bounds: a cap tested against a burnable number is // defeated by mint, burn, mint, which issues 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. state contractOwner: address = caller; state pendingOwner: address = ""; state mintAuthority: address = caller; state frozen: bool = false; // The pool. Not a balance entry at any address — see the header's invariant. state vaultAssets: uint = 0; state shareSupply: uint = 0; state balances: map
; state shares: map; // Transfer is the single record of every ASSET movement between addresses: a mint is // Transfer("", to, amount) and a burn is Transfer(from, "", amount). Movements into and out // of the POOL are Deposit and Withdraw instead, because they are not movements between two // addresses and an indexer summing Transfer must not see them twice. event Transfer(from: address, to: address, amount: uint); event ShareTransfer(from: address, to: address, amount: uint); event Deposit(owner: address, assets: uint, sharesMinted: uint); event Withdraw(owner: address, assets: uint, sharesBurned: uint); event YieldFunded(from: address, assets: 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); // ---- asset views: the ZK13 fungible interface, answering for the asset ledger ---------- // Which standards this contract was written against — both of them, because it is a // conforming ZK13 fungible token AND a conforming vault over itself. A SELF-DECLARATION that // proves nothing; a client that needs certainty checks `catalog(address)` against each // standard's required surface. view fn standard() -> string { return "zk13-vault/1+zk13-fungible/1"; } view fn name() -> string { return tokenName; } view fn symbol() -> string { return tokenSymbol; } view fn decimals() -> uint { return tokenDecimals; } // Every asset unit in existence, INCLUDING the ones held in the pool. The pool is not an // address, so `balanceOf` cannot see them; `totalAssets()` is where they are. 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]; } // ---- vault views ------------------------------------------------------------------------ // What the pool holds. The denominator of every price in this contract. view fn totalAssets() -> uint { return vaultAssets; } view fn totalShares() -> uint { return shareSupply; } view fn shareBalanceOf(account: address) -> uint { return shares[account]; } // Asset decimals plus the offset, because one asset unit opens at ten million shares. A // wallet that renders a share count with `decimals()` is out by seven orders of magnitude. view fn shareDecimals() -> uint { return tokenDecimals + shareOffsetDigits; } // The quotes. Every one of them is the same arithmetic the mutating function performs, // so a caller can price a move before making it — and they are `view`, so the compiler has // refused them the ability to write anything, which is why it is safe to call one from a // wallet against live state. // // They quote the CURRENT block's state. Between the quote and the record landing, anyone may // deposit, redeem or fund yield, and the price moves. Treat a quote as an estimate and the // return value of the mutating call as the fact. view fn convertToShares(assets: uint) -> uint { return assets * (shareSupply + virtualShares) / (vaultAssets + 1); } view fn convertToAssets(shareAmount: uint) -> uint { return shareAmount * (vaultAssets + 1) / (shareSupply + virtualShares); } view fn previewDeposit(assets: uint) -> uint { return assets * (shareSupply + virtualShares) / (vaultAssets + 1); } view fn previewRedeem(shareAmount: uint) -> uint { return shareAmount * (vaultAssets + 1) / (shareSupply + virtualShares); } // What an exact number of shares COSTS, rounded UP. The counterpart of `previewWithdraw` on // the way in: the caller pays the remainder of the division rather than the pool absorbing it. view fn previewMintShares(shareAmount: uint) -> uint { return (shareAmount * (vaultAssets + 1) + shareSupply + virtualShares - 1) / (shareSupply + virtualShares); } // Rounds UP: taking an exact number of assets out costs at least the shares that many assets // are worth, and the remainder of the division stays with the pool rather than with the // caller. `(x + d - 1) / d` with `d = vaultAssets + 1`, which is why the numerator adds // `vaultAssets` and not `vaultAssets + 1`. view fn previewWithdraw(assets: uint) -> uint { return (assets * (shareSupply + virtualShares) + vaultAssets) / (vaultAssets + 1); } view fn maxRedeem(account: address) -> uint { return shares[account]; } view fn maxWithdraw(account: address) -> uint { return shares[account] * (vaultAssets + 1) / (shareSupply + virtualShares); } 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 vault --------------------------------------------------------------------------- fn deposit(assets: uint) -> uint { require(frozen == false, "paused"); require(assets > 0, "amount must be positive"); require(balances[caller] >= assets, "insufficient balance"); // Priced BEFORE any state changes, off the pool as it stands. Computing the price after // crediting the pool is the classic vault bug: the depositor is priced against their own // deposit and gets fewer shares than the quote said. let minted = assets * (shareSupply + virtualShares) / (vaultAssets + 1); // A deposit that buys nothing is a donation the caller did not intend. Refuse it rather // than take the assets — this is the line that makes a rounded-to-zero deposit a revert // instead of a loss. require(minted > 0, "no shares"); balances[caller] = balances[caller] - assets; vaultAssets = vaultAssets + assets; shares[caller] = shares[caller] + minted; shareSupply = shareSupply + minted; emit Deposit(caller, assets, minted); return minted; } // Assets in for an EXACT number of shares — the other direction of `deposit`, for a caller who // needs a share count rather than an asset amount. It is a separate function rather than an // argument to `deposit` because the rounding goes the other way: the assets it takes are // rounded UP, so the pool never sells a share for less than the share is worth. // // Named `mintShares` and not `mint`, because `mint` on this contract is the ASSET's minter // function and the two are not remotely the same power: one issues the underlying token by // authority, the other buys a claim on the pool with tokens the caller already holds. fn mintShares(shareAmount: uint) -> uint { require(frozen == false, "paused"); require(shareAmount > 0, "amount must be positive"); let cost = (shareAmount * (vaultAssets + 1) + shareSupply + virtualShares - 1) / (shareSupply + virtualShares); require(balances[caller] >= cost, "insufficient balance"); balances[caller] = balances[caller] - cost; vaultAssets = vaultAssets + cost; shares[caller] = shares[caller] + shareAmount; shareSupply = shareSupply + shareAmount; // The same event as `deposit`, because it is the same movement: assets into the pool, // shares to the caller. An indexer must not have to know which entry point was used. emit Deposit(caller, cost, shareAmount); return cost; } // Shares in, assets out. NOT gated by the pause: see the header — the owner does not stand // between a holder and their own share of the pool. fn redeem(shareAmount: uint) -> uint { require(shareAmount > 0, "amount must be positive"); require(shares[caller] >= shareAmount, "insufficient shares"); // Rounds down, so the remainder of the division stays in the pool and is shared by // everyone still in it. let paid = shareAmount * (vaultAssets + 1) / (shareSupply + virtualShares); // The mirror of `no shares` in `deposit`, and just as load-bearing: without it, redeeming // a small enough parcel of shares destroys them and pays nothing, which is a loss the // caller asked for by accident. It also means a holder cannot exit an EMPTY pool — there // is nothing there to pay them, and burning the shares would not change that. require(paid > 0, "no assets"); // Unreachable as the arithmetic stands: `shareAmount` is at most `shareSupply`, and // `shareSupply * (vaultAssets + 1) / (shareSupply + virtualShares)` is strictly below // `vaultAssets + 1` whenever `virtualShares` is positive. It is kept because it is the // floor under the pool, and because anyone editing the formula above will want the revert // rather than a `SUBU` underflow with no name on it. require(paid <= vaultAssets, "vault short"); shares[caller] = shares[caller] - shareAmount; shareSupply = shareSupply - shareAmount; vaultAssets = vaultAssets - paid; balances[caller] = balances[caller] + paid; emit Withdraw(caller, paid, shareAmount); return paid; } // Assets out, shares in — the other direction of the same move, for a caller who needs an // exact number of assets rather than an exact number of shares. Also not pause-gated. fn withdraw(assets: uint) -> uint { require(assets > 0, "amount must be positive"); require(assets <= vaultAssets, "vault short"); // Rounds UP. The caller pays the remainder of the division rather than the pool paying it, // which is the only direction that cannot be looped into a drain. let burned = (assets * (shareSupply + virtualShares) + vaultAssets) / (vaultAssets + 1); require(shares[caller] >= burned, "insufficient shares"); shares[caller] = shares[caller] - burned; shareSupply = shareSupply - burned; vaultAssets = vaultAssets - assets; balances[caller] = balances[caller] + assets; emit Withdraw(caller, assets, burned); return burned; } // Anyone may pay assets into the pool for the benefit of every share outstanding. This is // the ONLY way the pool grows other than deposits, and nothing in this contract obliges // anybody to call it. fn fundYield(amount: uint) -> bool { require(frozen == false, "paused"); require(amount > 0, "amount must be positive"); // Into an empty vault this would be a gift to whoever deposits next, priced against a pool // that already holds it — and with no shares outstanding, nobody can ever claim it back. // Refuse rather than accept assets that cannot be attributed. require(shareSupply > 0, "no shares outstanding"); require(balances[caller] >= amount, "insufficient balance"); balances[caller] = balances[caller] - amount; vaultAssets = vaultAssets + amount; emit YieldFunded(caller, amount); return true; } // A share is a bearer claim on the pool and moves like one. Gated by the pause, unlike // redeeming: stopping the market in shares is not the same as standing between a holder and // the pool, and the holder can still exit through `redeem` while stopped. fn transferShares(to: address, amount: uint) -> bool { require(frozen == false, "paused"); require(len(to) > 0, "empty recipient"); require(to != caller, "self transfer"); require(to != self, "self is not payable"); require(shares[caller] >= amount, "insufficient shares"); shares[caller] = shares[caller] - amount; shares[to] = shares[to] + amount; emit ShareTransfer(caller, to, amount); return true; } // ---- the asset ledger --------------------------------------------------------------------- fn transfer(to: address, amount: uint) -> bool { require(frozen == false, "paused"); require(len(to) > 0, "empty recipient"); require(to != caller, "self transfer"); require(to != self, "self is not payable"); require(balances[caller] >= amount, "insufficient balance"); balances[caller] = balances[caller] - amount; balances[to] = balances[to] + amount; emit Transfer(caller, to, amount); return true; } // The caller burns its OWN balance and no other. Burning does not touch the pool: assets in // the pool are not in anyone's balance, so they cannot be burned by anyone. 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"); 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 = ""; } // Covers deposit, fundYield, transferShares, transfer, burn and mint. It does NOT cover // redeem or withdraw, and that asymmetry is the most important line in this file. 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 asset PROVE a fixed supply rather than promise one. fn closeMinting() { require(caller == contractOwner, "not owner"); require(len(mintAuthority) > 0, "mint closed"); emit MinterChanged(mintAuthority, ""); mintAuthority = ""; } }