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.

reimbursements.gno

69.12 Kb · 1811 lines
   1// Realm reimbursements is a claimant-initiated, approver-gated expense
   2// reimbursement desk in GNOT: an owner funds a pool and appoints an
   3// approver with a bounded, rate-limited, revocable authority to approve
   4// against it; anyone may then SUBMIT a request for a specific amount with
   5// a memo; and the approver either approves it — which settles the money
   6// into the claimant's credit immediately — or rejects it.
   7//
   8// WHY THIS EXISTS (see DISCOVERY.md): request-then-approve is NOT new
   9// here, and an earlier draft of this comment wrongly claimed it was.
  10// Three realms in this portfolio already let a requester initiate and a
  11// second party gate before value moves — grants (applicants apply, the
  12// creator selects), bounties (a funder designates a winner) and
  13// bounty_panel (contributors submit, an M-of-N panel votes). The §7
  14// sweep that found them is recorded in DISCOVERY.md. The claim this
  15// realm actually rests on is narrower, and all three parts are
  16// load-bearing:
  17//
  18//   - THE REQUESTER PRICES IT. A grant or bounty amount is the funder's
  19//     offer, fixed before anyone asks. A reimbursement amount is a cost
  20//     the claimant already incurred, so the claimant names it and the
  21//     approver's only lever is yes or no on that figure.
  22//   - SETTLEMENT IS NON-EXCLUSIVE. Those three are competitive awards:
  23//     escrow is reserved per item and exactly one requester wins. Here an
  24//     unbounded number of requests settle against one SHARED,
  25//     UN-ESCROWED pool, which is why a pending request is not a
  26//     reservation (see below) — the divergence this realm is built on.
  27//   - THE APPROVER IS RATE-LIMITED. In all three the selecting party may
  28//     award whenever it likes. Here the approver is not the owner and
  29//     holds a bounded, windowed, revocable spendbook authority, so an
  30//     approver key cannot drain the pool.
  31//
  32// The four other disbursement shapes here are further away still:
  33// treasury_board needs the PROPOSER to be a signer, so an outside
  34// claimant cannot ask; budgets grants authority up front with no
  35// per-item gate; invoices is a receivable nobody approves; payroll and
  36// vesting are employer-funded accrual on a schedule.
  37//
  38// It is also, deliberately, the SECOND CONSUMER of spendbook, and the
  39// first with an INVERTED temporal order. budgets consumes authority at
  40// spend time by the spender. Here the spendbook "spender" is the
  41// APPROVER, and the authority is consumed at APPROVE time against a
  42// request a third party submitted. A primitive with one consumer is
  43// barely proven as a primitive; a second consumer that uses it the same
  44// way proves little more. This one exercises the opposite direction.
  45//
  46// COMPOSITION: approval authority is delegated entirely to spendbook
  47// (anchored windows, lifetime caps, expiry, one-sided amendments,
  48// revocation), coin movement to coinio, per-claimant credit and the fee
  49// pot to feeledger, ordered storage to p/nt/avl/v0, and free-text render
  50// output to p/nt/markdown/sanitize/v0. This realm owns the pool
  51// accounting, the desk and request records, the conflict-of-interest
  52// rule, and the solvency rule.
  53//
  54// # A desk, and what an approval IS
  55//
  56// A desk is one (owner, approver) pair with a bounded authority over the
  57// owner's pool. A request is one claimant's ask against one desk.
  58//
  59// The DELIBERATE DIVERGENCE from budgets is at approval:
  60//
  61//	An approved reimbursement is a SETTLED OBLIGATION, not authority.
  62//
  63// Approve moves the gross out of the owner's pool and into the claimant's
  64// ledger credit in the same call. The owner cannot then withdraw it out
  65// from under an approved claimant, and no second step can fail. budgets'
  66// rule — "a budget is authority, not a reservation" — is explicitly NOT
  67// inherited, because the two realms are answering different questions. A
  68// budget is an ongoing permission whose point is not pre-committing
  69// capital. An approved expense claim is a decision about a cost already
  70// incurred, and a decision that can evaporate is not a decision.
  71//
  72// The realm therefore never holds a partially-committed state: a request
  73// is pending (nothing has moved) or settled (everything has moved).
  74//
  75// # A PENDING REQUEST IS NOT A LIABILITY
  76//
  77// Stated plainly because the opposite is the natural assumption.
  78// Submitting reserves nothing, locks nothing, and is not a claim on the
  79// pool. Ten pending requests of 100 against a pool of 100 are all ten
  80// submittable; the approver can approve one of them. Liabilities() counts
  81// pooled funds and settled credit, and a pending request appears in
  82// neither. What a claimant can rely on is the moment of approval, which
  83// is atomic, and the Approvable view, which reports the honest figure
  84// before they are told yes.
  85//
  86// # THE APPROVER IS THE SPENDBOOK SPENDER
  87//
  88// The desk's allowance is consumed by the GROSS approved amount, so it
  89// bounds the owner's total OUTFLOW per window rather than the claimant's
  90// receipt — the figure an owner actually cares about capping, and the same
  91// treatment budgets gives its fee. An approver with a PerPeriod of 1 GNOT
  92// can approve 1 GNOT of the owner's money per window no matter how many
  93// claimants ask, and no single compromised approver key can do more than
  94// its window allows before the owner revokes the desk.
  95//
  96// # SELF-APPROVAL IS PROHIBITED
  97//
  98// Approve refuses when the request's claimant is the approving address.
  99// This is a real conflict-of-interest control and not a formality: an
 100// approver with a desk could otherwise reimburse themselves at will up to
 101// the window cap. The rule is per-APPROVAL, not per-desk, so an address
 102// cannot route its own claim through a second desk it also approves. An
 103// approver may still submit requests — to be approved by somebody else.
 104//
 105// # LIFECYCLE
 106//
 107//	Fund (owner, +send)      : adds real coins to the caller's own pool.
 108//	                           Anyone may fund; funding makes you an
 109//	                           owner. Minimum MinFunding.
 110//	WithdrawPool (owner)     : pulls UNSPENT pool back out, any amount,
 111//	                           any time. Approved credit is already out
 112//	                           of the pool and unreachable.
 113//	WithdrawPoolAll (owner)  : the whole unspent pool.
 114//	OpenDesk (owner)         : appoints one approver with bounded
 115//	                           authority and returns the desk id. The
 116//	                           approval fee is snapshotted here.
 117//	AmendDesk (owner)        : changes the desk's terms. Tighter-or-equal
 118//	                           applies now; looser waits for the next
 119//	                           window boundary. periodLength is NOT a
 120//	                           parameter — spendbook makes it immutable.
 121//	CloseDesk (owner)        : immediate; the id is never reused.
 122//	ReapDesk (ANYONE)        : removes an EXPIRED desk, freeing its slot.
 123//	Submit (ANYONE)          : files a request against a desk for a
 124//	                           specific amount with a memo, and returns
 125//	                           the request id. Expires after RequestTTL
 126//	                           blocks.
 127//	Approve (APPROVER)       : settles the request — debits the owner's
 128//	                           pool, credits the claimant net of the fee,
 129//	                           consumes the desk's authority, removes the
 130//	                           request.
 131//	Reject (APPROVER)        : closes the request with a reason. Nothing
 132//	                           moves. Allowed even after expiry.
 133//	Retract (CLAIMANT)       : the claimant withdraws their own request.
 134//	                           Nothing moves. Named Retract, not
 135//	                           Withdraw, because every Withdraw* in this
 136//	                           portfolio moves money and this moves none.
 137//	ExpireRequest (ANYONE)   : removes a request past its expiry.
 138//	Claim (CLAIMANT)         : pays the claimant's whole settled credit
 139//	                           out. The portfolio's standard pull.
 140//	WithdrawFees (ANYONE)    : pays the fee pot to the compile-time
 141//	                           FeeCollector. Fixed destination.
 142//	SweepSurplus (ANYONE)    : out-of-band coins to the FeeCollector,
 143//	                           never touching tracked liabilities.
 144//
 145// # A CLOSED DESK LEAVES ITS REQUESTS UN-APPROVABLE
 146//
 147// CloseDesk and ReapDesk remove the desk and its allowance. Requests
 148// filed against it remain as records and can never be approved — Approve
 149// panics on the missing desk. They can be Retracted by their claimant or,
 150// once past expiry, ExpireRequested by anyone.
 151//
 152// There is deliberately NO CASCADE DELETE. Finding a closed desk's
 153// requests means iterating the request tree, which is unbounded work in a
 154// transaction whose gas the owner pays, and an owner who cannot close a
 155// desk because it has too many pending requests is a worse realm than one
 156// where closing leaves records to be swept. The condition is documented,
 157// visible in RequestInfo and on the request's render page, and
 158// self-clearing within RequestTTL.
 159//
 160// # AVAILABILITY BOUND, stated honestly
 161//
 162// Submit is permissionless, so the request tree is fillable. The
 163// per-claimant and per-desk caps bound ONE address and ONE desk, and are
 164// defeated by many addresses — that is the correct way to read a
 165// per-identity cap on a permissionless chain, and budgets' own corrected
 166// comment (finding Y1) is this portfolio's record of getting it wrong the
 167// other way.
 168//
 169// What genuinely helps here, and does not help in budgets, is that a
 170// request has a MANDATORY expiry: RequestTTL is a compile-time constant,
 171// every request carries submittedAt + RequestTTL, and ExpireRequest is
 172// permissionless with the freed storage deposit refunded to the sweeping
 173// transaction. So the fill is bounded in DURATION as well as priced in
 174// gas and storage, and it drains itself. budgets' grants with expiresAt
 175// == 0 can never be reaped by anyone, which is strictly worse. The
 176// residual is real: for RequestTTL blocks a filled tree blocks NEW
 177// requests. Every desk, pool, approval, credit and claim keeps working,
 178// and no value is at risk.
 179//
 180// # THERE IS NO ADMIN
 181//
 182// The fee rate, the fee collector, the TTL, every bound and every cap are
 183// compile-time constants. No address can change terms, pause the realm,
 184// touch a pool it did not fund, approve at a desk it was not appointed
 185// to, or move a request it did not file. The deployer has no privilege of
 186// any kind after deployment.
 187//
 188// # MONETARY INVARIANT (conservation)
 189//
 190// Let H be the ugnot held at this realm's address, P the sum of all owner
 191// pools (tracked O(1) as totalPooled), L the ledger's liabilities
 192// (settled claimant credit plus the fee pot), and S >= 0 the out-of-band
 193// surplus:
 194//
 195//	H == P + L + S
 196//
 197// Every transition moves value between exactly two terms inside one
 198// transaction. Fund raises H and P together (coinio.Receive is the
 199// receipt-guaranteed shape). WithdrawPool debits P before the identical
 200// amount leaves H. Approve moves the gross out of P and into L, split
 201// into the claimant's credit and the fee — H does not move at all, which
 202// is why Approve has no payout to fail. Claim and WithdrawFees debit L
 203// before their payout. Submit, Reject, Retract and ExpireRequest move
 204// nothing in any term. Any panic aborts the whole transaction; this realm
 205// never issues or removes coins.
 206//
 207// # ONLY GNOT IS ACCEPTED
 208//
 209// Fund rejects any envelope that is not exactly one positive ugnot coin
 210// (coinio.Receive). Every other entrypoint rejects attached coins
 211// outright rather than converting them into sweepable surplus.
 212//
 213// # AUTHORIZATION
 214//
 215// Every identity is derived from the crossing entrypoint's
 216// cur.Previous().Address(). No function takes a caller identity as a
 217// parameter, so funding another owner's pool, approving at another
 218// address's desk, retracting another claimant's request, or claiming
 219// another claimant's credit is impossible by construction. This is
 220// spendbook consumer contract item 2, closed structurally.
 221//
 222// The clock is runtime.ChainHeight(), never an argument — spendbook
 223// consumer contract item 1. A caller who could choose `now` could roll
 224// the approval window at will, and could also expire a request out from
 225// under its claimant or keep an expired one alive.
 226//
 227// # STORAGE
 228//
 229// A pool entry is paid for by the owner's own funding transaction; a desk
 230// record and its allowance by the owner's OpenDesk; a request record by
 231// the claimant's Submit. CloseDesk, ReapDesk, Approve, Reject, Retract
 232// and ExpireRequest each remove a record, and the chain refunds the freed
 233// storage deposit to the transaction that frees it — which is the
 234// standing incentive to call ExpireRequest or ReapDesk on somebody
 235// else's stale record.
 236package reimbursements
 237
 238import (
 239	"chain"
 240	"chain/runtime"
 241	"chain/runtime/unsafe"
 242	"strconv"
 243	"strings"
 244
 245	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
 246	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
 247	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/spendbook"
 248	"gno.land/p/nt/avl/v0"
 249	"gno.land/p/nt/markdown/sanitize/v0"
 250)
 251
 252// RealmPath is this realm's own path, used to build Render links.
 253const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/reimbursements"
 254
 255// Denom is the only asset this realm holds.
 256const Denom = "ugnot"
 257
 258// MaxFeeBps is the hard ceiling on any approval fee this realm could ever
 259// charge, enforced by the feeledger the realm is constructed with — a
 260// feeBps above it is refused by the primitive's own validation, not by a
 261// check in this file. 500 bps = 5%.
 262const MaxFeeBps = int64(500)
 263
 264// ApprovalFeeBps is the fee actually snapshotted into every desk: 100 bps
 265// = 1%, charged once per approval on the gross amount, floor-rounded in
 266// the claimant's favor. The most conservative live fee in this portfolio
 267// (fee_split, crowdfund, budgets) is also 1%.
 268const ApprovalFeeBps = int64(100)
 269
 270// FeeCollector receives withdrawn fees and swept surplus. Compile-time
 271// constant: there is no setter and no transfer path. It is the deploying
 272// namespace's address — stated plainly: the operator of this realm.
 273const FeeCollector = address("g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3")
 274
 275// MinFunding keeps dust pools out: 1 GNOT per Fund call.
 276const MinFunding = int64(1_000_000)
 277
 278// MinPerPeriod is the smallest per-window approval cap a desk may carry.
 279// It exists so a desk cannot be opened that no approval could ever
 280// satisfy, and it is paired with MinRequestAmount below rather than
 281// standing alone.
 282const MinPerPeriod = int64(1_000)
 283
 284// MinPeriodBlocks is the shortest window a desk may use. A window of a
 285// handful of blocks is a rate, not a budget, and it makes PerPeriod
 286// meaningless as a bound on worst-case loss. A periodLength of 0 is still
 287// accepted and means ONE LIFETIME WINDOW that never refills (spendbook's
 288// own semantics).
 289const MinPeriodBlocks = int64(100)
 290
 291// MinRequestAmount is the smallest request that may be filed, and it is
 292// deliberately EQUAL TO MinPerPeriod rather than smaller. The two
 293// together are what make a desk's authority expressible: a desk whose
 294// per-window cap is at the floor can still approve a request at the
 295// floor. Setting this below MinPerPeriod would admit dust requests that
 296// cost the approver a transaction each; setting it above would create
 297// desks no request could use.
 298const MinRequestAmount = int64(1_000)
 299
 300// RequestTTL is how long a request stays approvable, IN BLOCKS. No
 301// wall-clock equivalent is stated because onyx-1's block time has never
 302// been measured by this project, and converting blocks to hours with an
 303// assumed rate is how a document comes to carry a number nobody checked.
 304// It is sized to be long enough that an approver need not be online
 305// continuously and short enough that the request tree drains.
 306const RequestTTL = int64(86_400)
 307
 308// MaxLiveDesks caps simultaneously live desks, via spendbook's own
 309// construction bound. OpenDesk requires a funded pool, which is NOT a
 310// capital bound — see MaxOpenPerClaimant for why that mistake is not
 311// repeated here. spendbook's own MaxLiveLimit is 10000.
 312const MaxLiveDesks = 4096
 313
 314// MaxDesksPerOwner bounds how much of the global desk cap one owner can
 315// occupy. spendbook's package documentation names this as the gap a
 316// consuming realm must close: "maxLive is a single global figure, so any
 317// owner permitted to Open can exhaust it and lock out the rest … a Book
 318// shared across mutually untrusting owners needs its own per-owner cap on
 319// top of this one." This is that cap.
 320const MaxDesksPerOwner = int64(16)
 321
 322// MaxLiveRequests caps simultaneously live requests across the realm.
 323// Submit is permissionless, so this cap is exhaustible, and the package
 324// doc states that bound rather than claiming the per-identity caps below
 325// close it.
 326const MaxLiveRequests = 4096
 327
 328// MaxOpenPerClaimant bounds one claimant's simultaneously-pending
 329// requests. It is a GRIEF-COST multiplier and not a defence: one address
 330// is bounded, N addresses are not, and an attacker controls N. The real
 331// mitigation is RequestTTL plus permissionless ExpireRequest — see the
 332// package doc's availability section.
 333const MaxOpenPerClaimant = int64(16)
 334
 335// MaxOpenPerDesk bounds the pending requests at one desk, so a single
 336// desk's approver is not handed an unreviewable queue by one burst. It is
 337// per-desk, so filling it denies that desk only; other desks are
 338// unaffected.
 339const MaxOpenPerDesk = int64(64)
 340
 341// MaxLabelLen bounds a desk's free-text label.
 342const MaxLabelLen = 64
 343
 344// MaxMemoLen bounds a request's free-text memo and a rejection's reason.
 345// Larger than MaxLabelLen because a memo is the expense description — the
 346// thing an approver reads to decide — while a label only names a desk.
 347const MaxMemoLen = 160
 348
 349// MaxRenderRows bounds the lists on the index page.
 350const MaxRenderRows = 20
 351
 352type desk struct {
 353	id        int64
 354	owner     address
 355	approver  address
 356	allowID   uint64 // the spendbook allowance backing this desk
 357	feeBps    int64  // snapshotted at OpenDesk
 358	label     string
 359	createdAt int64
 360
 361	approved   int64 // lifetime gross approved through this desk
 362	approvals  int64
 363	rejections int64
 364}
 365
 366type request struct {
 367	id          int64
 368	deskID      int64
 369	claimant    address
 370	amount      int64
 371	feeBps      int64 // snapshotted at Submit from the desk
 372	memo        string
 373	submittedAt int64
 374	expiresAt   int64 // submittedAt + RequestTTL
 375}
 376
 377var (
 378	self address
 379
 380	// desks holds one record per live desk. CloseDesk and ReapDesk remove
 381	// the record; ids are never reused.
 382	desks      = avl.NewTree() // padID(id) -> *desk
 383	nextDeskID int64
 384
 385	// requests holds one record per PENDING request. Approve, Reject,
 386	// Retract and ExpireRequest each remove one; ids are never reused.
 387	requests  = avl.NewTree() // padID(id) -> *request
 388	nextReqID int64
 389
 390	// pools is each owner's unspent funded balance:
 391	// address string -> int64 (> 0). This is the P term.
 392	pools = avl.NewTree()
 393
 394	// desksByOwner tracks each owner's live desk count:
 395	// address string -> int64 (> 0).
 396	desksByOwner = avl.NewTree()
 397
 398	// openByClaimant tracks each claimant's pending request count:
 399	// address string -> int64 (> 0).
 400	openByClaimant = avl.NewTree()
 401
 402	// openByDesk tracks each desk's pending request count:
 403	// padID(deskID) -> int64 (> 0).
 404	openByDesk = avl.NewTree()
 405
 406	// totalPooled is the P term of H == P + L + S, maintained O(1).
 407	totalPooled int64
 408
 409	// lifetime counters, rendered for operators.
 410	lifetimeFunded    int64
 411	lifetimeSubmitted int64
 412	// lifetimeApprovals counts REQUESTS settled; lifetimeApproved sums the
 413	// GROSS ugnot they moved. Two different axes, and keeping only one
 414	// would force a Render line to imply the other. A per-desk
 415	// `approvals` field exists too and is NOT a substitute: closing a desk
 416	// removes its record and therefore its count, while these survive.
 417	lifetimeApprovals int64
 418	lifetimeApproved  int64 // gross, before the fee
 419	lifetimeRejected  int64
 420	lifetimeRetracted int64
 421	lifetimeExpired   int64
 422	lifetimeClaimed   int64 // net, paid to claimants
 423	lifetimeReturned  int64 // unspent pool withdrawn by owners
 424
 425	// ledger holds each claimant's settled credit and the fee pot. Its
 426	// Liabilities() is the L term and the reserve SweepSurplus protects.
 427	ledger = feeledger.MustNew(MaxFeeBps)
 428
 429	// book holds one live allowance per live desk. Every bound on
 430	// approval authority — windows, caps, expiry, amendment direction — is
 431	// the primitive's, not this file's.
 432	book = spendbook.MustNew(MaxLiveDesks)
 433)
 434
 435func init() {
 436	self = unsafe.CurrentRealm().Address()
 437}
 438
 439// rejectStraySend aborts when coins are attached to a non-payable call.
 440// This realm holds funds, so a stray send would become sweepable surplus,
 441// silently converting a user's coins into operator funds. Aborting
 442// reverts the transfer instead. Guarded on IsUserCall, not IsUser,
 443// because a MsgRun ephemeral can consume the OriginSend envelope before
 444// forwarding control; for a realm-routed call the envelope lands at the
 445// intermediary, so there is nothing here to reject and the guard
 446// deliberately fails open (budgets, crowdfund and treasury_board document
 447// the same scope).
 448func rejectStraySend(cur realm) {
 449	if !cur.IsCurrent() {
 450		// Every call site forwards a crossing entrypoint's own live cur,
 451		// so this cannot fire today; it exists so a future refactor that
 452		// hands this guard a stale realm value fails closed, not open
 453		// (coinio names IsCurrent as the guard secondary realm parameters
 454		// require, and this helper follows that discipline).
 455		panic("reimbursements: realm capability is not current")
 456	}
 457	if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
 458		panic("this entrypoint does not accept coins")
 459	}
 460}
 461
 462// --- the pool ---
 463
 464// Fund adds the attached coins to the caller's own pool. Direct EOA calls
 465// with -send only (the receipt-guaranteed shape); exactly one positive
 466// ugnot coin, at least MinFunding. Anyone may fund; funding is what makes
 467// an address an owner.
 468func Fund(cur realm) int64 {
 469	owner, amount := coinio.Receive(0, cur, Denom)
 470	if amount < MinFunding {
 471		panic("fund at least " + itoa(MinFunding) + Denom)
 472	}
 473	key := owner.String()
 474
 475	newPool, ok := checkedAdd(poolOf(key), amount)
 476	if !ok {
 477		panic("pool would overflow")
 478	}
 479	newTotal, ok := checkedAdd(totalPooled, amount)
 480	if !ok {
 481		panic("total pooled would overflow")
 482	}
 483	newLifetime, ok := checkedAdd(lifetimeFunded, amount)
 484	if !ok {
 485		panic("lifetime funded would overflow")
 486	}
 487
 488	setPool(key, newPool)
 489	totalPooled = newTotal
 490	lifetimeFunded = newLifetime
 491
 492	chain.Emit("Funded", "owner", key, "amount", itoa(amount),
 493		"pool", itoa(newPool))
 494	return newPool
 495}
 496
 497// WithdrawPool returns part of the caller's UNSPENT pool and reports the
 498// pool REMAINING afterwards.
 499//
 500// Unlike budgets, this is not a hazard to a counterparty, and the reason
 501// is the divergence in the package doc: an approved reimbursement has
 502// already left the pool and sits in the ledger as the claimant's. What an
 503// owner can still do is empty the pool under a live desk, so that the
 504// approver's next Approve fails on solvency and pending requests cannot
 505// be settled. That is the owner declining to spend their own money, which
 506// is theirs to decline; CloseDesk is the clean way to end the
 507// relationship.
 508func WithdrawPool(cur realm, amount int64) int64 {
 509	rejectStraySend(cur)
 510	owner := cur.Previous().Address()
 511	if amount <= 0 {
 512		panic("amount must be positive")
 513	}
 514	return payOutPool(cur, owner, amount)
 515}
 516
 517// WithdrawPoolAll returns the caller's entire unspent pool and reports
 518// the amount WITHDRAWN.
 519//
 520// The return values of the two withdraw entrypoints are deliberately
 521// asymmetric, which is worth naming rather than letting a caller
 522// discover: each returns the figure its caller does not already have.
 523// WithdrawPool was told the amount and wants to know what is left;
 524// WithdrawPoolAll knows what is left (nothing) and wants to know what it
 525// got. Returning the remainder here would hand every caller a constant 0.
 526func WithdrawPoolAll(cur realm) int64 {
 527	rejectStraySend(cur)
 528	owner := cur.Previous().Address()
 529	amount := poolOf(owner.String())
 530	if amount == 0 {
 531		panic("no pool to withdraw")
 532	}
 533	payOutPool(cur, owner, amount)
 534	return amount
 535}
 536
 537// payOutPool is the shared debit-then-send path: the pool and the P term
 538// are reduced before the identical amount leaves the realm
 539// (checks-effects-interactions).
 540func payOutPool(cur realm, owner address, amount int64) int64 {
 541	key := owner.String()
 542	pool := poolOf(key)
 543	if amount > pool {
 544		panic("amount exceeds pool")
 545	}
 546	newReturned, ok := checkedAdd(lifetimeReturned, amount)
 547	if !ok {
 548		panic("lifetime returned would overflow")
 549	}
 550
 551	setPool(key, pool-amount)
 552	totalPooled -= amount
 553	lifetimeReturned = newReturned
 554
 555	coinio.Payout(0, cur, owner, Denom, amount)
 556
 557	chain.Emit("PoolWithdrawn", "owner", key, "amount", itoa(amount),
 558		"pool", itoa(pool-amount))
 559	return pool - amount
 560}
 561
 562// --- desks ---
 563
 564// OpenDesk appoints an approver with a bounded authority over the
 565// caller's pool and returns the desk id. The caller becomes the desk's
 566// owner and must already have a funded pool — not because the desk
 567// reserves anything (it does not), but because a desk over an unfunded
 568// pool is an invitation to claimants to file requests nothing can settle,
 569// and this realm declines to record one.
 570//
 571// Terms, all in the chain's own units:
 572//
 573//	perPeriod    the gross the approver may approve per window, at least
 574//	             MinPerPeriod. Required.
 575//	periodLength blocks per window. 0 means ONE LIFETIME WINDOW that never
 576//	             refills; otherwise at least MinPeriodBlocks.
 577//	total        lifetime cap across every window. 0 means unlimited.
 578//	expiresAt    absolute block height at which the approver's authority
 579//	             ends. 0 means no expiry.
 580//
 581// Every bound above except the two realm minimums is validated and
 582// enforced by spendbook, and an invalid combination surfaces as that
 583// package's own sentinel error.
 584func OpenDesk(cur realm, approver address, label string,
 585	perPeriod, periodLength, total, expiresAt int64) int64 {
 586	rejectStraySend(cur)
 587	owner := cur.Previous().Address()
 588
 589	// The lowercase clause is load-bearing, not decoration, and the reason
 590	// was measured on this toolchain rather than argued: address.IsValid()
 591	// defers to crypto.AddressFromBech32, whose decoder lowercases the
 592	// input BEFORE verifying the checksum, so it returns true for the
 593	// all-uppercase spelling of a valid address. Here the hazard is a
 594	// BRICKED DESK: Approve authenticates against cur.Previous().Address(),
 595	// which is always canonical, so an uppercase-spelled approver could
 596	// never approve anything, while the desk still occupied a
 597	// MaxDesksPerOwner slot and a storage deposit, and claimants could
 598	// still file requests at it that nobody could ever settle. The check is
 599	// complete as well as necessary — that decoder rejects mixed case
 600	// outright, so a valid address has exactly two spellings and rejecting
 601	// every non-lower one leaves precisely the canonical one.
 602	if !approver.IsValid() || string(approver) != strings.ToLower(string(approver)) {
 603		panic("approver is not a valid address in canonical (lowercase) form")
 604	}
 605	if len(label) > MaxLabelLen {
 606		panic("label exceeds " + strconv.Itoa(MaxLabelLen) + " bytes")
 607	}
 608	if perPeriod < MinPerPeriod {
 609		panic("perPeriod must be at least " + itoa(MinPerPeriod) + Denom)
 610	}
 611	if periodLength != 0 && periodLength < MinPeriodBlocks {
 612		panic("periodLength must be 0 (lifetime) or at least " +
 613			itoa(MinPeriodBlocks) + " blocks")
 614	}
 615
 616	key := owner.String()
 617	if poolOf(key) == 0 {
 618		panic("fund a pool before opening a desk")
 619	}
 620	if ownerDesks(key) >= MaxDesksPerOwner {
 621		panic("owner already has " + itoa(MaxDesksPerOwner) + " live desks")
 622	}
 623
 624	now := runtime.ChainHeight()
 625	allowID, err := book.Open(key, approver.String(), now, spendbook.Terms{
 626		PerPeriod:    perPeriod,
 627		PeriodLength: periodLength,
 628		Total:        total,
 629		ExpiresAt:    expiresAt,
 630	})
 631	if err != nil {
 632		panic(err)
 633	}
 634
 635	id := nextDeskID + 1
 636	nextDeskID = id
 637	desks.Set(padID(id), &desk{
 638		id:        id,
 639		owner:     owner,
 640		approver:  approver,
 641		allowID:   allowID,
 642		feeBps:    ApprovalFeeBps,
 643		label:     label,
 644		createdAt: now,
 645	})
 646	setOwnerDesks(key, ownerDesks(key)+1)
 647
 648	chain.Emit("DeskOpened", "id", itoa(id), "owner", key,
 649		"approver", approver.String(), "perPeriod", itoa(perPeriod),
 650		"periodLength", itoa(periodLength), "total", itoa(total),
 651		"expiresAt", itoa(expiresAt), "feeBps", itoa(ApprovalFeeBps))
 652	return id
 653}
 654
 655// AmendDesk changes a desk's terms. Owner only. The returned bool reports
 656// whether the new terms are in force NOW (a tighter-or-equal amendment)
 657// or queued for the next window boundary (a looser one) — spendbook's
 658// one-sided rule, and the distinction worth knowing.
 659//
 660// periodLength is NOT a parameter. spendbook makes it immutable because
 661// moving it would move every future boundary, which is an enlargement
 662// vector wearing a different hat; this realm reads the desk's own
 663// periodLength and passes it unchanged, so changing it is not merely
 664// refused, it is unexpressible.
 665//
 666// Amending does not touch pending requests. Their fee was snapshotted at
 667// Submit and their amount is fixed; what changes is whether the approver
 668// still has the authority to settle them, which Approvable reports.
 669//
 670// A QUEUED EXPIRY EXTENSION CAN BE LOST, AND THE DESK BECOMES REAPABLE IN
 671// THE MEANTIME. A widening amendment is queued rather than applied, and
 672// spendbook surfaces a queued term only through its internal at()
 673// accessor — which IsExpired does NOT use: it reads the live
 674// Terms.ExpiresAt directly. So between queueing and the next approval,
 675// IsDeskExpired and ReapDesk still read the OLD expiry. If the old expiry
 676// passes with no Approve and no restated AmendDesk in between, ANYONE may
 677// ReapDesk the desk and the queued extension dies with it. Restating the
 678// same amendment while the old expiry is still in the future makes it
 679// permanent, because spendbook writes Pending back to Terms on a spend or
 680// on a later amendment; restating it after the old expiry is refused with
 681// "allowance has expired".
 682//
 683// NO FUNDS ARE AT RISK and that is structural, not incidental: dropDesk
 684// moves no coins, and the claimant pools are keyed by CLAIMANT ADDRESS
 685// rather than by desk, so a reap strands open requests and leaves every
 686// accrued balance claimable. The root cause is in spendbook, which is
 687// already live and immutable, so the repair belongs to a future /p/
 688// version and not to this realm. See AUDIT.md Y1 and DEPLOY_READINESS.md.
 689func AmendDesk(cur realm, id int64, perPeriod, total, expiresAt int64) bool {
 690	rejectStraySend(cur)
 691	owner := cur.Previous().Address()
 692	d := mustDesk(id)
 693	if owner != d.owner {
 694		panic("owner only")
 695	}
 696	if perPeriod < MinPerPeriod {
 697		panic("perPeriod must be at least " + itoa(MinPerPeriod) + Denom)
 698	}
 699
 700	a, err := book.Get(d.allowID)
 701	if err != nil {
 702		panic(err)
 703	}
 704	applied, err := book.Amend(d.allowID, owner.String(), runtime.ChainHeight(),
 705		spendbook.Terms{
 706			PerPeriod:    perPeriod,
 707			PeriodLength: a.Terms.PeriodLength, // immutable
 708			Total:        total,
 709			ExpiresAt:    expiresAt,
 710		})
 711	if err != nil {
 712		panic(err)
 713	}
 714
 715	chain.Emit("DeskAmended", "id", itoa(d.id), "owner", owner.String(),
 716		"perPeriod", itoa(perPeriod), "total", itoa(total),
 717		"expiresAt", itoa(expiresAt), "applied", boolstr(applied))
 718	return applied
 719}
 720
 721// CloseDesk ends an approver's authority immediately. Owner only. The id
 722// is never reused, so a closed desk cannot be resurrected.
 723//
 724// Closing does NOT claw back credit already approved — that is the
 725// claimant's and stays claimable. It does NOT remove requests filed at
 726// this desk either: see the package doc, "a closed desk leaves its
 727// requests un-approvable". The count of those is reported in the event so
 728// the owner learns what they just stranded.
 729func CloseDesk(cur realm, id int64) {
 730	rejectStraySend(cur)
 731	owner := cur.Previous().Address()
 732	d := mustDesk(id)
 733	if owner != d.owner {
 734		panic("owner only")
 735	}
 736	// Owner-gated twice on purpose: the check above is for the error
 737	// message, the primitive's is the authoritative one.
 738	if err := book.Revoke(d.allowID, owner.String()); err != nil {
 739		panic(err)
 740	}
 741	stranded := deskOpen(id)
 742	dropDesk(d)
 743
 744	chain.Emit("DeskClosed", "id", itoa(d.id), "owner", owner.String(),
 745		"approver", d.approver.String(), "approved", itoa(d.approved),
 746		"strandedRequests", itoa(stranded))
 747}
 748
 749// ReapDesk removes a desk whose authority has EXPIRED, freeing its slot
 750// and its storage. Anyone may call it, and the reasoning is spendbook's
 751// own: an expired allowance can never authorize another approval, so
 752// removing it takes nothing from the owner and grants nothing to the
 753// caller. The chain refunds the freed storage deposit to the reaping
 754// transaction, which is the standing incentive.
 755//
 756// A desk with no expiry can never be reaped; only its owner can close it.
 757func ReapDesk(cur realm, id int64) {
 758	rejectStraySend(cur)
 759	d := mustDesk(id)
 760	if err := book.Reap(d.allowID, runtime.ChainHeight()); err != nil {
 761		panic(err)
 762	}
 763	stranded := deskOpen(id)
 764	dropDesk(d)
 765
 766	chain.Emit("DeskReaped", "id", itoa(d.id), "owner", d.owner.String(),
 767		"by", cur.Previous().Address().String(),
 768		"strandedRequests", itoa(stranded))
 769}
 770
 771// --- requests ---
 772
 773// Submit files a reimbursement request against a desk and returns the
 774// request id. ANYONE may submit: that is the point of the realm — the
 775// party who spent the money asks, and does not need to be a signer, a
 776// delegate, or known to the desk in advance.
 777//
 778// Nothing is reserved and nothing moves. The amount and the fee are fixed
 779// here (the fee is snapshotted from the desk, so a later AmendDesk cannot
 780// change the terms of a request already filed), and the request expires
 781// at submittedAt + RequestTTL.
 782//
 783// Submitting against a desk whose approver is the submitter is permitted
 784// and useless: Approve refuses self-approval, so such a request can only
 785// be rejected, retracted or expired. It is not refused here because an
 786// approver may legitimately submit at a desk they do not approve, and a
 787// check at Submit would have to guess which desk the claimant meant.
 788func Submit(cur realm, deskID int64, amount int64, memo string) int64 {
 789	rejectStraySend(cur)
 790	claimant := cur.Previous().Address()
 791	d := mustDesk(deskID)
 792
 793	if amount < MinRequestAmount {
 794		panic("request at least " + itoa(MinRequestAmount) + Denom)
 795	}
 796	if len(memo) > MaxMemoLen {
 797		panic("memo exceeds " + strconv.Itoa(MaxMemoLen) + " bytes")
 798	}
 799	if memo == "" {
 800		// A reimbursement request with no description is unreviewable: the
 801		// approver's whole job is deciding whether THIS expense should be
 802		// paid, and an amount alone does not support that decision.
 803		panic("memo is required: describe the expense")
 804	}
 805	if requests.Size() >= MaxLiveRequests {
 806		panic("the request queue is full; expired requests can be cleared " +
 807			"by anyone with ExpireRequest")
 808	}
 809
 810	ckey := claimant.String()
 811	if claimantOpen(ckey) >= MaxOpenPerClaimant {
 812		panic("you already have " + itoa(MaxOpenPerClaimant) +
 813			" pending requests")
 814	}
 815	if deskOpen(deskID) >= MaxOpenPerDesk {
 816		panic("this desk already has " + itoa(MaxOpenPerDesk) +
 817			" pending requests")
 818	}
 819
 820	now := runtime.ChainHeight()
 821	expires, ok := checkedAdd(now, RequestTTL)
 822	if !ok {
 823		panic("request expiry would overflow")
 824	}
 825	newSubmitted, ok := checkedAdd(lifetimeSubmitted, 1)
 826	if !ok {
 827		panic("lifetime submitted would overflow")
 828	}
 829
 830	id := nextReqID + 1
 831	nextReqID = id
 832	requests.Set(padID(id), &request{
 833		id:          id,
 834		deskID:      deskID,
 835		claimant:    claimant,
 836		amount:      amount,
 837		feeBps:      d.feeBps,
 838		memo:        memo,
 839		submittedAt: now,
 840		expiresAt:   expires,
 841	})
 842	setClaimantOpen(ckey, claimantOpen(ckey)+1)
 843	setDeskOpen(deskID, deskOpen(deskID)+1)
 844	lifetimeSubmitted = newSubmitted
 845
 846	chain.Emit("Submitted", "id", itoa(id), "desk", itoa(deskID),
 847		"claimant", ckey, "amount", itoa(amount), "feeBps", itoa(d.feeBps),
 848		"expiresAt", itoa(expires))
 849	return id
 850}
 851
 852// Approve settles a request. APPROVER ONLY: the approver is the calling
 853// address, never an argument. The claimant is credited net of the fee and
 854// claims separately, which is this portfolio's standard pull shape.
 855//
 856// The checks fail in this order, and the order matters:
 857//
 858//  1. the desk must still exist (a closed or reaped desk strands its
 859//     requests, by design);
 860//  2. the caller must be that desk's approver;
 861//  3. the caller must not be the request's own claimant (no
 862//     self-approval);
 863//  4. the request must not have expired;
 864//  5. the owner's pool must cover the GROSS amount;
 865//  6. the desk's spendbook authority must permit the gross amount.
 866//
 867// Solvency (5) is checked BEFORE the allowance is consumed (6), so an
 868// approval refused for insolvency leaves the desk's window untouched
 869// rather than burning capacity on an approval that did not happen. This
 870// is budgets' ordering, kept for the same reason.
 871func Approve(cur realm, reqID int64) int64 {
 872	rejectStraySend(cur)
 873	approver := cur.Previous().Address()
 874	r := mustRequest(reqID)
 875	d := mustDesk(r.deskID)
 876
 877	if approver != d.approver {
 878		panic("approver only")
 879	}
 880	if r.claimant == approver {
 881		// Conflict of interest, enforced per-approval so it cannot be
 882		// routed around via a second desk the same address approves.
 883		panic("an approver cannot approve their own request")
 884	}
 885	now := runtime.ChainHeight()
 886	if now >= r.expiresAt {
 887		panic("request has expired; anyone may ExpireRequest it")
 888	}
 889
 890	ownerKey := d.owner.String()
 891	pool := poolOf(ownerKey)
 892	if r.amount > pool {
 893		// A pending request is not a reservation (package doc). The honest
 894		// failure is here, at approval time, rather than a credit the
 895		// claimant could not claim.
 896		panic("owner's pool cannot cover this request")
 897	}
 898
 899	// Authority. spendbook checks every bound and CONSUMES the allowance
 900	// in this one call, before returning; on any error nothing is
 901	// consumed. Its sentinel errors name the reason. The allowance is
 902	// consumed by the GROSS, so the desk bounds the owner's outflow.
 903	if err := book.Spend(d.allowID, approver.String(), now, r.amount); err != nil {
 904		panic(err)
 905	}
 906
 907	newApproved, ok := checkedAdd(d.approved, r.amount)
 908	if !ok {
 909		panic("desk approved total would overflow")
 910	}
 911	newLifetime, ok := checkedAdd(lifetimeApproved, r.amount)
 912	if !ok {
 913		panic("lifetime approved would overflow")
 914	}
 915	newApprovals, ok := checkedAdd(lifetimeApprovals, 1)
 916	if !ok {
 917		panic("lifetime approvals would overflow")
 918	}
 919	// The desk counter is guarded for the same reason as every other
 920	// accumulation here, and not because the overflow is reachable: it
 921	// costs one transaction per increment, so it is not. The audit (G1)
 922	// flagged `d.approvals++` and `d.rejections++` as the only two bare
 923	// accumulations in the realm -- nineteen lines from a checkedAdd of
 924	// the same shape -- and read the asymmetry as mild evidence that two
 925	// lines had been skipped rather than reasoned about. That is the
 926	// cheap reading to close BEFORE addpkg, after which it is closed
 927	// forever: a reader who has to re-derive the reachability argument
 928	// at every bare `++` eventually stops deriving it.
 929	newDeskApprovals, ok := checkedAdd(d.approvals, 1)
 930	if !ok {
 931		panic("desk approvals would overflow")
 932	}
 933
 934	// Move the gross out of P and into L. H does not move, which is why
 935	// this transition has no payout to fail.
 936	setPool(ownerKey, pool-r.amount)
 937	totalPooled -= r.amount
 938	credited, fee := ledger.MustDeposit(r.claimant.String(), r.amount, r.feeBps)
 939	d.approved = newApproved
 940	d.approvals = newDeskApprovals
 941	lifetimeApproved = newLifetime
 942	lifetimeApprovals = newApprovals
 943	dropRequest(r)
 944
 945	chain.Emit("Approved", "id", itoa(r.id), "desk", itoa(d.id),
 946		"approver", approver.String(), "claimant", r.claimant.String(),
 947		"gross", itoa(r.amount), "credited", itoa(credited),
 948		"fee", itoa(fee), "pool", itoa(pool-r.amount))
 949	return credited
 950}
 951
 952// Reject closes a request without settling it. APPROVER ONLY. Nothing
 953// moves in any term of the invariant, and no authority is consumed — a
 954// rejection costs the desk's window nothing, which is deliberate: an
 955// approver must be able to say no as often as necessary.
 956//
 957// Rejecting an EXPIRED request is allowed. The alternative would force an
 958// approver who wants the record to carry a reason to race the expiry, and
 959// the reason is the useful artifact: ExpireRequest emits no explanation.
 960func Reject(cur realm, reqID int64, reason string) {
 961	rejectStraySend(cur)
 962	approver := cur.Previous().Address()
 963	r := mustRequest(reqID)
 964	d := mustDesk(r.deskID)
 965
 966	if approver != d.approver {
 967		panic("approver only")
 968	}
 969	if len(reason) > MaxMemoLen {
 970		panic("reason exceeds " + strconv.Itoa(MaxMemoLen) + " bytes")
 971	}
 972	newRejected, ok := checkedAdd(lifetimeRejected, 1)
 973	if !ok {
 974		panic("lifetime rejected would overflow")
 975	}
 976	// Guarded for the reason given at the desk-approvals counter above.
 977	newDeskRejections, ok := checkedAdd(d.rejections, 1)
 978	if !ok {
 979		panic("desk rejections would overflow")
 980	}
 981
 982	d.rejections = newDeskRejections
 983	lifetimeRejected = newRejected
 984	dropRequest(r)
 985
 986	chain.Emit("Rejected", "id", itoa(r.id), "desk", itoa(d.id),
 987		"approver", approver.String(), "claimant", r.claimant.String(),
 988		"amount", itoa(r.amount), "reason", reason)
 989}
 990
 991// Retract removes the caller's own pending request. CLAIMANT ONLY.
 992// Nothing moves.
 993//
 994// Named Retract rather than Withdraw on purpose: every Withdraw* in this
 995// portfolio moves money, and this moves none. A claimant reading a
 996// function list should not have to open the source to learn which.
 997func Retract(cur realm, reqID int64) {
 998	rejectStraySend(cur)
 999	claimant := cur.Previous().Address()
1000	r := mustRequest(reqID)
1001	if claimant != r.claimant {
1002		panic("claimant only")
1003	}
1004	newRetracted, ok := checkedAdd(lifetimeRetracted, 1)
1005	if !ok {
1006		panic("lifetime retracted would overflow")
1007	}
1008	lifetimeRetracted = newRetracted
1009	dropRequest(r)
1010
1011	chain.Emit("Retracted", "id", itoa(r.id), "desk", itoa(r.deskID),
1012		"claimant", claimant.String(), "amount", itoa(r.amount))
1013}
1014
1015// ExpireRequest removes a request past its expiry, freeing its slot and
1016// its storage. ANYONE may call it, and the reasoning matches ReapDesk's:
1017// an expired request can never be approved, so removing it takes nothing
1018// from the claimant and grants nothing to the caller. The chain refunds
1019// the freed storage deposit to the expiring transaction, which is the
1020// standing incentive and the mechanism that drains the queue.
1021func ExpireRequest(cur realm, reqID int64) {
1022	rejectStraySend(cur)
1023	r := mustRequest(reqID)
1024	now := runtime.ChainHeight()
1025	if now < r.expiresAt {
1026		panic("request has not expired yet")
1027	}
1028	newExpired, ok := checkedAdd(lifetimeExpired, 1)
1029	if !ok {
1030		panic("lifetime expired would overflow")
1031	}
1032	lifetimeExpired = newExpired
1033	dropRequest(r)
1034
1035	chain.Emit("RequestExpired", "id", itoa(r.id), "desk", itoa(r.deskID),
1036		"claimant", r.claimant.String(), "amount", itoa(r.amount),
1037		"by", cur.Previous().Address().String())
1038}
1039
1040// --- the pull ---
1041
1042// Claim pays the caller's entire settled credit out. CLAIMANT ONLY: the
1043// account is the calling address. Credit from a desk that has since been
1044// closed, expired or reaped is still claimable — it was settled under a
1045// live authority and this realm does not revisit that.
1046func Claim(cur realm) int64 {
1047	rejectStraySend(cur)
1048	account := cur.Previous().Address()
1049	key := account.String()
1050
1051	amount, err := ledger.WithdrawAll(key)
1052	if err != nil {
1053		panic(err)
1054	}
1055	if amount == 0 {
1056		panic("no credit to claim")
1057	}
1058	newClaimed, ok := checkedAdd(lifetimeClaimed, amount)
1059	if !ok {
1060		panic("lifetime claimed would overflow")
1061	}
1062	lifetimeClaimed = newClaimed
1063
1064	coinio.Payout(0, cur, account, Denom, amount)
1065
1066	chain.Emit("Claimed", "account", key, "amount", itoa(amount))
1067	return amount
1068}
1069
1070// --- fees and surplus ---
1071
1072// WithdrawFees pays the entire accrued fee pot to the compile-time
1073// FeeCollector. Anyone may call it: the destination is fixed, so there is
1074// nothing for a caller to steer.
1075func WithdrawFees(cur realm) int64 {
1076	rejectStraySend(cur)
1077	fees := ledger.WithdrawFees()
1078	if fees == 0 {
1079		panic("no fees accrued")
1080	}
1081	coinio.Payout(0, cur, FeeCollector, Denom, fees)
1082	chain.Emit("FeesWithdrawn", "to", FeeCollector.String(), "amount", itoa(fees))
1083	return fees
1084}
1085
1086// SweepSurplus sends out-of-band coins to the FeeCollector: for the pool
1087// denom, everything above tracked liabilities; for any foreign denom, the
1088// full balance. Owner pools, claimant credit and the fee pot are
1089// untouchable by construction (coinio.Sweep refuses to dip below the
1090// reserve). Anyone may call it.
1091//
1092// With nothing out of band this ABORTS ("coinio: no surplus to sweep for
1093// <denom>") rather than returning 0. That is the primitive's choice and
1094// the right one: a sweep is a transfer, and a transfer of nothing that
1095// reports success is how a caller comes to believe a balance was moved.
1096func SweepSurplus(cur realm, denom string) int64 {
1097	rejectStraySend(cur)
1098	reserve := int64(0)
1099	if denom == Denom {
1100		reserve = Liabilities()
1101	}
1102	swept := coinio.Sweep(0, cur, FeeCollector, denom, reserve)
1103	chain.Emit("Swept", "denom", denom, "to", FeeCollector.String(),
1104		"amount", itoa(swept))
1105	return swept
1106}
1107
1108// --- desk views ---
1109
1110// DeskInfo returns a desk's parties and the terms currently recorded for
1111// it. perPeriod reflects a tightening amendment immediately; a queued
1112// loosening shows in PendingTerms until its boundary arrives.
1113func DeskInfo(id int64) (owner, approver address, perPeriod, periodLength,
1114	total, expiresAt, anchor, createdAt, feeBps int64) {
1115	d := mustDesk(id)
1116	a := mustAllowance(d)
1117	return d.owner, d.approver, a.Terms.PerPeriod, a.Terms.PeriodLength,
1118		a.Terms.Total, a.Terms.ExpiresAt, a.Anchor, d.createdAt, d.feeBps
1119}
1120
1121// DeskUsage returns a desk's consumption AT THE CURRENT HEIGHT: what the
1122// window the chain is in right now has approved, the lifetime gross, that
1123// window's index, how many approvals there have been, how many
1124// rejections, and how many requests are pending at the desk.
1125//
1126// The two window figures are computed rather than read straight off the
1127// record, and that is the whole point of windowAt below: a window that
1128// has rolled with no approval since leaves Allowance.WindowIndex naming a
1129// window the chain has already left, and Allowance.SpentWindow holding
1130// the previous window's spend.
1131func DeskUsage(id int64) (approvedWindow, approvedTotal, windowIndex,
1132	approvals, rejections, pending int64) {
1133	d := mustDesk(id)
1134	a := mustAllowance(d)
1135	now := runtime.ChainHeight()
1136	return spentInWindow(a, now), a.SpentTotal, windowAt(a, now),
1137		d.approvals, d.rejections, deskOpen(id)
1138}
1139
1140// DeskLabel returns a desk's raw label (unsanitized: callers rendering it
1141// into markdown must sanitize, as this realm's Render does).
1142func DeskLabel(id int64) string { return mustDesk(id).label }
1143
1144// Remaining is the AUTHORITY figure for a desk: what its terms permit the
1145// approver to approve right now, ignoring whether the owner's pool can
1146// cover it.
1147func Remaining(id int64) int64 {
1148	d := mustDesk(id)
1149	return book.Remaining(d.allowID, runtime.ChainHeight())
1150}
1151
1152// Approvable is the HONEST figure for a desk, and the one both an
1153// approver and a prospective claimant should read: the lesser of what the
1154// desk authorizes and what the owner's pool holds. This distinction is
1155// the realm's own — spendbook bounds permission and says so, never
1156// solvency.
1157func Approvable(id int64) int64 {
1158	d := mustDesk(id)
1159	authorized := book.Remaining(d.allowID, runtime.ChainHeight())
1160	pool := poolOf(d.owner.String())
1161	if pool < authorized {
1162		return pool
1163	}
1164	return authorized
1165}
1166
1167// PendingTerms returns a queued loosening amendment on a desk and whether
1168// there is one. An amendment is queued only when it would widen the live
1169// window, and it takes effect at the next window boundary.
1170func PendingTerms(id int64) (perPeriod, total, expiresAt int64, pending bool) {
1171	a := mustAllowance(mustDesk(id))
1172	if !a.HasPending {
1173		return 0, 0, 0, false
1174	}
1175	return a.Pending.PerPeriod, a.Pending.Total, a.Pending.ExpiresAt, true
1176}
1177
1178// IsDeskExpired reports whether a desk's authority has lapsed, which is
1179// also the condition under which anyone may ReapDesk it.
1180//
1181// It reads the LIVE expiry only. A queued expiry extension reported by
1182// PendingTerms is NOT considered here, so this can read true while an
1183// amendment widening the window is still queued — see AmendDesk for the
1184// full disclosure and why no funds are at risk.
1185func IsDeskExpired(id int64) bool {
1186	return mustAllowance(mustDesk(id)).IsExpired(runtime.ChainHeight())
1187}
1188
1189// --- request views ---
1190
1191// RequestInfo returns a request's desk, claimant, amount, snapshotted
1192// fee, and the two heights that bound it.
1193func RequestInfo(id int64) (deskID int64, claimant address,
1194	amount, feeBps, submittedAt, expiresAt int64) {
1195	r := mustRequest(id)
1196	return r.deskID, r.claimant, r.amount, r.feeBps, r.submittedAt, r.expiresAt
1197}
1198
1199// RequestMemo returns a request's raw memo (unsanitized: callers
1200// rendering it into markdown must sanitize, as this realm's Render does).
1201func RequestMemo(id int64) string { return mustRequest(id).memo }
1202
1203// IsRequestExpired reports whether a request is past its expiry, which is
1204// both the condition under which Approve refuses and the condition under
1205// which anyone may ExpireRequest it.
1206func IsRequestExpired(id int64) bool {
1207	return runtime.ChainHeight() >= mustRequest(id).expiresAt
1208}
1209
1210// ApprovalBlocker returns the empty string when the request could be
1211// approved right now by its desk's approver, and otherwise the single
1212// reason it could not. It is the view a UI wants before showing an
1213// approve button, and an approver wants before paying gas for a call that
1214// will abort.
1215//
1216// The reasons are reported in the same order Approve checks them, so this
1217// view and that entrypoint can never disagree about WHICH reason applies.
1218// It deliberately does NOT report "you are not the approver": the caller
1219// of a view is not the caller of the approval, so this answers about the
1220// request and the desk, not about whoever is asking.
1221func ApprovalBlocker(id int64) string {
1222	r := mustRequest(id)
1223	v := desks.Get(padID(r.deskID))
1224	if v == nil {
1225		return "the desk has been closed or reaped; this request can only " +
1226			"be retracted by its claimant or expired by anyone"
1227	}
1228	d := v.(*desk)
1229	if r.claimant == d.approver {
1230		return "the desk's approver is the claimant; self-approval is refused"
1231	}
1232	if runtime.ChainHeight() >= r.expiresAt {
1233		return "the request has expired"
1234	}
1235	if r.amount > poolOf(d.owner.String()) {
1236		return "the owner's pool cannot cover this request"
1237	}
1238	if r.amount > book.Remaining(d.allowID, runtime.ChainHeight()) {
1239		return "the desk's authority does not cover this request at this " +
1240			"height (window cap spent, lifetime cap reached, or expired)"
1241	}
1242	return ""
1243}
1244
1245// --- aggregate views ---
1246
1247// PoolOf returns an owner's unspent pool.
1248func PoolOf(addr address) int64 { return poolOf(addr.String()) }
1249
1250// CreditOf returns a claimant's settled-but-unclaimed credit.
1251func CreditOf(addr address) int64 { return ledger.BalanceOf(addr.String()) }
1252
1253// DesksOf returns how many live desks an owner currently holds.
1254func DesksOf(addr address) int64 { return ownerDesks(addr.String()) }
1255
1256// OpenRequestsOf returns how many pending requests a claimant has.
1257func OpenRequestsOf(addr address) int64 { return claimantOpen(addr.String()) }
1258
1259// OpenRequestsAt returns how many pending requests a desk has.
1260func OpenRequestsAt(deskID int64) int64 { return deskOpen(deskID) }
1261
1262// NumDesks returns how many desks have ever been opened. Ids are never
1263// reused, so this only grows.
1264func NumDesks() int64 { return nextDeskID }
1265
1266// LiveDesks returns how many desks are currently live, from the
1267// primitive's own count.
1268func LiveDesks() int64 { return int64(book.Count()) }
1269
1270// NumRequests returns how many requests have ever been filed. Ids are
1271// never reused, so this only grows.
1272func NumRequests() int64 { return nextReqID }
1273
1274// LiveRequests returns how many requests are currently pending. A pending
1275// request may still be unapprovable — see ApprovalBlocker.
1276func LiveRequests() int64 { return int64(requests.Size()) }
1277
1278// TotalPooled returns the P term: unspent owner funds. Pending requests
1279// are NOT included; they are not liabilities.
1280func TotalPooled() int64 { return totalPooled }
1281
1282// UnclaimedCredit returns the claimants' share of the L term: approved
1283// and not yet claimed.
1284func UnclaimedCredit() int64 { return ledger.UsersTotal() }
1285
1286// FeesAccrued returns the fee pot: charged, not yet withdrawn.
1287func FeesAccrued() int64 { return ledger.FeesAccrued() }
1288
1289// Liabilities returns P + L — everything this realm owes. A pending
1290// request is not part of it.
1291func Liabilities() int64 { return totalPooled + ledger.Liabilities() }
1292
1293// Held returns H: the ugnot actually at this realm's address.
1294func Held() int64 { return coinio.HeldAt(self, Denom) }
1295
1296// Surplus returns H - (P + L) — out-of-band coins, sweepable.
1297func Surplus() int64 { return Held() - Liabilities() }
1298
1299// Address returns this realm's own address.
1300func Address() address { return self }
1301
1302// Height returns the current chain height: the clock every window,
1303// expiry and TTL is measured against, and the one no caller can choose.
1304func Height() int64 { return runtime.ChainHeight() }
1305
1306// --- render ---
1307
1308// Render shows the realm at "", a desk page at "desk/<id>" and a request
1309// page at "request/<id>". Labels and memos are free text and pass through
1310// the ecosystem sanitizer before hitting markdown.
1311func Render(path string) string {
1312	switch {
1313	case path == "":
1314		return renderIndex()
1315	case strings.HasPrefix(path, "desk/"):
1316		return renderDesk(path[len("desk/"):])
1317	case strings.HasPrefix(path, "request/"):
1318		return renderRequest(path[len("request/"):])
1319	}
1320	return "> [!WARNING]\n> unknown path. Try `desk/<id>` or `request/<id>`.\n"
1321}
1322
1323func renderIndex() string {
1324	now := runtime.ChainHeight()
1325	held := Held()
1326	liab := Liabilities()
1327	conservation := "OK"
1328	if held < liab {
1329		conservation = "VIOLATED"
1330	}
1331
1332	out := "# Reimbursements\n\n"
1333	out += "Claimant-initiated expense reimbursement in GNOT. An owner " +
1334		"funds a pool and appoints an approver with a bounded, " +
1335		"rate-limited, revocable authority over it. **Anyone** may submit " +
1336		"a request with an amount and a description; the approver either " +
1337		"approves it — which settles the money into the claimant's credit " +
1338		"in the same transaction — or rejects it.\n\n"
1339	out += "> [!IMPORTANT]\n> A pending request is **not** a reservation " +
1340		"and not a liability. Nothing moves until an approval, and an " +
1341		"approval is atomic. Read `Approvable(deskID)` for what a desk can " +
1342		"actually settle right now, and `ApprovalBlocker(reqID)` for why a " +
1343		"specific request cannot be.\n\n"
1344	out += "## Terms (fixed at deploy; no admin, no setters)\n\n"
1345	out += "- approval fee: " + bps(ApprovalFeeBps) +
1346		" of the gross approved amount, paid from the owner's pool (hard cap " +
1347		bps(MaxFeeBps) + ")\n"
1348	out += "- no fee on funding, on withdrawing an unspent pool, or on claiming\n"
1349	out += "- self-approval is refused: an approver cannot approve their own request\n"
1350	out += "- minimum funding: " + itoa(MinFunding) + Denom + " per call\n"
1351	out += "- minimum request: " + itoa(MinRequestAmount) + Denom + "\n"
1352	out += "- minimum per-window approval cap: " + itoa(MinPerPeriod) + Denom + "\n"
1353	out += "- window length: 0 (one lifetime window) or at least " +
1354		itoa(MinPeriodBlocks) + " blocks\n"
1355	out += "- request expiry: " + itoa(RequestTTL) +
1356		" blocks after submission, then anyone may ExpireRequest it\n"
1357	out += "- live desks: " + itoa(LiveDesks()) + " of " +
1358		strconv.Itoa(MaxLiveDesks) + " (per owner: " + itoa(MaxDesksPerOwner) + ")\n"
1359	out += "- pending requests: " + itoa(LiveRequests()) + " of " +
1360		strconv.Itoa(MaxLiveRequests) + " (per claimant: " +
1361		itoa(MaxOpenPerClaimant) + ", per desk: " + itoa(MaxOpenPerDesk) + ")\n"
1362	out += "- current height: " + itoa(now) + "\n\n"
1363	out += "## Accounting (H == P + L + S)\n\n"
1364	out += "- pooled (P): " + itoa(totalPooled) + Denom + "\n"
1365	out += "- unclaimed claimant credit: " + itoa(ledger.UsersTotal()) + Denom + "\n"
1366	out += "- fee pot: " + itoa(ledger.FeesAccrued()) + Denom + "\n"
1367	out += "- held (H): " + itoa(held) + Denom + "\n"
1368	out += "- surplus (S): " + itoa(held-liab) + Denom + "\n"
1369	out += "- conservation: " + conservation + "\n"
1370	out += "- lifetime funded / approved / claimed / returned: " +
1371		itoa(lifetimeFunded) + " / " + itoa(lifetimeApproved) + " / " +
1372		itoa(lifetimeClaimed) + " / " + itoa(lifetimeReturned) + Denom + "\n"
1373	out += "- requests submitted / approved / rejected / retracted / expired: " +
1374		itoa(lifetimeSubmitted) + " / " + itoa(lifetimeApprovals) + " / " +
1375		itoa(lifetimeRejected) + " / " + itoa(lifetimeRetracted) + " / " +
1376		itoa(lifetimeExpired) + "\n\n"
1377
1378	out += renderDeskList(now)
1379	out += renderRequestList(now)
1380	return out
1381}
1382
1383func renderDeskList(now int64) string {
1384	out := "## Live desks\n\n"
1385	if desks.Size() == 0 {
1386		return out + "None yet. Fund a pool, then OpenDesk an approver.\n\n"
1387	}
1388	shown := 0
1389	expired := 0
1390	desks.ReverseIterate("", "", func(_ string, v any) bool {
1391		d := v.(*desk)
1392		a, aerr := book.Get(d.allowID)
1393		state := "live"
1394		if aerr != nil {
1395			state = "missing"
1396		} else if a.IsExpired(now) {
1397			state = "expired (reapable)"
1398			expired++
1399		}
1400		out += "- [desk #" + itoa(d.id) + "](" + RealmPath + ":desk/" +
1401			itoa(d.id) + ") [" + state + "] " +
1402			sanitize.InlineText(labelOr(d)) + " — approvable " +
1403			itoa(Approvable(d.id)) + Denom + ", " + itoa(deskOpen(d.id)) +
1404			" pending\n"
1405		shown++
1406		return shown >= MaxRenderRows
1407	})
1408	if shown < desks.Size() {
1409		out += "\n> [!NOTE]\n> Showing the " + strconv.Itoa(shown) +
1410			" most recent of " + strconv.Itoa(desks.Size()) +
1411			" live desks (" + itoa(nextDeskID) +
1412			" ever opened; closed and reaped records are removed). " +
1413			"Use DeskInfo(id) for any specific desk.\n"
1414	}
1415	if expired > 0 {
1416		out += "\n> [!TIP]\n> Anyone may ReapDesk(id) an expired desk to " +
1417			"free its slot and reclaim its storage deposit: " +
1418			strconv.Itoa(expired) + " of the desks above are reapable.\n"
1419	}
1420	return out + "\n"
1421}
1422
1423func renderRequestList(now int64) string {
1424	out := "## Pending requests\n\n"
1425	if requests.Size() == 0 {
1426		return out + "None pending.\n"
1427	}
1428	shown := 0
1429	stale := 0
1430	requests.ReverseIterate("", "", func(_ string, v any) bool {
1431		r := v.(*request)
1432		state := "awaiting approval"
1433		if now >= r.expiresAt {
1434			state = "expired (clearable)"
1435			stale++
1436		} else if blocker := ApprovalBlocker(r.id); blocker != "" {
1437			state = "blocked"
1438		}
1439		out += "- [request #" + itoa(r.id) + "](" + RealmPath + ":request/" +
1440			itoa(r.id) + ") [" + state + "] " + itoa(r.amount) + Denom +
1441			" at desk #" + itoa(r.deskID) + " — " +
1442			sanitize.InlineText(r.memo) + "\n"
1443		shown++
1444		return shown >= MaxRenderRows
1445	})
1446	if shown < requests.Size() {
1447		out += "\n> [!NOTE]\n> Showing the " + strconv.Itoa(shown) +
1448			" most recent of " + strconv.Itoa(requests.Size()) +
1449			" pending requests (" + itoa(nextReqID) +
1450			" ever filed; settled and cleared records are removed). " +
1451			"Use RequestInfo(id) for any specific request.\n"
1452	}
1453	if stale > 0 {
1454		out += "\n> [!TIP]\n> Anyone may ExpireRequest(id) a request past " +
1455			"its expiry to free its slot and reclaim its storage deposit: " +
1456			strconv.Itoa(stale) + " of the requests above are clearable.\n"
1457	}
1458	return out
1459}
1460
1461func renderDesk(idStr string) string {
1462	id, err := strconv.ParseInt(idStr, 10, 64)
1463	if err != nil {
1464		return "> [!WARNING]\n> invalid desk id\n"
1465	}
1466	v := desks.Get(padID(id))
1467	if v == nil {
1468		return "> [!WARNING]\n> unknown desk id (never opened, or closed/reaped)\n"
1469	}
1470	d := v.(*desk)
1471	a, gerr := book.Get(d.allowID)
1472	if gerr != nil {
1473		// Unreachable: dropDesk removes the record and the allowance
1474		// together, in one call. Rendered rather than panicked so a
1475		// corrupted pair is visible instead of breaking the page.
1476		return "> [!CAUTION]\n> desk #" + itoa(d.id) +
1477			" has no backing allowance; this is a bug. Reason: " +
1478			sanitize.InlineText(gerr.Error()) + "\n"
1479	}
1480	now := runtime.ChainHeight()
1481
1482	out := "# Desk #" + itoa(d.id)
1483	if d.label != "" {
1484		out += ": " + sanitize.InlineText(d.label)
1485	}
1486	out += "\n\n"
1487	out += "- owner (funds the pool): `" + d.owner.String() + "`\n"
1488	out += "- approver (decides): `" + d.approver.String() + "`\n"
1489	out += "- per-window approval cap: " + itoa(a.Terms.PerPeriod) + Denom + "\n"
1490	if a.Terms.PeriodLength == 0 {
1491		out += "- window: one lifetime window; the cap never refills\n"
1492	} else {
1493		out += "- window: " + itoa(a.Terms.PeriodLength) +
1494			" blocks, anchored at " + itoa(a.Anchor) +
1495			" (now in window " + itoa(windowAt(a, now)) + ")\n"
1496	}
1497	if a.Terms.Total == 0 {
1498		out += "- lifetime cap: none\n"
1499	} else {
1500		out += "- lifetime cap: " + itoa(a.Terms.Total) + Denom +
1501			" (" + itoa(a.SpentTotal) + " used)\n"
1502	}
1503	if a.Terms.ExpiresAt == 0 {
1504		out += "- expiry: none (only the owner can close it)\n"
1505	} else {
1506		out += "- expiry: block " + itoa(a.Terms.ExpiresAt) + "\n"
1507	}
1508	out += "- approval fee: " + bps(d.feeBps) + " (snapshotted at desk open)\n"
1509	out += "- approved this window: " + itoa(spentInWindow(a, now)) + Denom + "\n"
1510	out += "- approved in total: " + itoa(d.approved) + Denom + " over " +
1511		itoa(d.approvals) + " approvals (" + itoa(d.rejections) + " rejected)\n"
1512	out += "- pending requests here: " + itoa(deskOpen(d.id)) + " of " +
1513		itoa(MaxOpenPerDesk) + "\n"
1514	out += "- opened at block: " + itoa(d.createdAt) + "\n"
1515	out += "- current height: " + itoa(now) + "\n\n"
1516
1517	authorized := a.Remaining(now)
1518	pool := poolOf(d.owner.String())
1519	out += "## What can be approved right now\n\n"
1520	out += "- authorized by the terms: " + itoa(authorized) + Denom + "\n"
1521	out += "- owner's unspent pool: " + itoa(pool) + Denom + "\n"
1522	approvable := authorized
1523	if pool < approvable {
1524		approvable = pool
1525	}
1526	out += "- **approvable: " + itoa(approvable) + Denom +
1527		"** (the lesser of the two)\n\n"
1528
1529	switch {
1530	case a.IsExpired(now):
1531		out += "This desk has **expired**. Anyone may ReapDesk(" + itoa(d.id) +
1532			") to free its slot and reclaim its storage deposit. Requests " +
1533			"filed here can no longer be approved.\n"
1534	case approvable == 0 && authorized == 0:
1535		out += "Nothing authorized at this height — the window cap is spent, " +
1536			"the lifetime cap is reached, or the desk has not started.\n"
1537	case approvable == 0:
1538		out += "Authorized but **not funded**: the owner's pool cannot cover " +
1539			"an approval. A pending request is not a reservation.\n"
1540	default:
1541		out += "A claimant may Submit(" + itoa(d.id) +
1542			", amount, memo) here, and the approver may Approve(reqID) up " +
1543			"to the approvable figure. The claimant then Claim()s.\n"
1544	}
1545	if a.HasPending {
1546		out += "\n> [!NOTE]\n> A **looser** amendment is queued and takes " +
1547			"effect at the next window boundary: per-window cap " +
1548			itoa(a.Pending.PerPeriod) + Denom + ", lifetime cap " +
1549			itoa(a.Pending.Total) + ", expiry " + itoa(a.Pending.ExpiresAt) +
1550			" (0 means none). Until then the terms above are what govern.\n"
1551	}
1552	return out
1553}
1554
1555func renderRequest(idStr string) string {
1556	id, err := strconv.ParseInt(idStr, 10, 64)
1557	if err != nil {
1558		return "> [!WARNING]\n> invalid request id\n"
1559	}
1560	v := requests.Get(padID(id))
1561	if v == nil {
1562		return "> [!WARNING]\n> unknown request id (never filed, or already " +
1563			"approved/rejected/retracted/expired)\n"
1564	}
1565	r := v.(*request)
1566	now := runtime.ChainHeight()
1567
1568	out := "# Request #" + itoa(r.id) + "\n\n"
1569	out += "- claimant: `" + r.claimant.String() + "`\n"
1570	out += "- desk: [#" + itoa(r.deskID) + "](" + RealmPath + ":desk/" +
1571		itoa(r.deskID) + ")\n"
1572	out += "- amount requested (gross): " + itoa(r.amount) + Denom + "\n"
1573	out += "- approval fee if settled: " + bps(r.feeBps) +
1574		" (snapshotted at submission)\n"
1575	out += "- submitted at block: " + itoa(r.submittedAt) + "\n"
1576	out += "- expires at block: " + itoa(r.expiresAt) + "\n"
1577	out += "- current height: " + itoa(now) + "\n\n"
1578	out += "## Expense\n\n> " + sanitize.InlineText(r.memo) + "\n\n"
1579	out += "## Status\n\n"
1580	if blocker := ApprovalBlocker(r.id); blocker != "" {
1581		out += "> [!WARNING]\n> **Not approvable:** " + blocker + "\n"
1582	} else {
1583		out += "> [!NOTE]\n> **Approvable now.** The desk's approver may " +
1584			"Approve(" + itoa(r.id) + "), which credits the claimant in the " +
1585			"same transaction; the claimant then Claim()s.\n"
1586	}
1587	out += "\nThe claimant may Retract(" + itoa(r.id) +
1588		") at any time while it is pending. Nothing has moved yet: a " +
1589		"pending request is not a reservation.\n"
1590	return out
1591}
1592
1593// --- internals ---
1594
1595func mustDesk(id int64) *desk {
1596	v := desks.Get(padID(id))
1597	if v == nil {
1598		panic("unknown desk id")
1599	}
1600	return v.(*desk)
1601}
1602
1603func mustRequest(id int64) *request {
1604	v := requests.Get(padID(id))
1605	if v == nil {
1606		panic("unknown request id")
1607	}
1608	return v.(*request)
1609}
1610
1611// mustAllowance fetches the spendbook allowance behind a desk. The pair
1612// is created and removed together, so a missing allowance is a bug in
1613// this realm rather than a user error, and it panics rather than
1614// reporting a zero value that a reader would take for "no authority".
1615func mustAllowance(d *desk) spendbook.Allowance {
1616	a, err := book.Get(d.allowID)
1617	if err != nil {
1618		panic(err)
1619	}
1620	return a
1621}
1622
1623// windowAt returns the anchored window index containing now, and
1624// spentInWindow what that window has approved.
1625//
1626// These duplicate a line of spendbook, and the duplication is deliberate,
1627// bounded, and worth justifying because the primitive's own at() comment
1628// warns against exactly this. What is NOT reimplemented is the part that
1629// warning is about: which terms are in force after a boundary, and
1630// therefore every authority figure this realm reports — Remaining and
1631// Approvable both call through to the primitive, and nothing here
1632// second-guesses them.
1633//
1634// What is copied is pure index arithmetic over three EXPORTED fields plus
1635// the already-documented fact that the in-window spend RESETS rather than
1636// carries. The alternative is worse than the duplication: Allowance's
1637// WindowIndex and SpentWindow are the last-WRITTEN pair, so between a
1638// boundary and the next approval they describe a window the chain has
1639// left, and a Render publishing them would print "approved this window:
1640// 100000" immediately above "authorized by the terms: 100000".
1641//
1642// The two are pinned to the primitive by
1643// TestWindowRefillsAtAnchoredBoundary rather than by inspection: it
1644// approves after crossing a boundary and asserts approvedWindow equals
1645// that approval, which can only hold if windowAt agreed with spendbook's
1646// own index at the moment of the write.
1647func windowAt(a spendbook.Allowance, now int64) int64 {
1648	if a.Terms.PeriodLength == 0 || now < a.Anchor {
1649		return 0
1650	}
1651	return (now - a.Anchor) / a.Terms.PeriodLength
1652}
1653
1654func spentInWindow(a spendbook.Allowance, now int64) int64 {
1655	if windowAt(a, now) != a.WindowIndex {
1656		return 0
1657	}
1658	return a.SpentWindow
1659}
1660
1661// dropDesk removes a desk record and decrements its owner's live count.
1662// The allowance itself is removed by the spendbook call that precedes
1663// every caller of this helper.
1664//
1665// It deliberately does NOT remove the desk's pending requests — see the
1666// package doc, "a closed desk leaves its requests un-approvable". Its
1667// openByDesk counter IS removed, because the counter exists to bound new
1668// submissions at a live desk and a dead desk accepts none; the stranded
1669// requests remain findable by id and are still counted against their
1670// claimants' openByClaimant, which is what bounds them.
1671func dropDesk(d *desk) {
1672	desks.Remove(padID(d.id))
1673	openByDesk.Remove(padID(d.id))
1674	decOwnerDesks(d.owner.String())
1675}
1676
1677// dropRequest removes a request record and decrements both of its
1678// counters. Every caller has already decided the request's outcome;
1679// this is only the bookkeeping.
1680func dropRequest(r *request) {
1681	requests.Remove(padID(r.id))
1682	decClaimantOpen(r.claimant.String())
1683	// A request at a desk closed since submission has no openByDesk entry
1684	// to decrement (dropDesk removed it); setDeskOpen floors at zero and
1685	// removes rather than storing a negative, so this is safe rather than
1686	// merely harmless.
1687	setDeskOpen(r.deskID, deskOpen(r.deskID)-1)
1688}
1689
1690func labelOr(d *desk) string {
1691	if d.label == "" {
1692		return "(no label)"
1693	}
1694	return d.label
1695}
1696
1697func poolOf(key string) int64 {
1698	v := pools.Get(key)
1699	if v == nil {
1700		return 0
1701	}
1702	return v.(int64)
1703}
1704
1705func setPool(key string, n int64) {
1706	if n <= 0 {
1707		pools.Remove(key)
1708		return
1709	}
1710	pools.Set(key, n)
1711}
1712
1713func ownerDesks(key string) int64 {
1714	v := desksByOwner.Get(key)
1715	if v == nil {
1716		return 0
1717	}
1718	return v.(int64)
1719}
1720
1721func setOwnerDesks(key string, n int64) {
1722	if n <= 0 {
1723		desksByOwner.Remove(key)
1724		return
1725	}
1726	desksByOwner.Set(key, n)
1727}
1728
1729func decOwnerDesks(key string) { setOwnerDesks(key, ownerDesks(key)-1) }
1730
1731func claimantOpen(key string) int64 {
1732	v := openByClaimant.Get(key)
1733	if v == nil {
1734		return 0
1735	}
1736	return v.(int64)
1737}
1738
1739func setClaimantOpen(key string, n int64) {
1740	if n <= 0 {
1741		openByClaimant.Remove(key)
1742		return
1743	}
1744	openByClaimant.Set(key, n)
1745}
1746
1747func decClaimantOpen(key string) { setClaimantOpen(key, claimantOpen(key)-1) }
1748
1749func deskOpen(deskID int64) int64 {
1750	v := openByDesk.Get(padID(deskID))
1751	if v == nil {
1752		return 0
1753	}
1754	return v.(int64)
1755}
1756
1757func setDeskOpen(deskID int64, n int64) {
1758	if n <= 0 {
1759		openByDesk.Remove(padID(deskID))
1760		return
1761	}
1762	openByDesk.Set(padID(deskID), n)
1763}
1764
1765func itoa(n int64) string { return strconv.FormatInt(n, 10) }
1766
1767func boolstr(b bool) string {
1768	if b {
1769		return "true"
1770	}
1771	return "false"
1772}
1773
1774// bps renders a basis-point figure as a human percentage, exactly.
1775func bps(n int64) string {
1776	whole := n / 100
1777	frac := n % 100
1778	if frac == 0 {
1779		return itoa(whole) + "%"
1780	}
1781	if frac%10 == 0 {
1782		return itoa(whole) + "." + itoa(frac/10) + "%"
1783	}
1784	pad := ""
1785	if frac < 10 {
1786		pad = "0"
1787	}
1788	return itoa(whole) + "." + pad + itoa(frac) + "%"
1789}
1790
1791// padID encodes an id so avl's lexical order matches numeric order.
1792func padID(id int64) string {
1793	s := strconv.FormatInt(id, 10)
1794	const width = 20
1795	if len(s) >= width {
1796		return s
1797	}
1798	return zeros[:width-len(s)] + s
1799}
1800
1801const zeros = "00000000000000000000"
1802
1803// checkedAdd returns a+b and reports whether the addition did not
1804// overflow int64.
1805func checkedAdd(a, b int64) (int64, bool) {
1806	sum := a + b
1807	if (b > 0 && sum < a) || (b < 0 && sum > a) {
1808		return 0, false
1809	}
1810	return sum, true
1811}