grc20votes.gno
23.82 Kb · 649 lines
1// Package grc20votes is a GRC20-shaped ledger that remembers what every
2// holder's voting power used to be.
3//
4// An analog of OpenZeppelin's ERC20Votes, not a transliteration. Where the
5// EVM's shape exists only because the EVM is what it is — opt-in delegation, a
6// storage model that charges for every extra word forever — gno gets to do the
7// obvious thing instead: delegation defaults to self, and the checkpoint rides
8// in the account record a transfer was going to write anyway.
9//
10// # What a consumer does
11//
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 a
24// crossing function. That is not a limitation worked around — it is the right
25// 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 a
31// consumer holds a *Ledger and nothing else. That matters more here than it
32// looks: /p/-declared types can be named by other /p/ packages, so an exported
33// *account or a method returning one would let a stranger declare a mutator
34// over it — and gno's storage-realm borrow would run that mutator under the
35// CONSUMING realm's authority. See gno-security-guide.md §3(B) and §4; this
36// follows the encapsulation pattern p/nt/grc20/v0 sets, with the
37// 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, so
40// there is nothing to launder through. Adding one later would need the
41// parameter type declared in the consuming realm, not here.
42package grc20votes
43
44import (
45 "chain"
46 "chain/runtime"
47 "math/overflow"
48 "strconv"
49 "time"
50
51 checkpoint "gno.land/p/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/checkpoint/v0"
52 bptree "gno.land/p/nt/bptree/v0"
53)
54
55// Bps is the basis-point scale, named once because it is load-bearing in three
56// places at once here and in any governor reading this ledger: the supply
57// ceiling, a rendered percentage, and a quorum. A scale written out separately
58// in each is a number that can come to disagree with itself.
59const Bps = int64(10000)
60
61// MaxSupply keeps a tally's arithmetic honest for anything weighing this
62// ledger. A threshold comparison is yes*Bps >= (yes+no)*threshold, and yes*Bps
63// overflows int64 once the supply passes MaxInt64/Bps — a wrapped tally goes
64// 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 base
67// 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) / Bps
70
71// Event names are grc20's exactly (p/nt/grc20/v0: types.gno
72// TransferEvent, token.gno chain.Emit), so an indexer following
73// Transfer/Approval sees the same stream a stock token produces.
74//
75// What this does NOT do is implement grc20.Teller. The SECURITY note on
76// types.gno's Teller tells consumers to reject anything failing
77// IsCanonicalTeller, so satisfying it would be worthless to a careful caller
78// and misleading to a careless one.
79const (
80 mintEvent = "Mint"
81 burnEvent = "Burn"
82 transferEvent = "Transfer"
83 approvalEvent = "Approval"
84
85 // delegateChangedEvent has no counterpart in grc20 because grc20 has no
86 // delegation. The name is OpenZeppelin's, since anybody indexing a
87 // governance token already knows it.
88 delegateChangedEvent = "DelegateChanged"
89)
90
91// sep joins the two halves of an allowance key. The checkpoint package's
92// separator rather than a byte that happens to match it: two constants with the
93// same value in different files eventually disagree.
94const sep = checkpoint.Sep
95
96// supplyKey checkpoints the supply like any holder, under a key no address can
97// take. "~" is not in the bech32 alphabet and an address always begins with
98// "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"
101
102// 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 this
105// 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 zero
108// (grc20 token.gno, led.balances.Remove); doing that here would delete the
109// voting history, and an account oscillating through zero would churn keys.
110type account struct {
111 balance int64
112 delegate address // "" means self — see Delegate
113 votes checkpoint.Series
114}
115
116// 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: the
119// trees inside carry that realm's storage stamp, so a method here borrows the
120// 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 supplies
123// one so its OWN clock governs epochs; nil means the chain's, which is what an
124// ordinary deployment wants.
125//
126// It exists because a realm that can fast-forward its clock for testing cannot
127// fast-forward this package's: a pure package may not import a realm, and a
128// SETTABLE package global would be a capability any caller could take. Bound at
129// 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() int64
133
134 // Now is the same block's wall-clock time, unix seconds. The ledger itself
135 // quantises by HEIGHT and always will — epoch quantisation is what makes the
136 // anti-flash-loan property structural — but it passes this through to the
137 // governor on the Electorate interface, where a voting DEADLINE lives.
138 Now() int64
139}
140
141type Ledger struct {
142 // clock is nil on an ordinary deployment; see Clock.
143 clock Clock
144 name string
145 symbol string
146 decimals int
147 id string
148
149 // epochBlocks quantises the clock. Every change inside one epoch coalesces
150 // into a single update of a record already being written, which is what
151 // bounds archive growth by wall-clock activity rather than by block
152 // production — and it makes the anti-flash-loan property an invariant
153 // rather than a discipline, since a transaction cannot outlive its block
154 // and a block cannot outlive its epoch.
155 epochBlocks int64
156
157 accounts *bptree.BPTree // address -> *account
158 allowances *bptree.BPTree // owner + sep + spender -> int64
159
160 // THIS ARCHIVE IS NEVER TRIMMED, and that is a property of the design rather
161 // than an omission. checkpoint.Archive has a Trim, the claim's hourly stake
162 // series uses it, and one line here would look like an ordinary storage
163 // saving.
164 //
165 // Trim's own contract is "ValueAt exact AT AND ABOVE keepFrom". Every question
166 // in the realm above is anchored at a PAST epoch and reads two values from
167 // here — PastVotes for each voter's ceiling and PastTotal for the bar those
168 // votes are judged against — so trimming past a live question's epoch makes
169 // both inexact, independently of each other. That is the numerator and the
170 // denominator drifting to different instants, which is the defect that
171 // 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 any
174 // open question is anchored at, which means knowing every open question — a
175 // fact this package deliberately does not have. check-epoch-coherence arm 12
176 // pins the call sites meanwhile.
177 archive *checkpoint.Archive
178
179 supply checkpoint.Series
180 total int64
181}
182
183// NewLedger creates an empty ledger. The identity is fixed here rather than
184// read from a constant, so one realm can run several and a court can name its
185// own coin.
186//
187// epochBlocks is the quantisation, in blocks. At gno's five-second cadence 720
188// 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 whose
190// 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 = clk
194 return l
195}
196
197func 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 symbol
210 // qualified by whatever the consumer wants. A realm that wants the
211 // 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}
219
220// SetID names this token the way an indexer will see it, usually
221// "gno.land/r/you/realm.SYMBOL". Called once, at construction time, by the
222// realm that knows its own path — a /p/ package cannot ask.
223func (l *Ledger) SetID(id string) { l.id = id }
224
225// ------------------------------------------------------------------ reads --
226
227// 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 turns
231// on, and the reason these are grouped apart from them.
232
233func (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 }
238
239func (l *Ledger) BalanceOf(owner address) int64 {
240 if a := l.getAccount(owner); a != nil {
241 return a.balance
242 }
243 return 0
244}
245
246func (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 0
251}
252
253// 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 0
260}
261
262// DelegateOf is who votes an address's balance. Self unless they said
263// 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 who
268 }
269 return a.delegate
270}
271
272// PastVotes is voting power throughout a SEALED epoch. Refusing the current one
273// is the anti-flash-loan property as an invariant rather than a convention: a
274// transaction runs inside one block, a block inside one epoch, and this will
275// 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 0
281 }
282 return a.votes.ValueAt(l.archive, string(who), at)
283}
284
285// PastTotal is every unit in existence during a sealed epoch, which a quorum is
286// a fraction of.
287//
288// Because delegation defaults to self, everyone's PastVotes sums to exactly
289// this. OZ cannot say so: getPastTotalSupply counts all supply while
290// getPastVotes counts only DELEGATED supply, so the denominator exceeds the
291// 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}
296
297// EngagedTotal is what a governor measures quorum against. Every holder is
298// engaged here by definition, since power is self-delegated by default — a
299// realm wanting idle weight dropped out of the denominator wraps this.
300func (l *Ledger) EngagedTotal(at uint32) int64 { return l.PastTotal(at) }
301
302// WalkSupply visits the supply's CHANGE points, newest first, and stops early
303// 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 wrong
307// shape for drawing a line — a chart over a year of hourly epochs would be
308// 8,760 lookups to find the handful of epochs that actually moved. The archive
309// already stores only the movements, so this hands them over directly and the
310// 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 running
313// epoch, which is exactly the value a live chart wants at its right edge. It
314// may still coalesce before the epoch seals, so it is a reading and not a
315// 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 return
321 }
322 }
323 if e1, prev := l.supply.Prev(); e1 != 0 {
324 if fn(e1, prev) {
325 return
326 }
327 }
328 l.archive.WalkDesc(supplyKey, fn)
329}
330
331// WalkAccounts hands every account's address and balance to fn, ascending by
332// 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 held
335// exactly this; what was missing was a way to read it as a set rather than one
336// address at a time. A consumer wanting a holder ranking would otherwise have
337// had to maintain a second index alongside every mint, burn and transfer — a
338// 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 and
341// stay in the tree — Remove would drop the delegate and the vote history with
342// it, which is why the balance path never removes — so filtering here would
343// quietly hide accounts that still carry voting state. The caller knows which
344// 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 false
350 }
351 return fn(address(k), a.balance)
352 })
353}
354
355// Epoch is now. A proposal snapshots Epoch()-1 and stores it; nothing
356// re-derives it later.
357func (l *Ledger) Epoch() uint32 {
358 // 1-based, so zero can mean "before this token existed" — which is what
359 // lets an untouched series answer correctly without storing anything.
360 return uint32(l.Height()/l.epochBlocks) + 1
361}
362
363// Height is this ledger's clock, and the governor's: it is on the Electorate
364// interface so a governor weighing votes uses the same height the ledger
365// quantised its snapshots by. Falling back to the chain when no clock was
366// 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}
373
374// Now is this ledger's wall clock, forwarded to the governor through the
375// Electorate interface. It is NOT used to quantise anything here: epochs are
376// height-quantised by design (see Clock), and this exists only so a deadline
377// 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}
384
385// EpochBlocks is the quantisation this ledger was built with, so a consumer
386// rendering a deadline in hours does not have to be told twice.
387func (l *Ledger) EpochBlocks() int64 { return l.epochBlocks }
388
389func (l *Ledger) mustBeSealed(at uint32) {
390 if at == 0 || at >= l.Epoch() {
391 panic("grc20votes: that epoch has not been sealed yet")
392 }
393}
394
395// ----------------------------------------------------------------- writes --
396//
397// Every one of these takes the acting address rather than reading it. The realm
398// above has the `cur realm` and is the only thing that can authenticate; being
399// handed an address here is what keeps this package free of a capability it
400// would have no way to check.
401
402// Transfer moves `from`'s own tokens.
403func (l *Ledger) Transfer(from, to address, amount int64) {
404 l.move(from, to, amount)
405}
406
407// 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}
426
427// 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: nothing
435 // in move calls out, so today the order is unobservable and no test can
436 // tell the difference. It is written this way for the day something here
437 // does call out, when it becomes the difference between spending an
438 // 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}
446
447// Mint creates tokens. This package holds no minter: who may call it is the
448// 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)
452
453 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 = next
458 l.supply.SetAt(l.archive, supplyKey, l.Epoch(), next)
459
460 a := l.openAccount(to)
461 a.balance += amount
462 l.addVotes(delegateeOf(to, a), amount)
463
464 // 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 sends
467 // a TRANSFER with an empty `from` and its Burn one with an empty `to`. That
468 // is the ERC20 convention, and how anything written against the standard
469 // reconstructs balances — sum the Transfers, treat the empty counterparty
470 // as the supply change. Emitting only "Mint" matches the names while
471 // breaking what they are for.
472 //
473 // "Mint" is kept because it says plainly what happened. Anything totalling
474 // supply should total Transfers and not both, which is safe for a standard
475 // 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}
488
489// 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 -= amount
497 l.addVotes(delegateeOf(from, a), -amount)
498
499 l.total -= amount
500 l.supply.SetAt(l.archive, supplyKey, l.Epoch(), l.total)
501
502 // The other half of the convention: a burn is a Transfer to nowhere. See
503 // 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}
516
517// Delegate points an address's voting power at someone else.
518//
519// Self-delegation is the DEFAULT, which OZ cannot afford: there it would put
520// two SSTOREs on every transfer forever, so it is opt-in and everybody is
521// surprised by it once. Here the checkpoint lives in a record the transfer
522// already writes.
523//
524// One hop, not transitive: if A delegates to B and B to C, A's weight sits with
525// B. Transitivity invites cycles and unbounded walks and buys nothing a second
526// call cannot.
527func (l *Ledger) Delegate(from, to address) {
528 mustBeValid(to)
529
530 if l.getAccount(from) == nil && to == from {
531 // Already self-delegated, because everybody is: DelegateOf reads a
532 // missing account and a blank delegate the same way, so a record here
533 // 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 has
536 // to persist for when it is funded. What it cannot usefully do is name
537 // itself.
538 return
539 }
540 a := l.openAccount(from)
541
542 was := delegateeOf(from, a)
543 if to == from {
544 a.delegate = "" // normalised, so "self" has one representation
545 } else {
546 a.delegate = to
547 }
548 now := delegateeOf(from, a)
549 if was == now {
550 return
551 }
552 l.addVotes(was, -a.balance)
553 l.addVotes(now, a.balance)
554
555 // The only state change here that nothing else reveals. Delegating moves
556 // power without moving a coin, so an indexer sees a holder's weight vanish
557 // with nothing to explain it, and the only alternative is polling
558 // DelegateOf for every address anyone has heard of.
559 //
560 // OZ also emits DelegateVotesChanged on every power change, which here
561 // would fire on every transfer, mint and burn — for something a caller can
562 // ask: VotesOf answers now, PastVotes for any sealed epoch. Emitting it
563 // 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}
571
572// --------------------------------------------------------------- innards --
573
574func (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)
582
583 src.balance -= amount
584 dst.balance += amount
585
586 // Resolved once each. When both sides vote their own balance — the
587 // overwhelmingly common case — these are the records already in hand, so a
588 // checkpointed transfer dirties the same two objects a plain one would. The one
589 // exception is a side's FIRST write in a new epoch, which also rolls one point
590 // into the archive (one added page); amortised across the epoch's writes that
591 // 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)
594
595 chain.Emit(transferEvent,
596 "token", l.id,
597 "from", from.String(),
598 "to", to.String(),
599 "value", strconv.Itoa(int(amount)),
600 )
601}
602
603func delegateeOf(self address, a *account) address {
604 if a.delegate == "" {
605 return self
606 }
607 return a.delegate
608}
609
610// addVotes moves voting power and checkpoints it.
611func (l *Ledger) addVotes(who address, delta int64) {
612 if delta == 0 {
613 return
614 }
615 a := l.openAccount(who)
616 next := a.votes.Value() + delta
617 if next < 0 {
618 panic("grc20votes: voting power would go negative")
619 }
620 a.votes.SetAt(l.archive, string(who), l.Epoch(), next)
621}
622
623func (l *Ledger) getAccount(who address) *account {
624 if v := l.accounts.Get(string(who)); v != nil {
625 return v.(*account)
626 }
627 return nil
628}
629
630func (l *Ledger) openAccount(who address) *account {
631 if a := l.getAccount(who); a != nil {
632 return a
633 }
634 a := &account{}
635 l.accounts.Set(string(who), a)
636 return a
637}
638
639func mustBeValid(a address) {
640 if !a.IsValid() {
641 panic("grc20votes: not an address")
642 }
643}
644
645func mustBePositive(amount int64) {
646 if amount <= 0 {
647 panic("grc20votes: amount must be positive")
648 }
649}