budgets.gno
46.34 Kb · 1220 lines
1// Realm budgets is a delegated-budget treasury in GNOT: an owner funds a
2// pool, grants named delegates a standing, rate-limited, revocable
3// authority to spend from it, and each delegate then draws within that
4// authority with no further approval from anyone.
5//
6// WHY THIS EXISTS (see DISCOVERY.md): every disbursement path on either
7// live chain is one of three shapes — a fresh group decision per spend
8// (this portfolio's treasury_board, gno.land's r/gov/dao/treasury, the
9// mainnet bubbletreasury), a fixed schedule (vesting), or a pull against
10// an already-settled entitlement (fee_split, revshare, crowdfund,
11// subscriptions). None of them lets one party spend another's money at
12// its own discretion within a bound. That is the gap, and
13// bubbletreasury — a real mainnet treasury that hand-rolled a sliding
14// spend window inside the realm, with every spend still a token-holder
15// proposal — is the evidence someone wanted it.
16//
17// It is also, deliberately, the FIRST CONSUMER of spendbook on any
18// chain. A primitive with no consumer has two properties unproven no
19// matter how many unit tests pass: that its API is usable from a realm,
20// and that its state survives across transactions. Only a realm can
21// answer either, and this one is the answer.
22//
23// COMPOSITION: spending authority is delegated entirely to spendbook
24// (anchored windows, lifetime caps, expiry, one-sided amendments,
25// revocation), coin movement to coinio, per-delegate credit and the fee
26// pot to feeledger, ordered storage to p/nt/avl/v0, and free-text render
27// output to p/nt/markdown/sanitize/v0. This realm owns only the pool
28// accounting, the grant records, and the solvency rule.
29//
30// # What a budget is, and what it is not
31//
32// A budget is AUTHORITY, not a reservation. Three grants of 100 against
33// a pool of 100 are all three valid, and the first delegate to draw gets
34// the money. This is not a defect to be fixed by reserving funds: a
35// reservation would make an owner unable to grant a standing allowance
36// without pre-committing the capital, which is the whole convenience
37// being bought. What the realm guarantees instead is that it never
38// PROMISES what it does not HOLD:
39//
40// Draw refuses unless amount <= the owner's own unspent pool.
41//
42// So a delegate is told "no" at draw time rather than credited with
43// something unclaimable. The pool is the solvency bound and it is
44// per-owner, which is strictly stronger than a realm-wide check: one
45// owner's grants can never reach another owner's funds.
46//
47// # Credit, then pull
48//
49// Draw moves nothing. It consumes the allowance, debits the owner's
50// pool, and credits the delegate in the ledger; the delegate then signs
51// a separate Claim to receive the coins. This is not ritual. If Draw
52// paid out directly, a failing payout inside the same transaction would
53// leave a consumed allowance with no money delivered, or force the
54// allowance consumption to happen after an effect — and spendbook's own
55// consumer contract item 3 says state first, effect second, for exactly
56// that reason. Splitting it also means a delegate can draw repeatedly
57// and claim once, which is cheaper than claiming per draw.
58//
59// # LIFECYCLE
60//
61// Fund (owner, +send) : adds real coins to the caller's own pool.
62// Anyone may fund; funding makes you an
63// owner. Minimum MinFunding.
64// WithdrawPool (owner) : pulls unspent pool back out, any amount,
65// any time. See "the owner can always
66// leave" below.
67// WithdrawPoolAll (owner) : the whole pool.
68// Grant (owner) : opens one spendbook allowance for one
69// delegate and returns the grant id. The
70// draw fee is snapshotted here.
71// Amend (owner) : changes the grant's terms. Tighter-or-
72// equal applies now; looser waits for the
73// next window boundary. PeriodLength is
74// NOT a parameter — spendbook makes it
75// immutable and this realm makes that
76// structural.
77// Revoke (owner) : immediate, and the id is never reused.
78// Draw (DELEGATE) : consumes authority, debits the pool,
79// credits the delegate net of the fee.
80// Claim (DELEGATE) : pays the delegate's whole credit out.
81// Reap (ANYONE) : removes an EXPIRED grant, freeing its
82// slot. Permissionless because an expired
83// allowance can authorize nothing, so
84// removing it takes nothing from anyone.
85// WithdrawFees (ANYONE) : pays the fee pot to the compile-time
86// FeeCollector. Fixed destination, so
87// there is nothing to steer.
88// SweepSurplus (ANYONE) : out-of-band coins to the FeeCollector,
89// never touching tracked liabilities.
90//
91// # The owner can always leave, and that is the design
92//
93// WithdrawPool has no lock, no notice period, and no check against
94// outstanding grants. An owner can empty the pool under a live grant and
95// the delegate's next Draw will fail on solvency. This is stated plainly
96// rather than buried: the money is the owner's, Revoke is the clean way
97// to end a relationship, and a realm that could trap an owner's capital
98// behind a grant they regret would be a worse realm. A delegate who
99// needs a guarantee of funds does not want a budget — they want escrow,
100// which is a different artifact.
101//
102// What the owner CANNOT do is undo work already authorized: credit
103// already drawn into the ledger is the delegate's, and Revoke does not
104// claw it back. Revoking a grant ends future authority only.
105//
106// # THE FEE, precisely
107//
108// feeBps is snapshotted into the grant at Grant time from the
109// compile-time DrawFeeBps, so a delegate's terms are fixed when the
110// grant is created and visible in GrantInfo from that moment. The fee is
111// charged ONCE per draw, on the gross amount, with feeledger's floor
112// rounding (rounding favors the delegate). The owner's pool is debited
113// the GROSS amount and the delegate is credited the net — the fee is
114// paid by the owner out of the budget, not taken out of the delegate's
115// authority, so a grant of PerPeriod means PerPeriod of the owner's
116// money leaves the pool per window, which is the figure an owner
117// actually cares about bounding. The ledger is constructed with the
118// compile-time MaxFeeBps cap, so a fee above the cap is refused by the
119// primitive, not by a check in this file. There is no fee on funding, on
120// withdrawing an unspent pool, or on claiming.
121//
122// # THERE IS NO ADMIN
123//
124// The fee rate, the fee collector, every bound and every cap are
125// compile-time constants. No address can change terms, pause the realm,
126// touch a pool it did not fund, or move a grant it did not create. The
127// deployer has no privilege of any kind after deployment.
128//
129// # MONETARY INVARIANT (conservation)
130//
131// Let H be the ugnot held at this realm's address, P the sum of all
132// owner pools (tracked O(1) as totalPooled), L the ledger's liabilities
133// (unclaimed delegate credit plus the fee pot), and S >= 0 the
134// out-of-band surplus:
135//
136// H == P + L + S
137//
138// Every transition moves value between exactly two terms inside one
139// transaction. Fund raises H and P together (coinio.Receive is the
140// receipt-guaranteed shape). WithdrawPool debits P before the identical
141// amount leaves H. Draw moves gross out of P and into L, split into the
142// delegate's credit and the fee — H does not move at all, which is why
143// Draw has no payout to fail. Claim and WithdrawFees debit L before
144// their payout. Any panic aborts the whole transaction; this realm never
145// issues or removes coins.
146//
147// # ONLY GNOT IS ACCEPTED
148//
149// Fund rejects any envelope that is not exactly one positive ugnot coin
150// (coinio.Receive). Every other entrypoint rejects attached coins
151// outright rather than converting them into sweepable surplus.
152//
153// # AUTHORIZATION
154//
155// Every identity is derived from the crossing entrypoint's
156// cur.Previous().Address(). No function takes a caller identity as a
157// parameter, so funding another owner's pool, drawing another delegate's
158// budget, claiming another delegate's credit, or amending a grant you do
159// not own is impossible by construction. This is spendbook consumer
160// contract item 2, closed structurally.
161//
162// The clock is runtime.ChainHeight(), never an argument. That is
163// spendbook consumer contract item 1: a realm that let a caller choose
164// `now` would have handed the delegate the power to roll the window at
165// will, defeating every cap in the primitive.
166//
167// # STORAGE
168//
169// A pool entry is paid for by the owner's own funding transaction; a
170// grant record and its allowance by the owner's Grant. Revoke and Reap
171// both remove the grant record and the allowance in the same call, and
172// the chain refunds the freed storage deposit to the transaction that
173// frees it — which is the standing incentive to call Reap on someone
174// else's expired grant.
175package budgets
176
177import (
178 "chain"
179 "chain/runtime"
180 "chain/runtime/unsafe"
181 "strconv"
182 "strings"
183
184 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
185 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
186 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/spendbook"
187 "gno.land/p/nt/avl/v0"
188 "gno.land/p/nt/markdown/sanitize/v0"
189)
190
191// RealmPath is this realm's own path, used to build Render links.
192const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/budgets"
193
194// Denom is the only asset this realm holds.
195const Denom = "ugnot"
196
197// MaxFeeBps is the hard ceiling on any draw fee this realm could ever
198// charge, enforced by the feeledger the realm is constructed with — a
199// feeBps above it is refused by the primitive's own validation, not by a
200// check in this file. 500 bps = 5%.
201const MaxFeeBps = int64(500)
202
203// DrawFeeBps is the fee actually snapshotted into every grant: 100 bps =
204// 1%, charged once per draw on the gross amount, floor-rounded in the
205// delegate's favor. The most conservative live fee in this portfolio
206// (fee_split, crowdfund) is also 1%.
207const DrawFeeBps = int64(100)
208
209// FeeCollector receives withdrawn fees and swept surplus. Compile-time
210// constant: there is no setter and no transfer path. It is the deploying
211// namespace's address — stated plainly: the operator of this realm.
212const FeeCollector = address("g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3")
213
214// MinFunding keeps dust pools out: 1 GNOT per Fund call.
215const MinFunding = int64(1_000_000)
216
217// MinPerPeriod is the smallest per-window cap a grant may carry. It
218// exists so a grant cannot be created that no draw could ever satisfy,
219// and it is the ONLY lower bound in the draw path — there is
220// deliberately no minimum Draw amount, because a minimum draw above a
221// grant's PerPeriod would make the grant silently undrawable.
222const MinPerPeriod = int64(1_000)
223
224// MinPeriodBlocks is the shortest rolling window a grant may use. A
225// window of a handful of blocks is a rate, not a budget, and it makes
226// PerPeriod meaningless as a bound on worst-case loss. A periodLength of
227// 0 is still accepted and means ONE LIFETIME WINDOW that never refills
228// (spendbook's own semantics).
229const MinPeriodBlocks = int64(100)
230
231// MaxLiveGrants caps simultaneously live grants, via spendbook's own
232// construction bound. Grant is permissionless to anyone with a funded
233// pool, so the cap is exhaustible in principle, and that AVAILABILITY
234// BOUND is documented rather than hidden.
235//
236// It is documented here in its CORRECTED form. An earlier version of
237// this comment claimed the mitigation was "MaxGrantsPerOwner plus the
238// cost of becoming an owner at all (MinFunding per pool), and Reap is
239// permissionless so expired grants cannot accumulate". An adversarial
240// audit (finding Y1, DISCOVERY.md § 9) showed BOTH HALVES were false. A
241// wrong mitigation is worse than an acknowledged bound, because a reader
242// sizes the risk from it and stops looking:
243//
244// - MinFunding is NOT a capital bound. The funded-pool requirement is
245// checked at Grant time only, and WithdrawPool has no lock, so one
246// address funds a pool, opens MaxGrantsPerOwner grants, withdraws
247// the pool in full, and the grants survive holding their slots.
248// TestOwnerCanAlwaysLeave pins exactly that sequence, as intended
249// behavior — the attack is built out of a documented feature.
250// - Reap is inert against it. Reap requires spendbook's IsExpired,
251// which is never true for expiresAt == 0, so an attacker omits the
252// expiry and no third party can remove the grant.
253//
254// What the cost ACTUALLY is: gas for one Fund, MaxGrantsPerOwner Grants
255// and one WithdrawPoolAll per address — about MaxLiveGrants*17/16
256// transactions to fill the cap — plus the storage deposit on every grant
257// record, which the chain holds for as long as the grant does and
258// refunds only when it is removed. That is real and unrecoverable while
259// the grief lasts. It is not the locked pool capital the old comment
260// promised, and it is priced in gas and storage rather than in capital.
261//
262// No capital bound was added, because the obvious one does not work.
263// Requiring an owner with live grants to retain MinFunding is defeated
264// for the price of the draw fee: nothing forbids granting to YOURSELF,
265// so the attacker draws its own pool to zero through its own grant and
266// Claims the credit back, recovering all but DrawFeeBps of it while the
267// retention rule still reads as satisfied. Closing THAT would mean
268// bounding Draw below MinFunding — the realm refusing to let a delegate
269// spend the budget, which is the feature. The remaining alternative, a
270// non-refundable per-grant fee, taxes every honest owner to price a
271// grief, and at a fee high enough to deter one it is a tenth of
272// MinFunding per grant.
273//
274// The blast radius is narrow and that is the reason this is disclosed
275// rather than redesigned: a full book blocks NEW grants only. Every
276// existing grant, pool, draw, credit and claim keeps working, no value
277// is at risk, and any owner can free their own slots with Revoke.
278//
279// The figure was 1024 until that audit, and 1024 was conservative rather
280// than measured: nothing in this realm iterates the book (Render is
281// bounded by MaxRenderRows, every write path is a single avl lookup) and
282// a grant record's storage is paid for by whoever creates it.
283// Quadrupling it quadruples the cost of filling the cap and costs an
284// honest owner nothing. spendbook's own MaxLiveLimit is 10000.
285const MaxLiveGrants = 4096
286
287// MaxGrantsPerOwner bounds how much of the global cap one owner can
288// occupy. spendbook's own package documentation names this as the gap a
289// consuming realm must close — "maxLive is a single global figure, so
290// any owner permitted to Open can exhaust it and lock out the rest …
291// the consuming realm decides who may Open, and a Book shared across
292// mutually untrusting owners needs its own per-owner cap on top of this
293// one." This is that cap. (permission_registry finding R1 is the same
294// lesson from this portfolio's own history.)
295const MaxGrantsPerOwner = int64(16)
296
297// MaxLabelLen bounds the grant's free-text label, which is sanitized
298// before reaching markdown.
299const MaxLabelLen = 64
300
301// MaxRenderRows bounds the grant list on the index page.
302const MaxRenderRows = 20
303
304type grant struct {
305 id int64
306 owner address
307 spender address
308 allowID uint64 // the spendbook allowance backing this grant
309 feeBps int64 // snapshotted at Grant
310 label string
311 createdAt int64
312
313 drawn int64 // lifetime gross drawn under this grant
314 draws int64 // number of successful draws
315}
316
317var (
318 self address
319
320 // grants holds one record per live grant. Revoke and Reap remove the
321 // record; ids are never reused.
322 grants = avl.NewTree() // padID(id) -> *grant
323 nextID int64
324
325 // pools is each owner's unspent funded balance:
326 // address string -> int64 (> 0). This is the P term.
327 pools = avl.NewTree()
328
329 // openByOwner tracks each owner's live grant count:
330 // address string -> int64 (> 0).
331 openByOwner = avl.NewTree()
332
333 // totalPooled is the P term of H == P + L + S, maintained O(1).
334 totalPooled int64
335
336 // lifetime counters, rendered for operators.
337 lifetimeFunded int64
338 lifetimeDrawn int64 // gross, before the fee
339 lifetimeClaimed int64 // net, paid to delegates
340 lifetimeReturned int64 // unspent pool withdrawn by owners
341
342 // ledger holds each delegate's unclaimed credit and the fee pot. Its
343 // Liabilities() is the L term and the reserve SweepSurplus protects.
344 ledger = feeledger.MustNew(MaxFeeBps)
345
346 // book holds one live allowance per live grant. Every bound on
347 // authority — windows, caps, expiry, amendment direction — is the
348 // primitive's, not this file's.
349 book = spendbook.MustNew(MaxLiveGrants)
350)
351
352func init() {
353 self = unsafe.CurrentRealm().Address()
354}
355
356// rejectStraySend aborts when coins are attached to a non-payable call.
357// This realm holds funds, so a stray send would become sweepable
358// surplus, silently converting a user's coins into operator funds.
359// Aborting reverts the transfer instead. Guarded on IsUserCall, not
360// IsUser, because a MsgRun ephemeral can consume the OriginSend envelope
361// before forwarding control; for a realm-routed call the envelope lands
362// at the intermediary, so there is nothing here to reject and the guard
363// deliberately fails open (crowdfund and treasury_board document the
364// same scope).
365func rejectStraySend(cur realm) {
366 if !cur.IsCurrent() {
367 // Every call site forwards a crossing entrypoint's own live cur,
368 // so this cannot fire today; it exists so a future refactor that
369 // hands this guard a stale realm value fails closed, not open
370 // (coinio names IsCurrent as the guard secondary realm
371 // parameters require, and this helper follows that discipline).
372 panic("budgets: realm capability is not current")
373 }
374 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
375 panic("this entrypoint does not accept coins")
376 }
377}
378
379// --- the pool ---
380
381// Fund adds the attached coins to the caller's own pool. Direct EOA
382// calls with -send only (the receipt-guaranteed shape); exactly one
383// positive ugnot coin, at least MinFunding. Anyone may fund; funding is
384// what makes an address an owner.
385func Fund(cur realm) int64 {
386 owner, amount := coinio.Receive(0, cur, Denom)
387 if amount < MinFunding {
388 panic("fund at least " + itoa(MinFunding) + Denom)
389 }
390 key := owner.String()
391
392 newPool, ok := checkedAdd(poolOf(key), amount)
393 if !ok {
394 panic("pool would overflow")
395 }
396 newTotal, ok := checkedAdd(totalPooled, amount)
397 if !ok {
398 panic("total pooled would overflow")
399 }
400 newLifetime, ok := checkedAdd(lifetimeFunded, amount)
401 if !ok {
402 panic("lifetime funded would overflow")
403 }
404
405 setPool(key, newPool)
406 totalPooled = newTotal
407 lifetimeFunded = newLifetime
408
409 chain.Emit("Funded", "owner", key, "amount", itoa(amount),
410 "pool", itoa(newPool))
411 return newPool
412}
413
414// WithdrawPool returns part of the caller's unspent pool and reports the
415// pool REMAINING afterwards. There is no lock and no check against
416// outstanding grants: the money is the owner's, and a delegate's
417// authority is not a claim on it (see the package doc, "the owner can
418// always leave"). Revoke is the clean way to end a grant.
419func WithdrawPool(cur realm, amount int64) int64 {
420 rejectStraySend(cur)
421 owner := cur.Previous().Address()
422 if amount <= 0 {
423 panic("amount must be positive")
424 }
425 return payOutPool(cur, owner, amount)
426}
427
428// WithdrawPoolAll returns the caller's entire unspent pool and reports the
429// amount WITHDRAWN.
430//
431// The return values of the two withdraw entrypoints are deliberately
432// asymmetric, which is worth naming rather than letting a caller discover:
433// each returns the figure its caller does not already have. WithdrawPool
434// was told the amount and wants to know what is left; WithdrawPoolAll
435// knows what is left (nothing) and wants to know what it got. Returning
436// the remainder here would hand every caller a constant 0.
437func WithdrawPoolAll(cur realm) int64 {
438 rejectStraySend(cur)
439 owner := cur.Previous().Address()
440 amount := poolOf(owner.String())
441 if amount == 0 {
442 panic("no pool to withdraw")
443 }
444 payOutPool(cur, owner, amount)
445 return amount
446}
447
448// payOutPool is the shared debit-then-send path: the pool and the P term
449// are reduced before the identical amount leaves the realm
450// (checks-effects-interactions).
451func payOutPool(cur realm, owner address, amount int64) int64 {
452 key := owner.String()
453 pool := poolOf(key)
454 if amount > pool {
455 panic("amount exceeds pool")
456 }
457 newReturned, ok := checkedAdd(lifetimeReturned, amount)
458 if !ok {
459 panic("lifetime returned would overflow")
460 }
461
462 setPool(key, pool-amount)
463 totalPooled -= amount
464 lifetimeReturned = newReturned
465
466 coinio.Payout(0, cur, owner, Denom, amount)
467
468 chain.Emit("PoolWithdrawn", "owner", key, "amount", itoa(amount),
469 "pool", itoa(pool-amount))
470 return pool - amount
471}
472
473// --- grants ---
474
475// Grant opens a standing spending authority for one delegate and returns
476// the grant id. The caller becomes the grant's owner and must already
477// have a funded pool — not because the grant reserves anything (it does
478// not), but because a grant from an address that has never funded
479// anything is a promise with nothing behind it, and this realm declines
480// to record one.
481//
482// Terms, all in the chain's own units:
483//
484// perPeriod the cap per window, at least MinPerPeriod. Required.
485// periodLength blocks per window. 0 means ONE LIFETIME WINDOW that
486// never refills; otherwise at least MinPeriodBlocks.
487// total lifetime cap across every window. 0 means unlimited.
488// expiresAt absolute block height at which authority ends. 0 means
489// no expiry.
490//
491// Every bound above except the two realm minimums is validated and
492// enforced by spendbook, and an invalid combination surfaces as that
493// package's own sentinel error.
494func Grant(cur realm, spender address, label string,
495 perPeriod, periodLength, total, expiresAt int64) int64 {
496 rejectStraySend(cur)
497 owner := cur.Previous().Address()
498
499 // The lowercase clause is load-bearing, not decoration, and the reason
500 // was measured on this toolchain rather than argued: address.IsValid()
501 // defers to crypto.AddressFromBech32, whose decoder lowercases the
502 // input BEFORE verifying the checksum, so it returns true for the
503 // all-uppercase spelling of a valid address. Here the hazard is not a
504 // double count (as in fee_split/v2 and treasury_board, where the
505 // address was a vote-bearing key) but a BRICKED GRANT: Draw
506 // authenticates against cur.Previous().Address(), which is always
507 // canonical, so an uppercase-spelled delegate could never draw, while
508 // the grant still occupied a MaxGrantsPerOwner slot and a storage
509 // deposit. The check is complete as well as necessary — that decoder
510 // rejects mixed case outright, so a valid address has exactly two
511 // spellings and rejecting every non-lower one leaves precisely the
512 // canonical one.
513 if !spender.IsValid() || string(spender) != strings.ToLower(string(spender)) {
514 panic("spender is not a valid address in canonical (lowercase) form")
515 }
516 if len(label) > MaxLabelLen {
517 panic("label exceeds " + strconv.Itoa(MaxLabelLen) + " bytes")
518 }
519 if perPeriod < MinPerPeriod {
520 panic("perPeriod must be at least " + itoa(MinPerPeriod) + Denom)
521 }
522 if periodLength != 0 && periodLength < MinPeriodBlocks {
523 panic("periodLength must be 0 (lifetime) or at least " +
524 itoa(MinPeriodBlocks) + " blocks")
525 }
526
527 key := owner.String()
528 if poolOf(key) == 0 {
529 panic("fund a pool before granting")
530 }
531 if ownerOpen(key) >= MaxGrantsPerOwner {
532 panic("owner already has " + itoa(MaxGrantsPerOwner) + " live grants")
533 }
534
535 now := runtime.ChainHeight()
536 allowID, err := book.Open(key, spender.String(), now, spendbook.Terms{
537 PerPeriod: perPeriod,
538 PeriodLength: periodLength,
539 Total: total,
540 ExpiresAt: expiresAt,
541 })
542 if err != nil {
543 panic(err)
544 }
545
546 id := nextID + 1
547 nextID = id
548 grants.Set(padID(id), &grant{
549 id: id,
550 owner: owner,
551 spender: spender,
552 allowID: allowID,
553 feeBps: DrawFeeBps,
554 label: label,
555 createdAt: now,
556 })
557 setOwnerOpen(key, ownerOpen(key)+1)
558
559 chain.Emit("Granted", "id", itoa(id), "owner", key,
560 "spender", spender.String(), "perPeriod", itoa(perPeriod),
561 "periodLength", itoa(periodLength), "total", itoa(total),
562 "expiresAt", itoa(expiresAt), "feeBps", itoa(DrawFeeBps))
563 return id
564}
565
566// Amend changes a grant's terms. Owner only. The returned bool reports
567// whether the new terms are in force NOW (a tighter-or-equal amendment)
568// or queued for the next window boundary (a looser one) — spendbook's
569// one-sided rule, and the distinction worth knowing.
570//
571// periodLength is NOT a parameter. spendbook makes it immutable because
572// moving it would move every future boundary, which is an enlargement
573// vector wearing a different hat; this realm reads the grant's own
574// periodLength and passes it unchanged, so changing it is not merely
575// refused, it is unexpressible.
576func Amend(cur realm, id int64, perPeriod, total, expiresAt int64) bool {
577 rejectStraySend(cur)
578 owner := cur.Previous().Address()
579 g := mustGet(id)
580 if owner != g.owner {
581 panic("owner only")
582 }
583 if perPeriod < MinPerPeriod {
584 panic("perPeriod must be at least " + itoa(MinPerPeriod) + Denom)
585 }
586
587 a, err := book.Get(g.allowID)
588 if err != nil {
589 panic(err)
590 }
591 applied, err := book.Amend(g.allowID, owner.String(), runtime.ChainHeight(),
592 spendbook.Terms{
593 PerPeriod: perPeriod,
594 PeriodLength: a.Terms.PeriodLength, // immutable
595 Total: total,
596 ExpiresAt: expiresAt,
597 })
598 if err != nil {
599 panic(err)
600 }
601
602 chain.Emit("Amended", "id", itoa(g.id), "owner", owner.String(),
603 "perPeriod", itoa(perPeriod), "total", itoa(total),
604 "expiresAt", itoa(expiresAt), "applied", boolstr(applied))
605 return applied
606}
607
608// Revoke ends a grant immediately. Owner only. The id is never reused,
609// so a revoked grant cannot be resurrected and a later Draw against it
610// fails as unknown.
611//
612// Revoking does NOT claw back credit the delegate has already drawn —
613// that is theirs and stays claimable. Revoke ends future authority.
614func Revoke(cur realm, id int64) {
615 rejectStraySend(cur)
616 owner := cur.Previous().Address()
617 g := mustGet(id)
618 if owner != g.owner {
619 panic("owner only")
620 }
621 // Owner-gated twice on purpose: the check above is for the error
622 // message, the primitive's is the authoritative one.
623 if err := book.Revoke(g.allowID, owner.String()); err != nil {
624 panic(err)
625 }
626 drop(g)
627
628 chain.Emit("Revoked", "id", itoa(g.id), "owner", owner.String(),
629 "spender", g.spender.String(), "drawn", itoa(g.drawn))
630}
631
632// Reap removes a grant whose authority has EXPIRED, freeing its slot and
633// its storage. Anyone may call it, and the reasoning is spendbook's own:
634// an expired allowance can never authorize another draw, so removing it
635// takes nothing from the owner and grants nothing to the caller. The
636// chain refunds the freed storage deposit to the reaping transaction,
637// which is the standing incentive.
638//
639// A grant with no expiry can never be reaped; only its owner can end it.
640func Reap(cur realm, id int64) {
641 rejectStraySend(cur)
642 g := mustGet(id)
643 if err := book.Reap(g.allowID, runtime.ChainHeight()); err != nil {
644 panic(err)
645 }
646 drop(g)
647
648 chain.Emit("Reaped", "id", itoa(g.id), "owner", g.owner.String(),
649 "by", cur.Previous().Address().String())
650}
651
652// --- drawing ---
653
654// Draw spends amount from the grant's budget. DELEGATE ONLY: the spender
655// is the calling address, never an argument. Nothing is paid out here —
656// the delegate is credited net of the fee and claims separately.
657//
658// Three things must all hold and they fail in this order, which matters:
659// the caller must be the grant's delegate and the amount must be within
660// every spendbook bound (window cap, lifetime cap, expiry, start); and
661// the owner's pool must cover the GROSS amount. The solvency check runs
662// BEFORE the allowance is consumed, so a draw refused for insolvency
663// leaves the budget untouched rather than burning window capacity on a
664// draw that did not happen.
665func Draw(cur realm, id int64, amount int64) int64 {
666 rejectStraySend(cur)
667 spender := cur.Previous().Address()
668 g := mustGet(id)
669 if spender != g.spender {
670 panic("delegate only")
671 }
672 if amount <= 0 {
673 panic("amount must be positive")
674 }
675
676 ownerKey := g.owner.String()
677 pool := poolOf(ownerKey)
678 if amount > pool {
679 // A budget is authority, not a reservation (package doc). The
680 // honest failure is here, at draw time, rather than a credit the
681 // delegate could not claim.
682 panic("owner's pool cannot cover this draw")
683 }
684
685 // Authority. spendbook checks every bound and CONSUMES the allowance
686 // in this one call, before returning; on any error nothing is
687 // consumed. Its sentinel errors name the reason.
688 if err := book.Spend(g.allowID, spender.String(),
689 runtime.ChainHeight(), amount); err != nil {
690 panic(err)
691 }
692
693 newDrawn, ok := checkedAdd(g.drawn, amount)
694 if !ok {
695 panic("grant drawn total would overflow")
696 }
697 newLifetime, ok := checkedAdd(lifetimeDrawn, amount)
698 if !ok {
699 panic("lifetime drawn would overflow")
700 }
701
702 // Move the gross out of P and into L. H does not move, which is why
703 // this transition has no payout to fail.
704 setPool(ownerKey, pool-amount)
705 totalPooled -= amount
706 credited, fee := ledger.MustDeposit(spender.String(), amount, g.feeBps)
707 g.drawn = newDrawn
708 g.draws++
709 lifetimeDrawn = newLifetime
710
711 chain.Emit("Drawn", "id", itoa(g.id), "spender", spender.String(),
712 "gross", itoa(amount), "credited", itoa(credited), "fee", itoa(fee),
713 "pool", itoa(pool-amount))
714 return credited
715}
716
717// Claim pays the caller's entire accrued credit out. DELEGATE ONLY: the
718// account is the calling address. Credit from a grant that has since
719// been revoked, expired or reaped is still claimable — it was earned
720// under a live authority and this realm does not revisit that.
721func Claim(cur realm) int64 {
722 rejectStraySend(cur)
723 account := cur.Previous().Address()
724 key := account.String()
725
726 amount, err := ledger.WithdrawAll(key)
727 if err != nil {
728 panic(err)
729 }
730 if amount == 0 {
731 panic("no credit to claim")
732 }
733 newClaimed, ok := checkedAdd(lifetimeClaimed, amount)
734 if !ok {
735 panic("lifetime claimed would overflow")
736 }
737 lifetimeClaimed = newClaimed
738
739 coinio.Payout(0, cur, account, Denom, amount)
740
741 chain.Emit("Claimed", "account", key, "amount", itoa(amount))
742 return amount
743}
744
745// --- fees and surplus ---
746
747// WithdrawFees pays the entire accrued fee pot to the compile-time
748// FeeCollector. Anyone may call it: the destination is fixed, so there
749// is nothing for a caller to steer.
750func WithdrawFees(cur realm) int64 {
751 rejectStraySend(cur)
752 fees := ledger.WithdrawFees()
753 if fees == 0 {
754 panic("no fees accrued")
755 }
756 coinio.Payout(0, cur, FeeCollector, Denom, fees)
757 chain.Emit("FeesWithdrawn", "to", FeeCollector.String(), "amount", itoa(fees))
758 return fees
759}
760
761// SweepSurplus sends out-of-band coins to the FeeCollector: for the pool
762// denom, everything above tracked liabilities; for any foreign denom,
763// the full balance. Owner pools, delegate credit and the fee pot are
764// untouchable by construction (coinio.Sweep refuses to dip below the
765// reserve). Anyone may call it.
766//
767// With nothing out of band this ABORTS ("coinio: no surplus to sweep for
768// <denom>") rather than returning 0. That is the primitive's choice and
769// the right one: a sweep is a transfer, and a transfer of nothing that
770// reports success is how a caller comes to believe a balance was moved.
771func SweepSurplus(cur realm, denom string) int64 {
772 rejectStraySend(cur)
773 reserve := int64(0)
774 if denom == Denom {
775 reserve = Liabilities()
776 }
777 swept := coinio.Sweep(0, cur, FeeCollector, denom, reserve)
778 chain.Emit("Swept", "denom", denom, "to", FeeCollector.String(),
779 "amount", itoa(swept))
780 return swept
781}
782
783// --- views ---
784
785// GrantInfo returns a grant's parties and the terms currently recorded
786// for it. perPeriod reflects a tightening amendment immediately; a
787// queued loosening shows in PendingTerms until its boundary arrives.
788func GrantInfo(id int64) (owner, spender address, perPeriod, periodLength,
789 total, expiresAt, anchor, createdAt, feeBps int64) {
790 g := mustGet(id)
791 a := mustAllowance(g)
792 return g.owner, g.spender, a.Terms.PerPeriod, a.Terms.PeriodLength,
793 a.Terms.Total, a.Terms.ExpiresAt, a.Anchor, g.createdAt, g.feeBps
794}
795
796// GrantUsage returns a grant's consumption AT THE CURRENT HEIGHT: what the
797// window the chain is in right now has spent, the lifetime gross, that
798// window's index, and how many draws there have been.
799//
800// The two window figures are computed rather than read straight off the
801// record, and that is the whole point of windowAt below: a window that has
802// rolled with no draw since leaves Allowance.WindowIndex naming a window
803// the chain has already left, and Allowance.SpentWindow holding the
804// previous window's spend. spentTotal and draws are cumulative and never
805// go stale.
806func GrantUsage(id int64) (spentWindow, spentTotal, windowIndex, draws int64) {
807 g := mustGet(id)
808 a := mustAllowance(g)
809 now := runtime.ChainHeight()
810 return spentInWindow(a, now), a.SpentTotal, windowAt(a, now), g.draws
811}
812
813// GrantLabel returns a grant's raw label (unsanitized: callers rendering
814// it into markdown must sanitize, as this realm's Render does).
815func GrantLabel(id int64) string { return mustGet(id).label }
816
817// Remaining is the AUTHORITY figure: what the grant's terms permit right
818// now, ignoring whether the owner's pool can cover it.
819func Remaining(id int64) int64 {
820 g := mustGet(id)
821 return book.Remaining(g.allowID, runtime.ChainHeight())
822}
823
824// Drawable is the HONEST figure, and the one a delegate should read: the
825// lesser of what the grant authorizes and what the owner's pool holds.
826// This distinction is the realm's own — spendbook bounds permission and
827// says so, never solvency.
828func Drawable(id int64) int64 {
829 g := mustGet(id)
830 authorized := book.Remaining(g.allowID, runtime.ChainHeight())
831 pool := poolOf(g.owner.String())
832 if pool < authorized {
833 return pool
834 }
835 return authorized
836}
837
838// PendingTerms returns a queued loosening amendment and whether there is
839// one. An amendment is queued only when it would widen the live window,
840// and it takes effect at the next window boundary.
841func PendingTerms(id int64) (perPeriod, total, expiresAt int64, pending bool) {
842 a := mustAllowance(mustGet(id))
843 if !a.HasPending {
844 return 0, 0, 0, false
845 }
846 return a.Pending.PerPeriod, a.Pending.Total, a.Pending.ExpiresAt, true
847}
848
849// IsExpired reports whether a grant's authority has lapsed, which is
850// also the condition under which anyone may Reap it.
851func IsExpired(id int64) bool {
852 return mustAllowance(mustGet(id)).IsExpired(runtime.ChainHeight())
853}
854
855// PoolOf returns an owner's unspent pool.
856func PoolOf(addr address) int64 { return poolOf(addr.String()) }
857
858// CreditOf returns a delegate's drawn-but-unclaimed credit.
859func CreditOf(addr address) int64 { return ledger.BalanceOf(addr.String()) }
860
861// GrantsOf returns how many live grants an owner currently holds.
862func GrantsOf(addr address) int64 { return ownerOpen(addr.String()) }
863
864// NumGrants returns how many grants have ever been created. Ids are
865// never reused, so this only grows.
866func NumGrants() int64 { return nextID }
867
868// LiveGrants returns how many grants are currently live, from the
869// primitive's own count.
870func LiveGrants() int64 { return int64(book.Count()) }
871
872// TotalPooled returns the P term: unspent owner funds.
873func TotalPooled() int64 { return totalPooled }
874
875// UnclaimedCredit returns the delegates' share of the L term.
876func UnclaimedCredit() int64 { return ledger.UsersTotal() }
877
878// FeesAccrued returns the fee pot: charged, not yet withdrawn.
879func FeesAccrued() int64 { return ledger.FeesAccrued() }
880
881// Liabilities returns P + L — everything this realm owes.
882func Liabilities() int64 { return totalPooled + ledger.Liabilities() }
883
884// Held returns H: the ugnot actually at this realm's address.
885func Held() int64 { return coinio.HeldAt(self, Denom) }
886
887// Surplus returns H - (P + L) — out-of-band coins, sweepable.
888func Surplus() int64 { return Held() - Liabilities() }
889
890// Address returns this realm's own address.
891func Address() address { return self }
892
893// Height returns the current chain height: the clock every window and
894// expiry is measured against, and the one no caller can choose.
895func Height() int64 { return runtime.ChainHeight() }
896
897// --- render ---
898
899// Render shows the realm at "" and a grant page at "<id>". Labels are
900// free text and pass through the ecosystem sanitizer before hitting
901// markdown.
902func Render(path string) string {
903 if path != "" {
904 return renderGrant(path)
905 }
906 now := runtime.ChainHeight()
907 held := Held()
908 liab := Liabilities()
909 conservation := "OK"
910 if held < liab {
911 conservation = "VIOLATED"
912 }
913
914 out := "# Budgets\n\n"
915 out += "Delegated spending in GNOT. Fund a pool, grant a delegate a " +
916 "standing rate-limited authority over it, and they draw within " +
917 "that bound without asking anyone. Revocable immediately; the " +
918 "windows are anchored, so nobody resets a cap by choosing when " +
919 "to call.\n\n"
920 out += "## Terms (fixed at deploy; no admin, no setters)\n\n"
921 out += "- draw fee: " + bps(DrawFeeBps) + " of the gross draw, paid from the owner's budget (hard cap " + bps(MaxFeeBps) + ")\n"
922 out += "- no fee on funding, on withdrawing an unspent pool, or on claiming\n"
923 out += "- minimum funding: " + itoa(MinFunding) + Denom + " per call\n"
924 out += "- minimum per-window cap: " + itoa(MinPerPeriod) + Denom + "\n"
925 out += "- window length: 0 (one lifetime window) or at least " + itoa(MinPeriodBlocks) + " blocks\n"
926 out += "- live grants: " + itoa(LiveGrants()) + " of " + strconv.Itoa(MaxLiveGrants) +
927 " (per owner: " + itoa(MaxGrantsPerOwner) + ")\n"
928 out += "- current height: " + itoa(now) + "\n\n"
929 out += "## Accounting (H == P + L + S)\n\n"
930 out += "- pooled (P): " + itoa(totalPooled) + Denom + "\n"
931 out += "- unclaimed delegate credit: " + itoa(ledger.UsersTotal()) + Denom + "\n"
932 out += "- fee pot: " + itoa(ledger.FeesAccrued()) + Denom + "\n"
933 out += "- held (H): " + itoa(held) + Denom + "\n"
934 out += "- surplus (S): " + itoa(held-liab) + Denom + "\n"
935 out += "- conservation: " + conservation + "\n"
936 out += "- lifetime funded / drawn / claimed / returned: " +
937 itoa(lifetimeFunded) + " / " + itoa(lifetimeDrawn) + " / " +
938 itoa(lifetimeClaimed) + " / " + itoa(lifetimeReturned) + Denom + "\n\n"
939 out += "## Live grants\n\n"
940 if grants.Size() == 0 {
941 out += "None yet. Fund a pool, then Grant a delegate.\n"
942 } else {
943 shown := 0
944 expired := 0
945 grants.ReverseIterate("", "", func(_ string, v any) bool {
946 g := v.(*grant)
947 a, aerr := book.Get(g.allowID)
948 state := "live"
949 if aerr != nil {
950 state = "missing"
951 } else if a.IsExpired(now) {
952 state = "expired (reapable)"
953 expired++
954 }
955 out += "- [#" + itoa(g.id) + "](" + RealmPath + ":" + itoa(g.id) +
956 ") [" + state + "] " + sanitize.InlineText(labelOr(g)) +
957 " — drawable " + itoa(Drawable(g.id)) + Denom + "\n"
958 shown++
959 return shown >= MaxRenderRows
960 })
961 if shown < grants.Size() {
962 out += "\n> [!NOTE]\n> Showing the " + strconv.Itoa(shown) +
963 " most recent of " + strconv.Itoa(grants.Size()) +
964 " live grants (" + itoa(nextID) +
965 " ever created; revoked and reaped records are removed). " +
966 "Use GrantInfo(id) for any specific grant.\n"
967 }
968 if expired > 0 {
969 out += "\n## Keeper work\n\nAnyone may Reap(id) an expired grant " +
970 "to free its slot and reclaim its storage deposit: " +
971 strconv.Itoa(expired) + " of the grants above are reapable.\n"
972 }
973 }
974 return out
975}
976
977func renderGrant(path string) string {
978 id, err := strconv.ParseInt(path, 10, 64)
979 if err != nil {
980 return "> [!WARNING]\n> invalid grant id\n"
981 }
982 v := grants.Get(padID(id))
983 if v == nil {
984 return "> [!WARNING]\n> unknown grant id (never created, or revoked/reaped)\n"
985 }
986 g := v.(*grant)
987 a, gerr := book.Get(g.allowID)
988 if gerr != nil {
989 // Unreachable: drop() removes the record and the allowance
990 // together, in one call. Rendered rather than panicked so a
991 // corrupted pair is visible instead of breaking the page.
992 return "> [!CAUTION]\n> grant #" + itoa(g.id) +
993 " has no backing allowance; this is a bug. Reason: " +
994 sanitize.InlineText(gerr.Error()) + "\n"
995 }
996 now := runtime.ChainHeight()
997
998 out := "# Grant #" + itoa(g.id)
999 if g.label != "" {
1000 out += ": " + sanitize.InlineText(g.label)
1001 }
1002 out += "\n\n"
1003 out += "- owner: `" + g.owner.String() + "`\n"
1004 out += "- delegate: `" + g.spender.String() + "`\n"
1005 out += "- per-window cap: " + itoa(a.Terms.PerPeriod) + Denom + "\n"
1006 if a.Terms.PeriodLength == 0 {
1007 out += "- window: one lifetime window; the cap never refills\n"
1008 } else {
1009 out += "- window: " + itoa(a.Terms.PeriodLength) +
1010 " blocks, anchored at " + itoa(a.Anchor) +
1011 " (now in window " + itoa(windowAt(a, now)) + ")\n"
1012 }
1013 if a.Terms.Total == 0 {
1014 out += "- lifetime cap: none\n"
1015 } else {
1016 out += "- lifetime cap: " + itoa(a.Terms.Total) + Denom +
1017 " (" + itoa(a.SpentTotal) + " used)\n"
1018 }
1019 if a.Terms.ExpiresAt == 0 {
1020 out += "- expiry: none (only the owner can end it)\n"
1021 } else {
1022 out += "- expiry: block " + itoa(a.Terms.ExpiresAt) + "\n"
1023 }
1024 out += "- draw fee: " + bps(g.feeBps) + " (snapshotted at grant time)\n"
1025 out += "- spent this window: " + itoa(spentInWindow(a, now)) + Denom + "\n"
1026 out += "- drawn in total: " + itoa(g.drawn) + Denom + " over " +
1027 itoa(g.draws) + " draws\n"
1028 out += "- created at block: " + itoa(g.createdAt) + "\n"
1029 out += "- current height: " + itoa(now) + "\n\n"
1030
1031 authorized := a.Remaining(now)
1032 pool := poolOf(g.owner.String())
1033 out += "## What can be drawn right now\n\n"
1034 out += "- authorized by the terms: " + itoa(authorized) + Denom + "\n"
1035 out += "- owner's unspent pool: " + itoa(pool) + Denom + "\n"
1036 drawable := authorized
1037 if pool < drawable {
1038 drawable = pool
1039 }
1040 out += "- **drawable: " + itoa(drawable) + Denom + "** (the lesser of the two)\n\n"
1041
1042 switch {
1043 case a.IsExpired(now):
1044 out += "This grant has **expired**. Anyone may Reap(" + itoa(g.id) +
1045 ") to free its slot and reclaim its storage deposit.\n"
1046 case drawable == 0 && authorized == 0:
1047 out += "Nothing authorized at this height — the window cap is spent, " +
1048 "the lifetime cap is reached, or the grant has not started.\n"
1049 case drawable == 0:
1050 out += "Authorized but **not funded**: the owner's pool cannot cover " +
1051 "a draw. A budget is authority, not a reservation.\n"
1052 default:
1053 out += "The delegate may Draw(" + itoa(g.id) + ", amount) up to the " +
1054 "drawable figure, then Claim() to receive it.\n"
1055 }
1056 if a.HasPending {
1057 out += "\n> [!NOTE]\n> A **looser** amendment is queued and takes " +
1058 "effect at the next window boundary: per-window cap " +
1059 itoa(a.Pending.PerPeriod) + Denom + ", lifetime cap " +
1060 itoa(a.Pending.Total) + ", expiry " + itoa(a.Pending.ExpiresAt) +
1061 " (0 means none). Until then the terms above are what govern.\n"
1062 }
1063 return out
1064}
1065
1066// --- internals ---
1067
1068func mustGet(id int64) *grant {
1069 v := grants.Get(padID(id))
1070 if v == nil {
1071 panic("unknown grant id")
1072 }
1073 return v.(*grant)
1074}
1075
1076// mustAllowance fetches the spendbook allowance behind a grant. The pair
1077// is created and removed together, so a missing allowance is a bug in
1078// this realm rather than a user error, and it panics rather than
1079// reporting a zero value that a reader would take for "no authority".
1080func mustAllowance(g *grant) spendbook.Allowance {
1081 a, err := book.Get(g.allowID)
1082 if err != nil {
1083 panic(err)
1084 }
1085 return a
1086}
1087
1088// windowAt returns the anchored window index containing now, and
1089// spentInWindow what that window has spent.
1090//
1091// These duplicate a line of spendbook, and the duplication is deliberate,
1092// bounded, and worth justifying because the primitive's own at() comment
1093// warns against exactly this. What is NOT reimplemented is the part that
1094// warning is about: which terms are in force after a boundary, and
1095// therefore every authority figure this realm reports — Remaining and
1096// Drawable both call through to the primitive, and nothing here second-
1097// guesses them.
1098//
1099// What is copied is pure index arithmetic over three EXPORTED fields plus
1100// the already-documented fact that the in-window spend RESETS rather than
1101// carries. The alternative is worse than the duplication: Allowance's
1102// WindowIndex and SpentWindow are the last-WRITTEN pair, so between a
1103// boundary and the next draw they describe a window the chain has left, and
1104// a Render publishing them would print "spent this window: 100000"
1105// immediately above "authorized by the terms: 100000".
1106//
1107// The two are pinned to the primitive by TestWindowRefillsAtAnchoredBoundary
1108// rather than by inspection: it draws after crossing a boundary and asserts
1109// spentWindow equals that draw, which can only hold if windowAt agreed with
1110// spendbook's own index at the moment of the write.
1111func windowAt(a spendbook.Allowance, now int64) int64 {
1112 if a.Terms.PeriodLength == 0 || now < a.Anchor {
1113 return 0
1114 }
1115 return (now - a.Anchor) / a.Terms.PeriodLength
1116}
1117
1118func spentInWindow(a spendbook.Allowance, now int64) int64 {
1119 if windowAt(a, now) != a.WindowIndex {
1120 return 0
1121 }
1122 return a.SpentWindow
1123}
1124
1125// drop removes a grant record and decrements its owner's live count. The
1126// allowance itself is removed by the spendbook call that precedes every
1127// caller of this helper.
1128func drop(g *grant) {
1129 grants.Remove(padID(g.id))
1130 decOwnerOpen(g.owner.String())
1131}
1132
1133func labelOr(g *grant) string {
1134 if g.label == "" {
1135 return "(no label)"
1136 }
1137 return g.label
1138}
1139
1140func poolOf(key string) int64 {
1141 v := pools.Get(key)
1142 if v == nil {
1143 return 0
1144 }
1145 return v.(int64)
1146}
1147
1148func setPool(key string, n int64) {
1149 if n <= 0 {
1150 pools.Remove(key)
1151 return
1152 }
1153 pools.Set(key, n)
1154}
1155
1156func ownerOpen(key string) int64 {
1157 v := openByOwner.Get(key)
1158 if v == nil {
1159 return 0
1160 }
1161 return v.(int64)
1162}
1163
1164func setOwnerOpen(key string, n int64) {
1165 if n <= 0 {
1166 openByOwner.Remove(key)
1167 return
1168 }
1169 openByOwner.Set(key, n)
1170}
1171
1172func decOwnerOpen(key string) { setOwnerOpen(key, ownerOpen(key)-1) }
1173
1174func itoa(n int64) string { return strconv.FormatInt(n, 10) }
1175
1176func boolstr(b bool) string {
1177 if b {
1178 return "true"
1179 }
1180 return "false"
1181}
1182
1183// bps renders a basis-point figure as a human percentage, exactly.
1184func bps(n int64) string {
1185 whole := n / 100
1186 frac := n % 100
1187 if frac == 0 {
1188 return itoa(whole) + "%"
1189 }
1190 if frac%10 == 0 {
1191 return itoa(whole) + "." + itoa(frac/10) + "%"
1192 }
1193 pad := ""
1194 if frac < 10 {
1195 pad = "0"
1196 }
1197 return itoa(whole) + "." + pad + itoa(frac) + "%"
1198}
1199
1200// padID encodes an id so avl's lexical order matches numeric order.
1201func padID(id int64) string {
1202 s := strconv.FormatInt(id, 10)
1203 const width = 20
1204 if len(s) >= width {
1205 return s
1206 }
1207 return zeros[:width-len(s)] + s
1208}
1209
1210const zeros = "00000000000000000000"
1211
1212// checkedAdd returns a+b and reports whether the addition did not
1213// overflow int64.
1214func checkedAdd(a, b int64) (int64, bool) {
1215 sum := a + b
1216 if (b > 0 && sum < a) || (b < 0 && sum > a) {
1217 return 0, false
1218 }
1219 return sum, true
1220}