Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}