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.

payroll.gno

32.91 Kb · 873 lines
  1// Package payroll is employer-funded recurring salary disbursement with
  2// real ugnot custody.
  3//
  4// # The one idea
  5//
  6// An allowance refills; a salary ACCRUES. That single sentence is why
  7// this realm exists beside budgets rather than inside it.
  8//
  9// budgets (this portfolio, live on onyx-1) grants a delegate a
 10// rate-limited spending authority through p/spendbook: the delegate may
 11// draw at most PerPeriod per window, and a window that passes unused is
 12// gone. Use-it-or-lose-it is exactly right for a spending authority — an
 13// unspent budget is not a debt.
 14//
 15// A salary is the dual. An employee who does not collect for three
 16// periods is owed three periods. Unclaimed pay is a LIABILITY of the
 17// employer, not an expired permission. No refilling primitive can model
 18// that, because the whole point of a refill is that the previous window
 19// is forgotten.
 20//
 21// So the arithmetic here is accrual arithmetic, and it is the one
 22// genuinely new mechanism in this realm. Everything that touches coins
 23// is delegated to primitives this portfolio has already audited and run
 24// live: p/coinio for custody and payout guards, p/feeledger for
 25// per-employee balances and the liabilities total.
 26//
 27// # Paid in arrears
 28//
 29// A period must COMPLETE before it is owed. At the instant a position
 30// starts, nothing is due; the first period becomes payable at
 31// StartHeight + PeriodBlocks. Paying in advance would mean the realm
 32// disbursing for time that has not elapsed, and there is no mechanism on
 33// chain to take it back if the position ends early.
 34//
 35// A consequence worth stating plainly: a position whose PeriodBlocks
 36// never fully elapses before it is terminated pays NOTHING. That is not
 37// a rounding loss hidden in the implementation, it is the arrears rule
 38// applied honestly, and Terminate's documentation repeats it.
 39//
 40// # Accrual is permissionless, and whole-period
 41//
 42// Gno has no autonomous execution, so "recurring" always means someone
 43// sends a transaction later. Accrue may therefore be called by ANYONE —
 44// the employee, the employer, a keeper realm, a stranger. It does only
 45// what the employer already committed to when the position was opened,
 46// it moves coins from that employer's own pool to that employee's own
 47// ledger balance, and the caller pays the gas. There is no third party
 48// who gains, so there is nothing to grief with. This is also what makes
 49// the realm composable with upkeep, the portfolio's keeper-incentive
 50// realm.
 51//
 52// Accrual settles WHOLE periods only. If the pool covers two of three
 53// due periods, two are credited and the third stays due — it is not
 54// partially paid, and no capacity is burned. The pool bounds HOW MANY
 55// periods clear, never WHETHER a period is paid in full.
 56//
 57// # Solvency, and the lesson taken from budgets
 58//
 59// budgets' package documentation records that a budget is authority and
 60// not a reservation: over-authorization is legal there, and solvency is
 61// enforced per-owner at draw time. The same choice is made here for the
 62// same reason, and the honest consequence is named rather than hidden:
 63//
 64//	OPENING A POSITION DOES NOT RESERVE ANY COINS.
 65//
 66// An employer may hire more people than the pool can ever pay. Nothing
 67// in this realm prevents it, because a future funding transaction is a
 68// perfectly ordinary way to intend to meet an obligation, and refusing
 69// the hire would only move the problem to the moment the pool dips. What
 70// the realm guarantees is narrower and checkable: a period either clears
 71// in full or stays due, and no employee's accrued ledger balance can be
 72// reduced by anyone.
 73//
 74// # The monetary invariant
 75//
 76// Two statements, because one of them is a decomposition and the other is
 77// a solvency claim, and collapsing them is how the wrong identity gets
 78// written down:
 79//
 80//	Liabilities() == TotalPooled() + AccruedTotal() + FeesAccrued()
 81//	Held()        == Liabilities() + Surplus()
 82//
 83// Liabilities() is every coin owed to anyone: the employers' unallocated
 84// pools PLUS the feeledger's own total, which is itself the accrued
 85// employee balances plus the fee pot. Surplus() is coins pushed at the
 86// realm outside every entrypoint. Both lines hold after every operation
 87// and are pinned by TestMonetaryInvariant. It is the same shape budgets
 88// carries, deliberately: a reader who has checked one can check the
 89// other.
 90//
 91// Only the first line can fail. The second is definitional — Surplus()
 92// is computed as Held()-Liabilities() — so asserting it is a tautology,
 93// and what the test actually checks is Held() >= Liabilities() beside the
 94// decomposition. A reader who takes the second line for a check has been
 95// reassured by arithmetic.
 96//
 97// This paragraph read `Held() == TotalPooled() + Liabilities() +
 98// Surplus()` until the pre-deploy audit, which double-counts the pools
 99// because Liabilities() already contains TotalPooled(). The code was
100// never wrong and payroll_test.gno encoded the right decomposition the
101// whole time; the comment was, and a comment is immutable after addpkg.
102//
103// # Termination is two steps, and that is a protection
104//
105// Terminate stops the clock. Release frees the storage. They are
106// separate because collapsing them would destroy earned-but-unaccrued
107// pay: an employer whose pool is short could remove a position owing
108// three periods and the debt would vanish with the record.
109//
110// So Terminate sets the end height to now and leaves the position in
111// place, with whatever is due still due and still accruable by anyone.
112// Release removes it, and only once nothing is due. Release is
113// permissionless and the chain refunds the freed storage deposit to the
114// caller, which is the standing incentive to tidy up someone else's
115// settled position — the same mechanism budgets uses for Reap.
116//
117// # What this realm does NOT do
118//
119//   - It does not value work, verify it happened, or arbitrate a dispute
120//     about it. A position is a standing instruction from an employer,
121//     and nothing more.
122//   - It holds ugnot and only ugnot.
123//   - It has no admin. The deploying namespace receives protocol fees
124//     and swept surplus and has no other privilege: no setter, no pause,
125//     no ability to touch a pool or a balance. Funding a pool is what
126//     makes an address an employer, exactly as in budgets.
127//
128// # STORAGE
129//
130// An employer record is paid for by that employer's first Fund; a
131// position record by the Hire that opens it. Release removes a position
132// record and the chain refunds the freed deposit to the transaction that
133// frees it.
134package payroll
135
136import (
137	"chain"
138	"chain/runtime"
139	"chain/runtime/unsafe"
140	"strconv"
141	"strings"
142
143	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
144	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
145	"gno.land/p/nt/avl/v0"
146	"gno.land/p/nt/markdown/sanitize/v0"
147)
148
149// RealmPath is this realm's own path, used to build Render links.
150const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/payroll"
151
152// Denom is the only asset this realm holds.
153const Denom = "ugnot"
154
155// MaxFeeBps is the hard ceiling on any accrual fee this realm could ever
156// charge, enforced by the feeledger the realm is constructed with — a
157// feeBps above it is refused by the primitive's own validation, not by a
158// check in this file. 500 bps = 5%.
159const MaxFeeBps = int64(500)
160
161// AccrueFeeBps is the fee charged on each accrual, on the gross amount,
162// floor-rounded in the EMPLOYEE's favour: 100 bps = 1%. It matches the
163// most conservative live fee in this portfolio (fee_split, crowdfund,
164// budgets). It is a compile-time constant and there is no setter, so a
165// position's economics cannot be changed under the employee after the
166// position is opened.
167const AccrueFeeBps = int64(100)
168
169// FeeCollector receives withdrawn fees and swept surplus. Compile-time
170// constant: there is no setter and no transfer path. It is the deploying
171// namespace's address — stated plainly: the operator of this realm.
172//
173// NOTE FOR ANY RE-NAMESPACED PORT: this is the address-as-data hazard
174// that a namespace substitution rewrites silently while every
175// byte-comparison still reports the tree faithful, because the rewrite
176// IS the intended transformation. Re-state it deliberately per chain;
177// never let it be derived.
178const FeeCollector = address("g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3")
179
180// MinFunding keeps dust pools out: 1 GNOT per Fund call.
181const MinFunding = int64(1_000_000)
182
183// MinPerPeriod is the smallest salary a position may carry. Below the
184// fee denominator a 1% fee floors to zero, so a per-period amount of a
185// few ugnot would be a fee-free position by accident rather than by
186// design; 1000 ugnot keeps the fee arithmetic meaningful.
187const MinPerPeriod = int64(1_000)
188
189// MinPeriodBlocks is the shortest period a position may use. A period of
190// a handful of blocks is a rate rather than a salary, and it makes the
191// arrears rule meaningless. Unlike budgets, a period of 0 is NOT
192// accepted here: there is no "one lifetime window" reading of a salary,
193// and dividing by it would panic.
194const MinPeriodBlocks = int64(100)
195
196// MaxRoster caps simultaneously-open positions per employer. Hire is
197// permissionless to anyone with a funded pool, so this is an
198// AVAILABILITY BOUND and it is documented rather than hidden: an
199// employer can exhaust their OWN roster and no one else's, because the
200// roster is per-employer. The cost of filling one is gas plus the
201// storage deposit on every position record, held until Release.
202const MaxRoster = 64
203
204// OpenEnded is the EndHeight value meaning "no end": the position
205// accrues until it is terminated.
206const OpenEnded = int64(0)
207
208type position struct {
209	employer address
210	employee address
211
212	// perPeriod is the gross salary per completed period. The employee
213	// receives perPeriod minus the accrual fee.
214	perPeriod int64
215
216	// periodBlocks is the period length. Periods are ANCHORED to
217	// startHeight, not sliding: period k closes at
218	// startHeight + k*periodBlocks.
219	periodBlocks int64
220
221	startHeight int64
222
223	// endHeight is OpenEnded, or the height past which no further period
224	// accrues. Terminate sets it to the current height.
225	endHeight int64
226
227	// paidPeriods counts whole periods already accrued. It only ever
228	// increases, and it is the single source of truth for what has been
229	// settled — never a coin balance, which the employee can withdraw.
230	paidPeriods int64
231}
232
233var (
234	self address
235
236	// employers holds one record per address that has ever funded a
237	// pool. Key is the address string.
238	employers = avl.NewTree() // address string -> *employer
239
240	// positions holds one record per open position, keyed by
241	// employer|employee so a roster scan is a prefix iteration and a
242	// duplicate hire is an exact-key collision.
243	positions = avl.NewTree() // "employer|employee" -> *position
244
245	// ledger holds every employee's accrued-but-unclaimed balance and
246	// the fee pot. It is GLOBAL across employers on purpose: an employee
247	// paid by two employers has one balance and one Claim.
248	ledger = feeledger.MustNew(MaxFeeBps)
249)
250
251type employer struct {
252	pool   int64
253	roster int
254}
255
256func init() {
257	self = unsafe.CurrentRealm().Address()
258}
259
260// rejectStraySend aborts when a direct EOA MsgCall attaches coins to a
261// non-payable entrypoint. This realm holds funds, so such a send would
262// become sweepable surplus, silently converting a user's coins into
263// operator funds.
264//
265// THE PREDICATE IS NARROWER THAN THE FIRST SENTENCE OF THIS COMMENT USED
266// TO BE, and the narrowing is correct rather than a gap. OriginSend()
267// reports the coins attached to the ORIGINATING TRANSACTION, not coins
268// this realm received in this call — there is no per-call msg.value in
269// Gno. MsgCall is the one caller shape where the two coincide: the
270// envelope is delivered to the realm being called. Under MsgRun the
271// envelope lands in the caller's own ephemeral /e/ realm, and under a
272// realm caller it lands at the first realm in the chain; in both, coins
273// that genuinely reach payroll do so through an explicit SendCoins that
274// OriginSend() does not describe. So widening this guard to IsUser(), or
275// dropping the conjunct entirely, would refuse calls that sent payroll
276// nothing while still not seeing the sends it was widened to catch. The
277// stdlib guidance is explicit that OriginSend pairs with IsUserCall.
278//
279// What remains uncovered is therefore not a hole in this guard: coins can
280// always arrive at an address by a bare MsgSend with no call at all, which
281// no entrypoint check can intercept. That is exactly what Surplus() and
282// SweepSurplus exist to name and reclaim.
283func rejectStraySend(cur realm) {
284	if !cur.IsCurrent() {
285		// Every call site forwards a crossing entrypoint's own live cur,
286		// so this cannot fire today; it exists so a future refactor that
287		// hands this guard a stale realm value fails closed, not open
288		// (coinio names IsCurrent as the guard secondary realm
289		// parameters require, and this helper follows that discipline).
290		panic("payroll: realm capability is not current")
291	}
292	if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
293		panic("this entrypoint does not accept coins")
294	}
295}
296
297// --- the pool ---
298
299// Fund adds the attached coins to the caller's own pool. Direct EOA
300// calls with -send only (the receipt-guaranteed shape); exactly one
301// positive ugnot coin, at least MinFunding. Anyone may fund; funding is
302// what makes an address an employer.
303func Fund(cur realm) int64 {
304	owner, amount := coinio.Receive(0, cur, Denom)
305	if amount < MinFunding {
306		panic("funding is below MinFunding")
307	}
308	key := owner.String()
309	e := getEmployer(key)
310	if e == nil {
311		e = &employer{}
312		employers.Set(key, e)
313	}
314	total, ok := checkedAdd(e.pool, amount)
315	if !ok {
316		panic("pool would overflow")
317	}
318	e.pool = total
319	chain.Emit("Funded", "employer", key, "amount", itoa(amount),
320		"pool", itoa(e.pool))
321	return e.pool
322}
323
324// WithdrawPool returns unallocated pool coins to the employer. There is
325// no lock and no notice period, and that is a DOCUMENTED asymmetry
326// rather than an oversight: see the package doc on solvency. An employer
327// may withdraw a pool while positions are open, which leaves those
328// positions accruing against an empty pool — due but unsettleable until
329// the pool is refunded. What an employer can never do is reduce a
330// balance already accrued to an employee's ledger account.
331func WithdrawPool(cur realm, amount int64) int64 {
332	rejectStraySend(cur)
333	if amount <= 0 {
334		panic("amount must be positive")
335	}
336	owner := cur.Previous().Address()
337	key := owner.String()
338	e := mustEmployer(key)
339	if amount > e.pool {
340		panic("amount exceeds pool")
341	}
342	// Debit before the payout, per coinio's ordering contract: a panic
343	// inside coinio aborts the transaction and reverts this debit.
344	e.pool -= amount
345	coinio.Payout(0, cur, owner, Denom, amount)
346	chain.Emit("PoolWithdrawn", "employer", key, "amount", itoa(amount),
347		"pool", itoa(e.pool))
348	return e.pool
349}
350
351// WithdrawPoolAll is WithdrawPool for the whole pool. It panics on an
352// empty pool rather than succeeding with a zero transfer, so a caller
353// cannot read success as evidence that coins moved.
354func WithdrawPoolAll(cur realm) int64 {
355	rejectStraySend(cur)
356	owner := cur.Previous().Address()
357	e := mustEmployer(owner.String())
358	if e.pool == 0 {
359		panic("no pool to withdraw")
360	}
361	return WithdrawPool(cur, e.pool)
362}
363
364// --- positions ---
365
366// Hire opens a position. The caller is the employer and must already
367// have a funded pool, which is this realm's only barrier to opening
368// records at someone else's expense.
369//
370// startHeight must not be in the past. A backdated start would make a
371// large number of periods due in the same transaction that opened the
372// position, draining the pool to one employee in one call — the hire
373// would look like a schedule and behave like a withdrawal. Refusing it
374// is cheaper to reason about than bounding it.
375//
376// # perPeriod is bounded below and NOT above, and the slot cost is permanent
377//
378// MinPerPeriod is a floor; there is no ceiling. A position opened with a
379// perPeriod larger than any pool that can be funded is unsettleable,
380// because Accrue's solvency check is integer division — e.pool/perPeriod
381// is 0 for every reachable pool, so Accrue panics on "cannot cover one
382// period" forever. Once that position's first period completes, duePeriods
383// is permanently non-zero, so Release refuses forever too, and the record
384// consumes one of the employer's MaxRoster slots for good.
385//
386// This is a DECLARED LIMITATION rather than a defect, and the reason is
387// that it is strictly self-harm: owner comes from cur.Previous().Address()
388// below and is never a parameter, so no caller can open an unpayable
389// position on anyone else's roster, and the pool itself stays fully
390// withdrawable through WithdrawPool. The mitigation is available and
391// narrow: Terminate then Release BEFORE the first period completes, while
392// duePeriods is still zero. After that the slot does not come back.
393func Hire(cur realm, employee address, perPeriod, periodBlocks, startHeight, endHeight int64) {
394	rejectStraySend(cur)
395
396	// The lowercase clause is load-bearing, and it is carried from budgets'
397	// Grant (measured on this toolchain, not argued): address.IsValid()
398	// defers to crypto.AddressFromBech32, whose decoder lowercases the
399	// input BEFORE verifying the checksum, so it returns true for the
400	// all-uppercase spelling of a valid address.
401	//
402	// HERE THE CONSEQUENCE IS WORSE THAN IN BUDGETS, which is why this is
403	// not boilerplate. In budgets an uppercase delegate yields a BRICKED
404	// GRANT: no coins move, the owner's pool is untouched, only a slot is
405	// wasted. In payroll, Accrue would debit the employer's pool and credit
406	// ledger["G1UPPER..."], while Claim authenticates against
407	// cur.Previous().Address(), which is always canonical. The balance would
408	// be real, owed, and unreachable by every caller — a PERMANENT LOSS of
409	// the employer's coins rather than a wasted record.
410	//
411	// One guard of this kind is all the realm needs, and the reason is
412	// structural: employee is the only caller-supplied value that ever
413	// becomes a storage key here. Every other key derives from
414	// cur.Previous().Address() or coinio.Receive's sender, both canonical by
415	// construction, and every other entrypoint LOOKS UP a position rather
416	// than creating one, so a non-canonical argument there fails closed at
417	// mustPosition. The check is complete as well as necessary: that decoder
418	// rejects mixed case outright, so a valid address has exactly two
419	// spellings and refusing every non-lower one leaves precisely the
420	// canonical one.
421	if !employee.IsValid() || string(employee) != strings.ToLower(string(employee)) {
422		panic("employee is not a valid address in canonical (lowercase) form")
423	}
424	if perPeriod < MinPerPeriod {
425		panic("perPeriod is below MinPerPeriod")
426	}
427	if periodBlocks < MinPeriodBlocks {
428		panic("periodBlocks is below MinPeriodBlocks")
429	}
430	now := runtime.ChainHeight()
431	if startHeight < now {
432		panic("startHeight is in the past")
433	}
434	if endHeight != OpenEnded {
435		if endHeight <= startHeight {
436			panic("endHeight must be after startHeight, or OpenEnded")
437		}
438		if endHeight-startHeight < periodBlocks {
439			panic("position ends before its first period completes")
440		}
441	}
442
443	owner := cur.Previous().Address()
444	ownerKey := owner.String()
445	e := mustEmployer(ownerKey)
446	if e.pool == 0 {
447		panic("fund a pool before hiring")
448	}
449	if e.roster >= MaxRoster {
450		panic("employer roster is full")
451	}
452	k := posKey(owner, employee)
453	if positions.Get(k) != nil {
454		panic("this employee already has an open position with this employer")
455	}
456
457	positions.Set(k, &position{
458		employer:     owner,
459		employee:     employee,
460		perPeriod:    perPeriod,
461		periodBlocks: periodBlocks,
462		startHeight:  startHeight,
463		endHeight:    endHeight,
464		paidPeriods:  0,
465	})
466	e.roster++
467	chain.Emit("Hired", "employer", ownerKey, "employee", employee.String(),
468		"perPeriod", itoa(perPeriod), "periodBlocks", itoa(periodBlocks),
469		"startHeight", itoa(startHeight), "endHeight", itoa(endHeight))
470}
471
472// Accrue settles every whole period that has completed and not yet been
473// paid, as far as the employer's pool reaches, and credits the gross
474// minus the accrual fee to the employee's ledger balance.
475//
476// ANYONE may call this — see the package doc. It returns the gross
477// amount settled.
478func Accrue(cur realm, employer, employee address) int64 {
479	rejectStraySend(cur)
480	p := mustPosition(employer, employee)
481	e := mustEmployer(employer.String())
482
483	now := runtime.ChainHeight()
484	due := duePeriods(p, now)
485	if due == 0 {
486		panic("no periods are due")
487	}
488
489	// Whole periods the pool can cover. This is the solvency check and
490	// it runs BEFORE any state moves, so a pool that cannot cover even
491	// one period leaves paidPeriods untouched and nothing is lost.
492	affordable := e.pool / p.perPeriod
493	n := due
494	if affordable < n {
495		n = affordable
496	}
497	if n == 0 {
498		panic("employer's pool cannot cover one period")
499	}
500
501	gross := n * p.perPeriod // bounded by e.pool, so it cannot overflow
502	e.pool -= gross
503	p.paidPeriods += n
504	credited, fee := ledger.MustDeposit(employee.String(), gross, AccrueFeeBps)
505
506	chain.Emit("Accrued", "employer", employer.String(),
507		"employee", employee.String(), "periods", itoa(n),
508		"gross", itoa(gross), "credited", itoa(credited), "fee", itoa(fee),
509		"paidPeriods", itoa(p.paidPeriods), "pool", itoa(e.pool))
510	return gross
511}
512
513// Terminate stops the clock on a position. The employer only.
514//
515// It does NOT remove the record and does not settle anything: whatever
516// was due stays due, and anyone may still Accrue it. That separation is
517// the employee's protection — see the package doc. Use Release to remove
518// a settled position.
519//
520// If the position has not yet reached its first complete period, this
521// pays nothing, by the arrears rule.
522func Terminate(cur realm, employee address) {
523	rejectStraySend(cur)
524	owner := cur.Previous().Address()
525	p := mustPosition(owner, employee)
526	now := runtime.ChainHeight()
527	if p.endHeight != OpenEnded && p.endHeight <= now {
528		panic("position has already ended")
529	}
530	p.endHeight = now
531	chain.Emit("Terminated", "employer", owner.String(),
532		"employee", employee.String(), "endHeight", itoa(now),
533		"paidPeriods", itoa(p.paidPeriods), "due", itoa(duePeriods(p, now)))
534}
535
536// Release removes an ended, fully-settled position and frees its
537// storage. Permissionless: the chain refunds the freed storage deposit
538// to the caller, which is the standing incentive to tidy up a settled
539// position that is not your own.
540//
541// It refuses while anything is due, which is what makes it safe to let a
542// stranger call: a Release cannot destroy an unpaid obligation.
543func Release(cur realm, employer, employee address) {
544	rejectStraySend(cur)
545	p := mustPosition(employer, employee)
546	now := runtime.ChainHeight()
547	if p.endHeight == OpenEnded || p.endHeight > now {
548		panic("position has not ended")
549	}
550	if duePeriods(p, now) != 0 {
551		panic("position has unsettled periods")
552	}
553	positions.Remove(posKey(employer, employee))
554	if e := getEmployer(employer.String()); e != nil && e.roster > 0 {
555		e.roster--
556	}
557	chain.Emit("Released", "employer", employer.String(),
558		"employee", employee.String(), "paidPeriods", itoa(p.paidPeriods))
559}
560
561// --- claiming ---
562
563// Claim pays the caller their whole accrued balance. The employee only —
564// there is no "claim on behalf of", because that would let a third party
565// choose the moment someone else's coins move.
566func Claim(cur realm) int64 {
567	rejectStraySend(cur)
568	who := cur.Previous().Address()
569	key := who.String()
570	amount := ledger.BalanceOf(key)
571	if amount == 0 {
572		panic("no balance to claim")
573	}
574	// Debit first, per coinio's ordering contract.
575	ledger.MustWithdraw(key, amount)
576	coinio.Payout(0, cur, who, Denom, amount)
577	chain.Emit("Claimed", "account", key, "amount", itoa(amount))
578	return amount
579}
580
581// WithdrawFees moves the accrued fee pot to FeeCollector. Anyone may
582// call it; the destination is a compile-time constant, so the caller
583// cannot influence where the coins go.
584func WithdrawFees(cur realm) int64 {
585	rejectStraySend(cur)
586	fees := ledger.WithdrawFees()
587	if fees == 0 {
588		panic("no fees accrued")
589	}
590	coinio.Payout(0, cur, FeeCollector, Denom, fees)
591	chain.Emit("FeesWithdrawn", "to", FeeCollector.String(), "amount", itoa(fees))
592	return fees
593}
594
595// SweepSurplus sends coins held beyond every obligation to FeeCollector.
596// The reserve passed to coinio is the realm's full liability — pooled
597// coins plus ledger liabilities — so a sweep can never reach a coin that
598// an employer or an employee is owed.
599func SweepSurplus(cur realm) int64 {
600	rejectStraySend(cur)
601	swept := coinio.Sweep(0, cur, FeeCollector, Denom, Liabilities())
602	chain.Emit("Swept", "denom", Denom, "to", FeeCollector.String(),
603		"amount", itoa(swept))
604	return swept
605}
606
607// --- accrual arithmetic, the one new mechanism ---
608
609// duePeriods is the number of whole periods that have completed and not
610// been paid. It is a pure function of the position and the clock, which
611// is what makes it unit-testable without a chain.
612//
613// Periods are ANCHORED to startHeight: period k closes at
614// startHeight + k*periodBlocks. Nothing is due at startHeight itself —
615// salary is paid in arrears.
616func duePeriods(p *position, now int64) int64 {
617	horizon := now
618	if p.endHeight != OpenEnded && p.endHeight < horizon {
619		horizon = p.endHeight
620	}
621	if horizon <= p.startHeight {
622		return 0
623	}
624	completed := (horizon - p.startHeight) / p.periodBlocks
625	if completed <= p.paidPeriods {
626		return 0
627	}
628	return completed - p.paidPeriods
629}
630
631// --- read-only accessors ---
632
633// DuePeriods is duePeriods for a live position, at the current height.
634func DuePeriods(employer, employee address) int64 {
635	return duePeriods(mustPosition(employer, employee), runtime.ChainHeight())
636}
637
638// DueAmount is the gross that DuePeriods would settle if the pool were
639// unbounded. Read it beside AccruableAmount: the difference between them
640// is exactly the employer's shortfall.
641//
642// It is the one arithmetic here with NO pool bound behind it, so it is the
643// one place an int64 can wrap: perPeriod has no ceiling and due grows with
644// the chain. Accrue cannot wrap, because its product is bounded by the
645// pool it is about to debit (see the comment there). An unpayable position
646// is a legal state in this realm, so the overflow is reachable in
647// principle, and this panics rather than returning a negative salary —
648// a view that refuses is recoverable, a view that lies is not.
649func DueAmount(employer, employee address) int64 {
650	p := mustPosition(employer, employee)
651	gross, ok := checkedMul(duePeriods(p, runtime.ChainHeight()), p.perPeriod)
652	if !ok {
653		panic("due amount would overflow; read DuePeriods and PerPeriodOf instead")
654	}
655	return gross
656}
657
658// AccruableAmount is the gross an Accrue would settle right now, pool
659// bound included. It is the honest number, and it can be less than
660// DueAmount without anything being wrong — see the package doc on
661// solvency.
662func AccruableAmount(employer, employee address) int64 {
663	p := mustPosition(employer, employee)
664	e := mustEmployer(employer.String())
665	due := duePeriods(p, runtime.ChainHeight())
666	affordable := e.pool / p.perPeriod
667	if affordable < due {
668		due = affordable
669	}
670	return due * p.perPeriod
671}
672
673// PoolOf is an employer's unallocated pool. Zero for an address that has
674// never funded one.
675func PoolOf(employer address) int64 {
676	if e := getEmployer(employer.String()); e != nil {
677		return e.pool
678	}
679	return 0
680}
681
682// RosterOf is the number of open positions an employer holds.
683func RosterOf(employer address) int {
684	if e := getEmployer(employer.String()); e != nil {
685		return e.roster
686	}
687	return 0
688}
689
690// BalanceOf is an employee's accrued, unclaimed balance.
691func BalanceOf(employee address) int64 { return ledger.BalanceOf(employee.String()) }
692
693// PerPeriodOf is a position's gross salary per period.
694func PerPeriodOf(employer, employee address) int64 {
695	return mustPosition(employer, employee).perPeriod
696}
697
698// PaidPeriodsOf is how many whole periods a position has settled.
699func PaidPeriodsOf(employer, employee address) int64 {
700	return mustPosition(employer, employee).paidPeriods
701}
702
703// EndHeightOf is a position's end height, or OpenEnded.
704func EndHeightOf(employer, employee address) int64 {
705	return mustPosition(employer, employee).endHeight
706}
707
708// HasPosition reports whether a position is open, without panicking on
709// absence — every other position accessor panics, which makes them
710// unusable as an existence probe.
711func HasPosition(employer, employee address) bool {
712	return positions.Get(posKey(employer, employee)) != nil
713}
714
715// OpenPositions is the number of open positions across every employer.
716func OpenPositions() int { return positions.Size() }
717
718// Employers is the number of addresses that have ever funded a pool.
719// Note the tense: an employer record survives a pool going to zero, so
720// this is not a count of currently-funded employers.
721func Employers() int { return employers.Size() }
722
723// TotalPooled is the sum of every employer's unallocated pool.
724func TotalPooled() int64 {
725	var sum int64
726	employers.Iterate("", "", func(_ string, v any) bool {
727		sum += v.(*employer).pool
728		return false
729	})
730	return sum
731}
732
733// AccruedTotal is the sum of every employee's unclaimed balance.
734func AccruedTotal() int64 { return ledger.UsersTotal() }
735
736// FeesAccrued is the fee pot awaiting WithdrawFees.
737func FeesAccrued() int64 { return ledger.FeesAccrued() }
738
739// Liabilities is every coin this realm owes to anyone: pooled coins plus
740// the ledger's own total (employee balances plus the fee pot).
741func Liabilities() int64 {
742	total, ok := checkedAdd(TotalPooled(), ledger.Liabilities())
743	if !ok {
744		panic("liabilities would overflow")
745	}
746	return total
747}
748
749// Held is the realm's actual ugnot balance, read from the chain.
750func Held() int64 { return coinio.HeldAt(self, Denom) }
751
752// Surplus is coins held beyond every obligation — what SweepSurplus
753// would move. It is zero when every coin arrived through an entrypoint.
754func Surplus() int64 {
755	h, l := Held(), Liabilities()
756	if h <= l {
757		return 0
758	}
759	return h - l
760}
761
762// Address is this realm's own address.
763func Address() address { return self }
764
765// Height is the current chain height, exposed so a caller can compute
766// period boundaries against the same clock this realm uses.
767func Height() int64 { return runtime.ChainHeight() }
768
769// --- internals ---
770
771func posKey(employer, employee address) string {
772	return employer.String() + "|" + employee.String()
773}
774
775func getEmployer(key string) *employer {
776	v := employers.Get(key)
777	if v == nil {
778		return nil
779	}
780	return v.(*employer)
781}
782
783func mustEmployer(key string) *employer {
784	e := getEmployer(key)
785	if e == nil {
786		panic("unknown employer")
787	}
788	return e
789}
790
791func mustPosition(employer, employee address) *position {
792	v := positions.Get(posKey(employer, employee))
793	if v == nil {
794		panic("unknown position")
795	}
796	return v.(*position)
797}
798
799func checkedAdd(a, b int64) (int64, bool) {
800	c := a + b
801	if (b > 0 && c < a) || (b < 0 && c > a) {
802		return 0, false
803	}
804	return c, true
805}
806
807// checkedMul reports a*b and whether it is exact. Both operands here are
808// non-negative (duePeriods never returns a negative, perPeriod is validated
809// at or above MinPerPeriod), so the division test is sufficient and the
810// sign cases a general version would need are deliberately absent.
811func checkedMul(a, b int64) (int64, bool) {
812	if a == 0 || b == 0 {
813		return 0, true
814	}
815	c := a * b
816	if c/b != a {
817		return 0, false
818	}
819	return c, true
820}
821
822func itoa(n int64) string { return strconv.FormatInt(n, 10) }
823
824// --- render ---
825
826func Render(path string) string {
827	var b strings.Builder
828	b.WriteString("# payroll\n\n")
829	b.WriteString("Employer-funded recurring salary disbursement. ")
830	b.WriteString("An allowance refills; a salary accrues.\n\n")
831
832	if path == "" {
833		b.WriteString("## Totals\n\n")
834		b.WriteString("| field | value |\n|---|---|\n")
835		b.WriteString("| employers | " + itoa(int64(Employers())) + " |\n")
836		b.WriteString("| open positions | " + itoa(int64(OpenPositions())) + " |\n")
837		b.WriteString("| pooled | " + itoa(TotalPooled()) + " " + Denom + " |\n")
838		b.WriteString("| accrued to employees | " + itoa(AccruedTotal()) + " " + Denom + " |\n")
839		b.WriteString("| fees accrued | " + itoa(FeesAccrued()) + " " + Denom + " |\n")
840		b.WriteString("| held | " + itoa(Held()) + " " + Denom + " |\n")
841		b.WriteString("| surplus | " + itoa(Surplus()) + " " + Denom + " |\n\n")
842		b.WriteString("Salary is paid in arrears: a period must complete ")
843		b.WriteString("before it is owed. Accrue is permissionless.\n\n")
844		b.WriteString("Append an employer address to this path to see ")
845		b.WriteString("their roster.\n")
846		return b.String()
847	}
848
849	who := sanitize.InlineText(path)
850	b.WriteString("## Roster of " + who + "\n\n")
851	e := getEmployer(path)
852	if e == nil {
853		b.WriteString("No employer record at that address.\n")
854		return b.String()
855	}
856	b.WriteString("Pool: " + itoa(e.pool) + " " + Denom + ", ")
857	b.WriteString("open positions: " + itoa(int64(e.roster)) + "\n\n")
858
859	now := runtime.ChainHeight()
860	b.WriteString("| employee | per period | period | paid | due |\n")
861	b.WriteString("|---|---|---|---|---|\n")
862	prefix := path + "|"
863	positions.Iterate(prefix, prefix+"\xff", func(_ string, v any) bool {
864		p := v.(*position)
865		b.WriteString("| " + sanitize.InlineText(p.employee.String()))
866		b.WriteString(" | " + itoa(p.perPeriod))
867		b.WriteString(" | " + itoa(p.periodBlocks))
868		b.WriteString(" | " + itoa(p.paidPeriods))
869		b.WriteString(" | " + itoa(duePeriods(p, now)) + " |\n")
870		return false
871	})
872	return b.String()
873}