1// Package grc20votes is a GRC20-shaped ledger that remembers what every2// holder's voting power used to be.3//4// An analog of OpenZeppelin's ERC20Votes, not a transliteration. Where the5// EVM's shape exists only because the EVM is what it is — opt-in delegation, a6// storage model that charges for every extra word forever — gno gets to do the7// obvious thing instead: delegation defaults to self, and the checkpoint rides8// in the account record a transfer was going to write anyway.9//10// # What a consumer does11//12// A realm allocates one Ledger and keeps it in an unexported package variable:13//14// var ledger = grc20votes.NewLedger("Kourt Governance", "COURT", 6, 720)15//16// func Transfer(cur realm, to address, amount int64) {17// if !cur.IsCurrent() {18// panic("stale realm")19// }20// ledger.Transfer(cur.Previous().Address(), to, amount)21// }22//23// Nothing here takes a `cur realm`, because a /p/ package cannot declare a24// crossing function. That is not a limitation worked around — it is the right25// split. Authentication belongs to the realm that has a caller to authenticate;26// this package is told WHICH address is acting and does the bookkeeping.27//28// # Why it is safe to keep this in /p/29//30// Every field is unexported and no method hands back an interior pointer, so a31// consumer holds a *Ledger and nothing else. That matters more here than it32// looks: /p/-declared types can be named by other /p/ packages, so an exported33// *account or a method returning one would let a stranger declare a mutator34// over it — and gno's storage-realm borrow would run that mutator under the35// CONSUMING realm's authority. See gno-security-guide.md §3(B) and §4; this36// follows the encapsulation pattern p/demo/tokens/grc20 sets, with the37// authentication moved out to the realm rather than carried on a teller.38//39// The one exception is deliberate: Ledger has no method taking a callback, so40// there is nothing to launder through. Adding one later would need the41// parameter type declared in the consuming realm, not here.42package grc20votes4344import (45 "chain"46 "chain/runtime"47 "math/overflow"48 "strconv"49 "time"5051 checkpoint "gno.land/p/g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6/checkpoint/v0"52 bptree "gno.land/p/nt/bptree/v0"53)5455// Bps is the basis-point scale, named once because it is load-bearing in three56// places at once here and in any governor reading this ledger: the supply57// ceiling, a rendered percentage, and a quorum. A scale written out separately58// in each is a number that can come to disagree with itself.59const Bps = int64(10000)6061// MaxSupply keeps a tally's arithmetic honest for anything weighing this62// ledger. A threshold comparison is yes*Bps >= (yes+no)*threshold, and yes*Bps63// overflows int64 once the supply passes MaxInt64/Bps — a wrapped tally goes64// negative rather than failing loudly, so a won vote reports as lost.65//66// Not a remote bound: six decimals and a billion units in issue is 1e15 base67// units, already past it. Capped at mint, so a tally needs no overflow checks —68// roughly 922 million whole tokens at six decimals.69const MaxSupply = int64(9223372036854775807) / Bps7071// Event names are grc20's exactly (p/demo/tokens/grc20: types.gno72// TransferEvent, token.gno chain.Emit), so an indexer following73// Transfer/Approval sees the same stream a stock token produces.74//75// What this does NOT do is implement grc20.Teller. The SECURITY note on76// types.gno's Teller tells consumers to reject anything failing77// IsCanonicalTeller, so satisfying it would be worthless to a careful caller78// and misleading to a careless one.79const (80 mintEvent = "Mint"81 burnEvent = "Burn"82 transferEvent = "Transfer"83 approvalEvent = "Approval"8485 // delegateChangedEvent has no counterpart in grc20 because grc20 has no86 // delegation. The name is OpenZeppelin's, since anybody indexing a87 // governance token already knows it.88 delegateChangedEvent = "DelegateChanged"89)9091// sep joins the two halves of an allowance key. The checkpoint package's92// separator rather than a byte that happens to match it: two constants with the93// same value in different files eventually disagree.94const sep = checkpoint.Sep9596// supplyKey checkpoints the supply like any holder, under a key no address can97// take. "~" is not in the bech32 alphabet and an address always begins with98// "g". Not the separator itself, which the checkpoint package refuses as a key:99// one containing the separator shares a page range with its neighbours.100const supplyKey = "~supply"101102// account is one holder: what they have, who votes it, and what it was.103//104// The votes series is a value field, not a pointer, so changing it dirties this105// record and nothing else — which is why checkpointing is affordable here.106//107// The record outlives a zero balance. The stock ledger removes a holder at zero108// (grc20 token.gno, led.balances.Remove); doing that here would delete the109// voting history, and an account oscillating through zero would churn keys.110type account struct {111 balance int64112 delegate address // "" means self — see Delegate113 votes checkpoint.Series114}115116// Ledger is one token's balances and the history of their voting power.117//118// Allocated by the consuming realm, which is what makes this work at all: the119// trees inside carry that realm's storage stamp, so a method here borrows the120// consumer's authority for the write and the storage is billed to them. A /p/121// package's own state is frozen after init and could hold none of this.122// Clock is where this ledger's sense of height comes from. A realm supplies123// one so its OWN clock governs epochs; nil means the chain's, which is what an124// ordinary deployment wants.125//126// It exists because a realm that can fast-forward its clock for testing cannot127// fast-forward this package's: a pure package may not import a realm, and a128// SETTABLE package global would be a capability any caller could take. Bound at129// construction by the realm that owns the ledger, it is neither.130type Clock interface {131 // Height is the block height this ledger should quantise epochs by.132 Height() int64133134 // Now is the same block's wall-clock time, unix seconds. The ledger itself135 // quantises by HEIGHT and always will — epoch quantisation is what makes the136 // anti-flash-loan property structural — but it passes this through to the137 // governor on the Electorate interface, where a voting DEADLINE lives.138 Now() int64139}140141type Ledger struct {142 // clock is nil on an ordinary deployment; see Clock.143 clock Clock144 name string145 symbol string146 decimals int147 id string148149 // epochBlocks quantises the clock. Every change inside one epoch coalesces150 // into a single update of a record already being written, which is what151 // bounds archive growth by wall-clock activity rather than by block152 // production — and it makes the anti-flash-loan property an invariant153 // rather than a discipline, since a transaction cannot outlive its block154 // and a block cannot outlive its epoch.155 epochBlocks int64156157 accounts *bptree.BPTree // address -> *account158 allowances *bptree.BPTree // owner + sep + spender -> int64159160 // THIS ARCHIVE IS NEVER TRIMMED, and that is a property of the design rather161 // than an omission. checkpoint.Archive has a Trim, the claim's hourly stake162 // series uses it, and one line here would look like an ordinary storage163 // saving.164 //165 // Trim's own contract is "ValueAt exact AT AND ABOVE keepFrom". Every question166 // in the realm above is anchored at a PAST epoch and reads two values from167 // here — PastVotes for each voter's ceiling and PastTotal for the bar those168 // votes are judged against — so trimming past a live question's epoch makes169 // both inexact, independently of each other. That is the numerator and the170 // denominator drifting to different instants, which is the defect that171 // produced turnout at 200-400% of its own bar and was reverted.172 //173 // What would have to be true to relax it: keepFrom below the oldest epoch any174 // open question is anchored at, which means knowing every open question — a175 // fact this package deliberately does not have. check-epoch-coherence arm 12176 // pins the call sites meanwhile.177 archive *checkpoint.Archive178179 supply checkpoint.Series180 total int64181}182183// NewLedger creates an empty ledger. The identity is fixed here rather than184// read from a constant, so one realm can run several and a court can name its185// own coin.186//187// epochBlocks is the quantisation, in blocks. At gno's five-second cadence 720188// is an hour, which is the figure the rest of this design was measured at.189// NewLedgerWithClock is NewLedger with the height source named. A realm whose190// own clock can be fast-forwarded passes itself; everyone else calls NewLedger.191func NewLedgerWithClock(name, symbol string, decimals int, epochBlocks int64, clk Clock) *Ledger {192 l := NewLedger(name, symbol, decimals, epochBlocks)193 l.clock = clk194 return l195}196197func NewLedger(name, symbol string, decimals int, epochBlocks int64) *Ledger {198 if name == "" || symbol == "" {199 panic("grc20votes: a token needs a name and a symbol")200 }201 if decimals < 0 || decimals > 18 {202 panic("grc20votes: decimals must be between 0 and 18")203 }204 if epochBlocks <= 0 {205 panic("grc20votes: an epoch has to be at least one block long")206 }207 return &Ledger{208 name: name, symbol: symbol, decimals: decimals,209 // The pkgpath is not knowable from here, so the id is the symbol210 // qualified by whatever the consumer wants. A realm that wants the211 // grc20reg shape passes its own path in the name.212 id: symbol,213 epochBlocks: epochBlocks,214 accounts: bptree.NewBPTree32(),215 allowances: bptree.NewBPTree32(),216 archive: checkpoint.NewArchive(),217 }218}219220// SetID names this token the way an indexer will see it, usually221// "gno.land/r/you/realm.SYMBOL". Called once, at construction time, by the222// realm that knows its own path — a /p/ package cannot ask.223func (l *Ledger) SetID(id string) { l.id = id }224225// ------------------------------------------------------------------ reads --226227// The GRC20 reads, with the names that standard uses, answering about NOW.228//229// Governance does not read any of these. It asks PastVotes and PastTotal,230// which answer about a sealed epoch — the distinction the whole design turns231// on, and the reason these are grouped apart from them.232233func (l *Ledger) Name() string { return l.name }234func (l *Ledger) Symbol() string { return l.symbol }235func (l *Ledger) Decimals() int { return l.decimals }236func (l *Ledger) ID() string { return l.id }237func (l *Ledger) TotalSupply() int64 { return l.total }238239func (l *Ledger) BalanceOf(owner address) int64 {240 if a := l.getAccount(owner); a != nil {241 return a.balance242 }243 return 0244}245246func (l *Ledger) Allowance(owner, spender address) int64 {247 if v := l.allowances.Get(string(owner) + sep + string(spender)); v != nil {248 return v.(int64)249 }250 return 0251}252253// VotesOf is voting power right now — the sum of every balance delegated here,254// including the holder's own unless they delegated it away.255func (l *Ledger) VotesOf(who address) int64 {256 if a := l.getAccount(who); a != nil {257 return a.votes.Value()258 }259 return 0260}261262// DelegateOf is who votes an address's balance. Self unless they said263// otherwise, which is the default nobody has to remember.264func (l *Ledger) DelegateOf(who address) address {265 a := l.getAccount(who)266 if a == nil || a.delegate == "" {267 return who268 }269 return a.delegate270}271272// PastVotes is voting power throughout a SEALED epoch. Refusing the current one273// is the anti-flash-loan property as an invariant rather than a convention: a274// transaction runs inside one block, a block inside one epoch, and this will275// not answer for the epoch it is in.276func (l *Ledger) PastVotes(who address, at uint32) int64 {277 l.mustBeSealed(at)278 a := l.getAccount(who)279 if a == nil {280 return 0281 }282 return a.votes.ValueAt(l.archive, string(who), at)283}284285// PastTotal is every unit in existence during a sealed epoch, which a quorum is286// a fraction of.287//288// Because delegation defaults to self, everyone's PastVotes sums to exactly289// this. OZ cannot say so: getPastTotalSupply counts all supply while290// getPastVotes counts only DELEGATED supply, so the denominator exceeds the291// achievable numerator and quorum is unreachable where few have delegated.292func (l *Ledger) PastTotal(at uint32) int64 {293 l.mustBeSealed(at)294 return l.supply.ValueAt(l.archive, supplyKey, at)295}296297// EngagedTotal is what a governor measures quorum against. Every holder is298// engaged here by definition, since power is self-delegated by default — a299// realm wanting idle weight dropped out of the denominator wraps this.300func (l *Ledger) EngagedTotal(at uint32) int64 { return l.PastTotal(at) }301302// WalkSupply visits the supply's CHANGE points, newest first, and stops early303// when fn returns true. An epoch in which supply did not move is not a point:304// a reader forward-fills between them.305//306// A WALK, not a per-epoch read. PastTotal answers one epoch and is the wrong307// shape for drawing a line — a chart over a year of hourly epochs would be308// 8,760 lookups to find the handful of epochs that actually moved. The archive309// already stores only the movements, so this hands them over directly and the310// cost is the number of mints, not the age of the court.311//312// Unsealed on purpose, unlike PastTotal: the newest point may be the running313// epoch, which is exactly the value a live chart wants at its right edge. It314// may still coalesce before the epoch seals, so it is a reading and not a315// record.316func (l *Ledger) WalkSupply(fn func(e uint32, v int64) bool) {317 // The two hot slots hold the newest points and are not in the archive yet.318 if e0 := l.supply.Since(); e0 != 0 {319 if fn(e0, l.supply.Value()) {320 return321 }322 }323 if e1, prev := l.supply.Prev(); e1 != 0 {324 if fn(e1, prev) {325 return326 }327 }328 l.archive.WalkDesc(supplyKey, fn)329}330331// WalkAccounts hands every account's address and balance to fn, ascending by332// address, and stops early when fn returns true.333//334// NO NEW STATE. The accounts tree is the ledger's own index and has always held335// exactly this; what was missing was a way to read it as a set rather than one336// address at a time. A consumer wanting a holder ranking would otherwise have337// had to maintain a second index alongside every mint, burn and transfer — a338// duplicate that can only ever drift out of step with the first.339//340// ZERO BALANCES ARE HANDED OVER TOO, deliberately. An account can reach zero and341// stay in the tree — Remove would drop the delegate and the vote history with342// it, which is why the balance path never removes — so filtering here would343// quietly hide accounts that still carry voting state. The caller knows which344// question it is asking; this one answers "what does the ledger hold".345func (l *Ledger) WalkAccounts(fn func(who address, bal int64) bool) {346 l.accounts.Iterate("", "", func(k string, v any) bool {347 a, ok := v.(*account)348 if !ok {349 return false350 }351 return fn(address(k), a.balance)352 })353}354355// Epoch is now. A proposal snapshots Epoch()-1 and stores it; nothing356// re-derives it later.357func (l *Ledger) Epoch() uint32 {358 // 1-based, so zero can mean "before this token existed" — which is what359 // lets an untouched series answer correctly without storing anything.360 return uint32(l.Height()/l.epochBlocks) + 1361}362363// Height is this ledger's clock, and the governor's: it is on the Electorate364// interface so a governor weighing votes uses the same height the ledger365// quantised its snapshots by. Falling back to the chain when no clock was366// supplied keeps every existing construction working unchanged.367func (l *Ledger) Height() int64 {368 if l.clock != nil {369 return l.clock.Height()370 }371 return runtime.ChainHeight()372}373374// Now is this ledger's wall clock, forwarded to the governor through the375// Electorate interface. It is NOT used to quantise anything here: epochs are376// height-quantised by design (see Clock), and this exists only so a deadline377// the governor publishes can be a date.378func (l *Ledger) Now() int64 {379 if l.clock != nil {380 return l.clock.Now()381 }382 return time.Now().Unix()383}384385// EpochBlocks is the quantisation this ledger was built with, so a consumer386// rendering a deadline in hours does not have to be told twice.387func (l *Ledger) EpochBlocks() int64 { return l.epochBlocks }388389func (l *Ledger) mustBeSealed(at uint32) {390 if at == 0 || at >= l.Epoch() {391 panic("grc20votes: that epoch has not been sealed yet")392 }393}394395// ----------------------------------------------------------------- writes --396//397// Every one of these takes the acting address rather than reading it. The realm398// above has the `cur realm` and is the only thing that can authenticate; being399// handed an address here is what keeps this package free of a capability it400// would have no way to check.401402// Transfer moves `from`'s own tokens.403func (l *Ledger) Transfer(from, to address, amount int64) {404 l.move(from, to, amount)405}406407// Approve lets a spender move some of the owner's balance.408func (l *Ledger) Approve(owner, spender address, amount int64) {409 mustBeValid(spender)410 if amount < 0 {411 panic("grc20votes: negative allowance")412 }413 key := string(owner) + sep + string(spender)414 if amount == 0 {415 l.allowances.Remove(key)416 } else {417 l.allowances.Set(key, amount)418 }419 chain.Emit(approvalEvent,420 "token", l.id,421 "owner", owner.String(),422 "spender", spender.String(),423 "value", strconv.Itoa(int(amount)),424 )425}426427// TransferFrom spends an allowance.428func (l *Ledger) TransferFrom(spender, from, to address, amount int64) {429 key := string(from) + sep + string(spender)430 allowed := l.Allowance(from, spender)431 if allowed < amount {432 panic("grc20votes: allowance exceeded")433 }434 // Debited before the move. Defence in depth rather than a live fix: nothing435 // in move calls out, so today the order is unobservable and no test can436 // tell the difference. It is written this way for the day something here437 // does call out, when it becomes the difference between spending an438 // allowance once and twice.439 if rest := allowed - amount; rest == 0 {440 l.allowances.Remove(key)441 } else {442 l.allowances.Set(key, rest)443 }444 l.move(from, to, amount)445}446447// Mint creates tokens. This package holds no minter: who may call it is the448// consuming realm's rule, because that is where the caller is known.449func (l *Ledger) Mint(to address, amount int64) {450 mustBeValid(to)451 mustBePositive(amount)452453 next, ok := overflow.Add64(l.total, amount)454 if !ok || next > MaxSupply {455 panic("grc20votes: supply would exceed what a tally can weigh without overflowing")456 }457 l.total = next458 l.supply.SetAt(l.archive, supplyKey, l.Epoch(), next)459460 a := l.openAccount(to)461 a.balance += amount462 l.addVotes(delegateeOf(to, a), amount)463464 // Both events, and the Transfer is the one that matters to anybody else.465 //466 // grc20 declares MintEvent and BurnEvent and emits neither: its Mint sends467 // a TRANSFER with an empty `from` and its Burn one with an empty `to`. That468 // is the ERC20 convention, and how anything written against the standard469 // reconstructs balances — sum the Transfers, treat the empty counterparty470 // as the supply change. Emitting only "Mint" matches the names while471 // breaking what they are for.472 //473 // "Mint" is kept because it says plainly what happened. Anything totalling474 // supply should total Transfers and not both, which is safe for a standard475 // indexer since the standard never emits "Mint".476 chain.Emit(transferEvent,477 "token", l.id,478 "from", "",479 "to", to.String(),480 "value", strconv.Itoa(int(amount)),481 )482 chain.Emit(mintEvent,483 "token", l.id,484 "to", to.String(),485 "value", strconv.Itoa(int(amount)),486 )487}488489// Burn destroys `from`'s own tokens.490func (l *Ledger) Burn(from address, amount int64) {491 mustBePositive(amount)492 a := l.getAccount(from)493 if a == nil || a.balance < amount {494 panic("grc20votes: insufficient balance")495 }496 a.balance -= amount497 l.addVotes(delegateeOf(from, a), -amount)498499 l.total -= amount500 l.supply.SetAt(l.archive, supplyKey, l.Epoch(), l.total)501502 // The other half of the convention: a burn is a Transfer to nowhere. See503 // the note in Mint for why both events go out.504 chain.Emit(transferEvent,505 "token", l.id,506 "from", from.String(),507 "to", "",508 "value", strconv.Itoa(int(amount)),509 )510 chain.Emit(burnEvent,511 "token", l.id,512 "from", from.String(),513 "value", strconv.Itoa(int(amount)),514 )515}516517// Delegate points an address's voting power at someone else.518//519// Self-delegation is the DEFAULT, which OZ cannot afford: there it would put520// two SSTOREs on every transfer forever, so it is opt-in and everybody is521// surprised by it once. Here the checkpoint lives in a record the transfer522// already writes.523//524// One hop, not transitive: if A delegates to B and B to C, A's weight sits with525// B. Transitivity invites cycles and unbounded walks and buys nothing a second526// call cannot.527func (l *Ledger) Delegate(from, to address) {528 mustBeValid(to)529530 if l.getAccount(from) == nil && to == from {531 // Already self-delegated, because everybody is: DelegateOf reads a532 // missing account and a blank delegate the same way, so a record here533 // buys a key to store a fact true of every address that never existed.534 //535 // An address holding nothing CAN still name somebody else, and that has536 // to persist for when it is funded. What it cannot usefully do is name537 // itself.538 return539 }540 a := l.openAccount(from)541542 was := delegateeOf(from, a)543 if to == from {544 a.delegate = "" // normalised, so "self" has one representation545 } else {546 a.delegate = to547 }548 now := delegateeOf(from, a)549 if was == now {550 return551 }552 l.addVotes(was, -a.balance)553 l.addVotes(now, a.balance)554555 // The only state change here that nothing else reveals. Delegating moves556 // power without moving a coin, so an indexer sees a holder's weight vanish557 // with nothing to explain it, and the only alternative is polling558 // DelegateOf for every address anyone has heard of.559 //560 // OZ also emits DelegateVotesChanged on every power change, which here561 // would fire on every transfer, mint and burn — for something a caller can562 // ask: VotesOf answers now, PastVotes for any sealed epoch. Emitting it563 // would be transliteration.564 chain.Emit(delegateChangedEvent,565 "token", l.id,566 "delegator", from.String(),567 "fromDelegate", was.String(),568 "toDelegate", now.String(),569 )570}571572// --------------------------------------------------------------- innards --573574func (l *Ledger) move(from, to address, amount int64) {575 mustBeValid(to)576 mustBePositive(amount)577 src := l.getAccount(from)578 if src == nil || src.balance < amount {579 panic("grc20votes: insufficient balance")580 }581 dst := l.openAccount(to)582583 src.balance -= amount584 dst.balance += amount585586 // Resolved once each. When both sides vote their own balance — the587 // overwhelmingly common case — these are the records already in hand, so a588 // checkpointed transfer dirties the same two objects a plain one would. The one589 // exception is a side's FIRST write in a new epoch, which also rolls one point590 // into the archive (one added page); amortised across the epoch's writes that591 // is ~nothing, but it is not literally two objects at the boundary.592 l.addVotes(delegateeOf(from, src), -amount)593 l.addVotes(delegateeOf(to, dst), amount)594595 chain.Emit(transferEvent,596 "token", l.id,597 "from", from.String(),598 "to", to.String(),599 "value", strconv.Itoa(int(amount)),600 )601}602603func delegateeOf(self address, a *account) address {604 if a.delegate == "" {605 return self606 }607 return a.delegate608}609610// addVotes moves voting power and checkpoints it.611func (l *Ledger) addVotes(who address, delta int64) {612 if delta == 0 {613 return614 }615 a := l.openAccount(who)616 next := a.votes.Value() + delta617 if next < 0 {618 panic("grc20votes: voting power would go negative")619 }620 a.votes.SetAt(l.archive, string(who), l.Epoch(), next)621}622623func (l *Ledger) getAccount(who address) *account {624 if v := l.accounts.Get(string(who)); v != nil {625 return v.(*account)626 }627 return nil628}629630func (l *Ledger) openAccount(who address) *account {631 if a := l.getAccount(who); a != nil {632 return a633 }634 a := &account{}635 l.accounts.Set(string(who), a)636 return a637}638639func mustBeValid(a address) {640 if !a.IsValid() {641 panic("grc20votes: not an address")642 }643}644645func mustBePositive(amount int64) {646 if amount <= 0 {647 panic("grc20votes: amount must be positive")648 }649}650Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.