Vesting: paying out over time
A cliff, then a straight line. The arithmetic that governs most team and investor allocations.
Vesting releases an allocation gradually instead of all at once. The standard shape has two parameters:
- a cliff — nothing at all is claimable before it
- a duration — after the cliff, the allocation unlocks linearly until it is fully released
before the cliff → 0 between cliff and end → total × elapsed ÷ duration after the end → total
Time is measured in block height, for the reason the voting lesson gave: it is a count every node agrees on exactly. Animica blocks average about two minutes, so a one-year vest is roughly 260,000 blocks.
Track what has been claimed, not what is left. The claimable amount is
vested(now) - already_claimed
If you instead decrement a "remaining" counter, every rounding error compounds and the final claim leaves dust stranded forever. Recomputing vested from the original total each time is self-correcting: the last claim always lands exactly on the total, whatever happened in between.
And multiply before you divide. total * elapsed // duration is right; total * (elapsed // duration) is zero for the entire vest until the very end, because integer division of a fraction less than one is zero. Contracts have shipped with that bug.
This is one of the few places where a contract genuinely needs to think about integer division. There are no floats in the VM — for good reason, since IEEE-754 rounding varies by platform — so the rounding behaviour you get is floor division, and you should choose the order of operations deliberately.
Your turn
Implement vested_at(height) and claim_at(height). Nothing vests before start + cliff. Between cliff and start + duration it is linear in the whole elapsed time since start. After that everything is vested. claim_at pays out only what has not already been claimed.
Hints
One way to do it
from stdlib import abi, events, hash, storage
def _uget(key: bytes) -> int:
raw = storage.get(key, b"")
return 0 if raw == b"" else int.from_bytes(raw, "big")
def _uset(key: bytes, value: int) -> None:
v = int(value)
abi.require(v >= 0, b"negative")
if v == 0:
storage.delete(key)
return
storage.set(key, v.to_bytes(max(1, (v.bit_length() + 7) // 8), "big"))
def _flag(key: bytes) -> bool:
return storage.get(key, b"") == b"\x01"
def _set_flag(key: bytes) -> None:
storage.set(key, b"\x01")
K_TOTAL = b"vest:total"
K_START = b"vest:start"
K_CLIFF = b"vest:cliff"
K_DURATION = b"vest:duration"
K_CLAIMED = b"vest:claimed"
def init(total: int, start: int, cliff: int, duration: int) -> None:
_uset(K_TOTAL, int(total))
_uset(K_START, int(start))
_uset(K_CLIFF, int(cliff))
_uset(K_DURATION, int(duration))
def claimed() -> int:
return _uget(K_CLAIMED)
def vested_at(height: int) -> int:
h = int(height)
total = _uget(K_TOTAL)
start = _uget(K_START)
cliff = _uget(K_CLIFF)
duration = _uget(K_DURATION)
if h < start + cliff:
return 0
if h >= start + duration:
return total
elapsed = h - start
# Multiply BEFORE dividing, or the result is 0 until the very end.
return total * elapsed // duration
def claimable_at(height: int) -> int:
vested = vested_at(height)
taken = _uget(K_CLAIMED)
return vested - taken if vested > taken else 0
def claim_at(height: int) -> int:
amount = claimable_at(height)
abi.require(amount > 0, b"nothing_vested")
# Recompute from the total every time; never decrement a remaining counter.
_uset(K_CLAIMED, _uget(K_CLAIMED) + amount)
events.emit(b"Claimed", {"to": abi.caller(), "value": amount})
return amount
Claim your 10 ANM
Finish this lesson and claim 10 ANM, once per address. Paid from the Animica treasury in batches — allow a few minutes.
Stuck? Ask
Answered by Animica's own free inference network. It is donated GPU capacity, so give it 20-30 seconds.