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}