height 23717 of 23717 on the network. 9 peer(s) connected, 77 in its peer directory; 1013 block(s) followed and 842 pulled in by sync since this follower started. Caught up: this follower holds every block any peer has claimed, so every panel below is the whole chain. Genesis 1cf8655125cc1bbb…, producers genesis.
What this follower is, locally
Following 7 seed(s) — their addresses are not published here, for the reason the peer table gives. Its store is held under its own Cerberus lease — no other node’s store is opened, touched or stopped.
StakingToken for your fork
- Status
- a draft, not deployed nothing here is on any chain; never send value to a draft
- Profile
- staking token
- Holds
- its own token ledger
- The property that matters
- a staker cannot withdraw more than they staked, and unstake is not pausable
- On Asgard
- the same template in the catalogue
Edit the draft
Set the constants the template marks for editing, and name a deployer to see where it would land.
The draft as shipped
- Contract
StakingToken- Compiler
areslang-1.3.0- Bytecode SHA-256
06b5aaec3d858b84dc1fc6f77964cc78a62bb477f14a6e833bda304e2396dc7c- Bytecode
- 5,720 bytes
- Source SHA-256
dc0ea23b463f9c8c5a6756512e5e957311a11dc64b08628b785d247f1782bd01- The catalogue text?
- Yes, byte for byte: matches the published vector
- Ready to deploy?
- not yet a deployment refuses while these remain: tokenName = "Example Staking Token", tokenSymbol = "EST"
- Would land at
- name a deployer to see it
Deploy it to your fork
Only the last step signs, and only it reaches a node.
- Save the draft. Download it above, or start from the catalogue text with
strblk contract template staking-token > staking-token.aresand edit the marked constants yourself. - Dry-run it on your machine.
strblk wallet deploy staking-token.ares --dry-run --address <your address on the fork>compiles it locally and prints its digest and address. They must match this page; if they do not, the file is not this draft. - Find your fork's RPC. It is the
rpc (Iris, wallets)line of the fork's banner, and therpcUrlof each node in<data root>/fork.json. - Deploy it.
strblk wallet deploy staking-token.ares --node <your fork's RPC>, from a wallet whose account holds a proved STR.domain on your fork. Before it signs, the wallet prints the network: the realm reads unregistered and the genesis is your fork's hash. Check that hash; it is what the signature binds.
Measured gas
Charged by the STARW VM under areslang-1.3.0, the same on your fork. Measured, not estimated.
| Entry point | Gas |
|---|---|
| unstake | 3,603 |
| stake(top-up) | 2,560 |
| unstake(paused) | 2,509 |
| stake | 2,276 |
| claim | 2,200 |
| fundRewards | 1,272 |
| mint | 1,144 |
| transfer | 835 |
| burn | 774 |
- Declare at least
- 36,030 ten times the most expensive call
Functions
| Kind | Name | Inputs | Outputs | Mutability |
|---|---|---|---|---|
| function | transfer | to: address, amount: uint | bool | nonpayable |
| function | burn | amount: uint | bool | nonpayable |
| function | mint | to: address, amount: uint | bool | nonpayable |
| function | fundRewards | amount: uint | uint | nonpayable |
| function | stake | amount: uint | uint | nonpayable |
| function | claim | uint | nonpayable | |
| function | unstake | amount: uint | uint | nonpayable |
| function | proposeOwner | next: address | nonpayable | |
| function | acceptOwnership | nonpayable | ||
| function | setPaused | next: bool | nonpayable | |
| function | closeMinting | nonpayable | ||
| function | name | string | view | |
| function | symbol | string | view | |
| function | decimals | uint | view | |
| function | totalSupply | uint | view | |
| function | maxSupply | uint | view | |
| function | lifetimeIssued | uint | view | |
| function | balanceOf | account: address | uint | view |
| function | owner | address | view | |
| function | minter | address | view | |
| function | paused | bool | view | |
| function | stakeOf | account: address | uint | view |
| function | stakedSinceOf | account: address | uint | view |
| function | staking | uint | view | |
| function | pool | uint | view | |
| function | rateBps | uint | view | |
| function | rewardOwedOf | account: address | uint | view |
| function | pendingReward | account: address | uint | view |
| 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 |
Your draft's source
templates/staking-token.ares, as shipped
// =========================================================================================
// 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<address, uint>;
state staked: map<address, uint>;
state stakedSince: map<address, uint>;
// 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<address, uint>;
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;
}
}