// ========================================================================================= // StakingToken — a ZK13 token whose holders can lock their balance and accrue a reward. // // WHY THE TOKEN AND THE STAKING ARE ONE CONTRACT, AND NOT TWO // Because they have to be. A ZK13 token's ledger lives in its own contract storage, and // AresLang has no cross-contract calls, so a separate staking contract could never move a // holder's token balance — and a balance recorded at a staking contract's address could // never move again at all, because a contract cannot be `caller`. Staking a ZK13 token is // therefore only writable INSIDE the token contract. This is that. // // The same reasoning is why this template does not stake native STR. A staker 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 // — staking, a token sale, a shared vault — is unwritable for that one reason, and no // amount of contract code fixes it. // // WHAT IT GUARANTEES // * A staker can never withdraw more principal than they staked. `unstake` is a checked // subtraction against that staker's own `staked` slot, keyed by `caller`. // * A staker can never touch another staker's position. Every position is keyed by // `caller` and there is no argument anywhere that names whose position to move. // * Reward is accrued from the BLOCK's timestamp. There is no time argument in the ABI. // * Reward is paid only out of the declared reward pool, and never more than the pool // holds, so rewards can never mint supply out of nothing. // * A reward that cannot be paid is CARRIED, never destroyed. When the pool is short, or // while the contract is paused, the settlement pays what it can and the remainder stays in // `rewardOwed` — readable as `rewardOwedOf(account)` — to be paid by a later call once the // pool is refilled. `claim` accepts a caller with a debt and no position, so a debt // outlives the stake that earned it. // * Principal is ALWAYS recoverable. Not "usually": `unstake` is the one mutating function // in this contract that the pause does not gate, so an exhausted pool cannot block a // withdrawal and neither can the owner. The reward is held by a pause; the stake is not. // * The supply cap bounds LIFETIME ISSUANCE (`lifetimeIssued()`), not the circulating // `totalSupply` that `burn` lowers. A cap checked against a burnable number is defeated by // mint, burn, mint — measured on this template before the fix: 4,000 issued against a cap // of 1,000. `maxSupply() == 0` means uncapped, which is what this ships as. // * totalSupply is unchanged by staking. Staked tokens are moved to this contract's own // address (`self`), which is a balance slot like any other; the sum of every balance plus // nothing else still equals `totalSupply`. // // THE ACCRUAL BLOCK APPEARS THREE TIMES AND MUST STAY IDENTICAL // AresLang has no user-defined functions, so the reward calculation cannot be factored out. // It is copied verbatim into `stake`, `claim` and `unstake` between the ACCRUAL BEGIN and // ACCRUAL END markers. IF YOU CHANGE ONE, CHANGE ALL THREE. A reward formula that differs // between claim and unstake is a hole that pays out on whichever path is more generous. // This is not left to the reader: `tests/integration/template-staking.integration.test.ts` // extracts all three blocks from this file and fails if they are not character-for-character // the same apart from the copy number, so a drifted copy cannot ship. // // HOW IT IS TYPICALLY MISUSED // * Expecting guaranteed yield. Rewards are first come, first served out of a finite pool // anyone can fund with `fundRewards`, and a DEBT IS NOT A GUARANTEE: it records what a // staker is owed and nothing in this contract mints to cover it. A refill is taken by // whoever settles first — one large position can absorb an entire refill and leave every // other debt untouched — so the ordering is a race, and the only fix is to fund the pool // for the maximum accrual of the maximum stake, or to say plainly that yield is // best-effort. What the debt DOES guarantee is that nobody's earned reward is destroyed by // their own claim, which is what happened before it existed: a claim against a short pool // paid the clamped amount and restarted the clock, and the difference was gone. // * Reading the pause as covering everything. It does not, deliberately: `unstake` runs // while paused. An owner can stop minting, transfers, staking, funding and every reward // payment; the one thing an owner cannot do is keep a staker from their own principal. // * Forgetting who holds the pause and that there is no `renounceOwnership`. `transfer` IS // gated by it, so an owner who pauses and then loses the key leaves a token whose balances // nobody can ever move again — stakes excepted. That is not confiscation and nothing is // destroyed, but it is a dead token, and the code is immutable. Read `owner()` before you // hold this, and hand ownership to a key that will still exist in five years. // * Believing this confers consensus weight. It does not. Validator admission is declared // in the genesis and is not chain state; a staking contract confers no consensus power of // any kind, and a template implying otherwise is claiming something the chain does not // implement. // * Adding to a stake and expecting the clock not to move. `stake` settles the accrued // reward first and then restarts the clock on the whole position. That is the correct // order — computing a reward from a `lastUpdate` the same call has already overwritten is // the classic staking bug — but it means topping up resets nothing you have already been // paid and everything you have not yet earned. // * Under-declaring the gas limit. MEASURED: mint 1,144, transfer 835, burn 774, // fundRewards 1,272, stake 2,276 on a fresh position and 2,560 on a top-up that settles a // reward, claim 2,200, unstake 3,603 and 2,509 while paused. Declare at least 38,000. // ========================================================================================= contract StakingToken { // ---- edit these before deploying ------------------------------------------------------- state tokenName: string = "Example Staking Token"; state tokenSymbol: string = "EST"; state tokenDecimals: uint = 13; // A ceiling on LIFETIME ISSUANCE, not on circulating supply. SHIPPED AT 0, WHICH IS // UNCAPPED: a template cannot invent a cap for a token it knows nothing about, and a // plausible-looking placeholder reads as a decision somebody made. Set the real number // before you deploy, or `closeMinting()` when issuance is done and prove the bound instead // of promising it. state supplyCap: uint = 0; // Reward rate in basis points per year: 500 = 5% a year, simple, not compounded. state rewardBpsPerYear: uint = 500; // The longest window a single position accrues over, in milliseconds. 730 days. A capped // window is what keeps `staked * elapsed * rate` from growing without bound until it // reverts at the 4096-bit integer ceiling and strands the position. state maxAccrualMs: uint = 63072000000; // ------------------------------------------------------------------------------------- // 10000 bps * 365 days in ms. The denominator of the reward formula, written once here so // the three copies of the accrual block cannot drift apart on it. state bpsYearMs: uint = 315360000000000; state supply: uint = 0; // Everything `mint` has ever created, and it never goes down. This — not `supply`, which // `burn` lowers — is what `supplyCap` bounds. See the header: a cap tested against a // burnable number is defeated by mint, burn, mint. state issuedEver: uint = 0; state contractOwner: address = caller; state pendingOwner: address = ""; state mintAuthority: address = caller; state frozen: bool = false; state rewardPool: uint = 0; state totalStaked: uint = 0; state balances: map; state staked: map; state stakedSince: map; // Reward that was earned and could not be paid, because the pool was short of it or the // contract was paused. It is carried, not forgotten. Without this slot a settlement that // paid less than it owed still restarted the clock, so the difference was destroyed by the // staker's own call — silently, with a RewardPaid event for the smaller number. state rewardOwed: map; event Transfer(from: address, to: address, amount: 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); event RewardsFunded(by: address, amount: uint, pool: uint); event Staked(who: address, amount: uint, position: uint); event Unstaked(who: address, amount: uint, position: uint); event RewardPaid(who: address, amount: uint, pool: uint); // ---- the token, exactly as ZK13Token defines it ----------------------------------------- fn transfer(to: address, amount: uint) -> bool { require(frozen == false, "paused"); require(len(to) > 0, "empty recipient"); require(to != caller, "self transfer"); // A transfer to the CONTRACT itself is unrecoverable loss, and here it is worse than the // usual kind: `balances[self]` is load-bearing accounting - it is `rewardPool + totalStaked` // by construction, and every withdrawal path is bounded by one of those two counters. Value // arriving here without a matching counter can never move again by any path. One opcode. require(to != self, "self is not payable; use fundRewards"); require(balances[caller] >= amount, "insufficient balance"); balances[caller] = balances[caller] - amount; balances[to] = balances[to] + amount; emit Transfer(caller, to, amount); return true; } 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; } // ---- the reward pool --------------------------------------------------------------------- // Moves the caller's own tokens into the pool. Anybody may fund it; nobody can take from // it except a staker accruing a reward, and there is no function that empties it. fn fundRewards(amount: uint) -> uint { require(frozen == false, "paused"); require(amount > 0, "amount must be positive"); require(balances[caller] >= amount, "insufficient balance"); balances[caller] = balances[caller] - amount; balances[self] = balances[self] + amount; rewardPool = rewardPool + amount; emit Transfer(caller, self, amount); emit RewardsFunded(caller, amount, rewardPool); return rewardPool; } // ---- staking ----------------------------------------------------------------------------- fn stake(amount: uint) -> uint { require(frozen == false, "paused"); require(amount > 0, "amount must be positive"); require(balances[caller] >= amount, "insufficient balance"); // ---- ACCRUAL BEGIN (copy 1 of 3) ---- let earned = 0; if (staked[caller] > 0) { let elapsed = timestamp - stakedSince[caller]; if (elapsed > maxAccrualMs) { elapsed = maxAccrualMs; } earned = staked[caller] * elapsed * rewardBpsPerYear / bpsYearMs; } // What is owed is what was carried plus what this window earned. The clamp below decides // how much of it can be PAID now; the remainder stays owed rather than being dropped. let owed = rewardOwed[caller] + earned; let reward = owed; if (rewardPool < owed) { reward = rewardPool; } // A pause holds the POOL, which is the contract's money. `stake` and `claim` can never // reach this line — both refuse to run while frozen — and it is here for `unstake`, which // deliberately does run while frozen and must return the principal without paying out of a // pool the owner has stopped. Nothing is lost by it: the whole amount stays owed. if (frozen == true) { reward = 0; } rewardOwed[caller] = owed - reward; if (reward > 0) { rewardPool = rewardPool - reward; balances[self] = balances[self] - reward; balances[caller] = balances[caller] + reward; emit Transfer(self, caller, reward); emit RewardPaid(caller, reward, rewardPool); } // ---- ACCRUAL END ---- balances[caller] = balances[caller] - amount; balances[self] = balances[self] + amount; staked[caller] = staked[caller] + amount; stakedSince[caller] = timestamp; totalStaked = totalStaked + amount; emit Transfer(caller, self, amount); emit Staked(caller, amount, staked[caller]); return staked[caller]; } fn claim() -> uint { require(frozen == false, "paused"); // A debt outlives the position it was earned by: a staker whose reward could not be paid // when they unstaked must still be able to collect it once the pool is refilled. require(staked[caller] > 0 || rewardOwed[caller] > 0, "nothing staked and nothing owed"); // ---- ACCRUAL BEGIN (copy 2 of 3) ---- let earned = 0; if (staked[caller] > 0) { let elapsed = timestamp - stakedSince[caller]; if (elapsed > maxAccrualMs) { elapsed = maxAccrualMs; } earned = staked[caller] * elapsed * rewardBpsPerYear / bpsYearMs; } // What is owed is what was carried plus what this window earned. The clamp below decides // how much of it can be PAID now; the remainder stays owed rather than being dropped. let owed = rewardOwed[caller] + earned; let reward = owed; if (rewardPool < owed) { reward = rewardPool; } // A pause holds the POOL, which is the contract's money. `stake` and `claim` can never // reach this line — both refuse to run while frozen — and it is here for `unstake`, which // deliberately does run while frozen and must return the principal without paying out of a // pool the owner has stopped. Nothing is lost by it: the whole amount stays owed. if (frozen == true) { reward = 0; } rewardOwed[caller] = owed - reward; if (reward > 0) { rewardPool = rewardPool - reward; balances[self] = balances[self] - reward; balances[caller] = balances[caller] + reward; emit Transfer(self, caller, reward); emit RewardPaid(caller, reward, rewardPool); } // ---- ACCRUAL END ---- // The clock restarts whether or not anything was paid, so the same elapsed time can // never be claimed twice. stakedSince[caller] = timestamp; return reward; } // NOT gated on the pause, and that is the whole point of it. A pause that holds the // PRINCIPAL is a pause that takes it: the owner sets one bool and every staker's own tokens // are frozen for as long as the key that set it stays silent, with no renounce and no // second key anywhere in this contract that could end it. The reward is a different // question and IS held by the pause — the accrual block pays nothing while frozen and // carries the whole amount as a debt — so a pause stops the contract paying out of its pool // without ever standing between a staker and their own stake. fn unstake(amount: uint) -> uint { require(amount > 0, "amount must be positive"); // The checked subtraction that bounds a withdrawal by what this caller actually staked. require(staked[caller] >= amount, "insufficient stake"); // ---- ACCRUAL BEGIN (copy 3 of 3) ---- let earned = 0; if (staked[caller] > 0) { let elapsed = timestamp - stakedSince[caller]; if (elapsed > maxAccrualMs) { elapsed = maxAccrualMs; } earned = staked[caller] * elapsed * rewardBpsPerYear / bpsYearMs; } // What is owed is what was carried plus what this window earned. The clamp below decides // how much of it can be PAID now; the remainder stays owed rather than being dropped. let owed = rewardOwed[caller] + earned; let reward = owed; if (rewardPool < owed) { reward = rewardPool; } // A pause holds the POOL, which is the contract's money. `stake` and `claim` can never // reach this line — both refuse to run while frozen — and it is here for `unstake`, which // deliberately does run while frozen and must return the principal without paying out of a // pool the owner has stopped. Nothing is lost by it: the whole amount stays owed. if (frozen == true) { reward = 0; } rewardOwed[caller] = owed - reward; if (reward > 0) { rewardPool = rewardPool - reward; balances[self] = balances[self] - reward; balances[caller] = balances[caller] + reward; emit Transfer(self, caller, reward); emit RewardPaid(caller, reward, rewardPool); } // ---- ACCRUAL END ---- staked[caller] = staked[caller] - amount; stakedSince[caller] = timestamp; totalStaked = totalStaked - amount; balances[self] = balances[self] - amount; balances[caller] = balances[caller] + amount; emit Transfer(self, caller, amount); emit Unstaked(caller, amount, staked[caller]); return staked[caller]; } // ---- ownership, pause, minting ------------------------------------------------------------- fn proposeOwner(next: address) { require(caller == contractOwner, "not owner"); pendingOwner = next; emit OwnerProposed(contractOwner, next); } 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 closeMinting() { require(caller == contractOwner, "not owner"); require(len(mintAuthority) > 0, "mint closed"); emit MinterChanged(mintAuthority, ""); mintAuthority = ""; } // ---- views ---------------------------------------------------------------------------------- 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, and the number `maxSupply` actually bounds. view fn lifetimeIssued() -> uint { return issuedEver; } view fn balanceOf(account: address) -> uint { return balances[account]; } view fn owner() -> address { return contractOwner; } view fn minter() -> address { return mintAuthority; } view fn paused() -> bool { return frozen; } view fn stakeOf(account: address) -> uint { return staked[account]; } view fn stakedSinceOf(account: address) -> uint { return stakedSince[account]; } view fn staking() -> uint { return totalStaked; } view fn pool() -> uint { return rewardPool; } view fn rateBps() -> uint { return rewardBpsPerYear; } // What was earned and could not be paid. It is a claim on the reward pool, not a promise // that the pool can ever pay it: nothing in this contract mints to cover a debt. view fn rewardOwedOf(account: address) -> uint { return rewardOwed[account]; } // What `claim` would pay right now, at this block's time — carried debt included, clamped // to the pool exactly as the accrual block clamps it. A wallet can show it without moving // anything. While `paused()` is true this is what a claim WOULD pay once the pause ends, // because a frozen contract pays no reward at all. view fn pendingReward(account: address) -> uint { let earned = 0; if (staked[account] > 0) { let elapsed = timestamp - stakedSince[account]; if (elapsed > maxAccrualMs) { elapsed = maxAccrualMs; } earned = staked[account] * elapsed * rewardBpsPerYear / bpsYearMs; } let owed = rewardOwed[account] + earned; let reward = owed; if (rewardPool < owed) { reward = rewardPool; } return reward; } }