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}