assets.gno
38.64 Kb · 989 lines
1// Package assets is a per-owner fixed-asset register whose ledger postings
2// are DERIVED from state rather than asserted by a caller.
3//
4// # What this realm is for
5//
6// A general ledger takes a debit code, a credit code and an amount from
7// whoever is posting. That is the right design for a ledger and it means
8// gno.land/r/.../books structurally CANNOT know that an entry was a
9// depreciation charge, or that the charge for that period had already been
10// made. Nothing stops an owner from booking the same month twice, or from
11// skipping one, and nothing in the book can tell afterwards.
12//
13// This realm closes that gap for one class of entry. It holds each asset's
14// depreciation schedule as state, computes the charge from the schedule and
15// the chain height, and keeps a per-asset HIGH-WATER MARK of the periods
16// already booked. The amount is never a parameter. A charge therefore cannot
17// be double-booked (the mark does not move backwards) and cannot be skipped
18// (the next call books the whole span, because charges telescope exactly).
19//
20// The resulting claim is narrow and checkable by anyone, which is the point:
21//
22// bookedAmount(asset) == asset.schedule.AccumulatedAt(bookedThrough(asset))
23//
24// and, across a whole register,
25//
26// sum of bookedAmount over assets == the credit balance of 1590
27//
28// Both are exported as [Invariant] and [CrossFoot] and both are FREE reads,
29// deliberately — see "Why Rederive is not owner-only" below.
30//
31// # The chart is two accounts and is fixed
32//
33// 5500 Depreciation expense (expense, debit-normal)
34// 1590 Accumulated depreciation (asset, CONTRA — see below)
35//
36// Every posting is debit 5500 / credit 1590 for an equal amount, so the
37// accounting equation offsets at 0 = 0 on an empty register and after every
38// charge. There is no OpenAccount entrypoint and the underlying book is
39// created with a hard cap of two accounts, so the chart cannot grow.
40//
41// The COST BASIS never reaches the book. Booking an asset's cost would force
42// a credit leg, and the only honest candidates are a liability
43// ("2000 Acquisition payable") or an equity plug ("3000 Contributed
44// capital"). Both assert how the asset was funded, which this realm does not
45// know and must not invent. The cost stays in register state, where it is
46// visible as data rather than as an accounting claim.
47//
48// 1590 is kind ASSET, so journal treats it as debit-normal and Balance()
49// returns Debits - Credits — which reads NEGATIVE for accumulated
50// depreciation. That is arithmetically correct for a contra-asset and is
51// confusing on first reading, so both figures are exported and each is named
52// for what it is: [AccumulatedCredit] is the positive contra balance.
53//
54// # Periods are measured in BLOCKS
55//
56// A period is periodBlocks chain heights long, anchored to the height the
57// asset was placed in service. Period k closes at placedAt + k*periodBlocks,
58// and a partial period in progress is not counted and is not lost.
59//
60// Block height, not wall-clock time, because that is this portfolio's
61// convention: payroll, budgets, reimbursements and subscriptions all carry a
62// periodBlocks field, and height is monotone by construction where block time
63// is validator-reported. p/depreciation's Daily/Monthly/Yearly constants are
64// SECONDS and are therefore not used here; its arithmetic is unit-agnostic
65// (Periods is an integer division), so this is a mismatch in that package's
66// documentation rather than a divergence from its behaviour. It is recorded
67// in DISCOVERY.md § 6 rather than silently worked around.
68//
69// # Retirement books first, then freezes
70//
71// [Retire] performs the final booking for the periods completed up to the
72// retirement height and only then stops the asset. The two are one
73// transaction on purpose: retiring without booking would leave depreciation
74// that was genuinely incurred permanently unbooked, and booking after
75// retirement would need an exception to the "retired assets do not accrue"
76// rule. Historical charges always stand — the depreciation really happened.
77//
78// Disposal gain or loss is OUT OF SCOPE for v1, and the reason is structural:
79// it needs the cost-basis account this realm deliberately does not open, plus
80// a proceeds figure the realm cannot observe because it holds no funds.
81//
82// # What this realm does NOT do
83//
84// - No custody. It holds no coins, has no withdrawal path, and refuses a
85// user call that arrives carrying any. An asset's cost is a number an
86// owner typed.
87// - No period close. p/journal can seal a book up to a HEIGHT, refusing
88// entries dated at or before it. This realm's bookedThrough is a PERIOD
89// INDEX — a different axis wearing the same English word — and sealing a
90// derived book could only make the derivation unpostable. The capability
91// exists in the engine and is deliberately not exposed.
92// - No bulk booking. p/depreciation's MaxLife doc states the loop bound
93// (1199 iterations per CarryingAt, 2398 per Charge, and a degenerate
94// declining schedule that runs the full bound on every call forever) and
95// explicitly refuses to price it. A loop over N assets multiplies an
96// unpriced bound by N, so one asset is booked per call.
97// - No truth. A register is a set of claims by its owner. With no custody
98// there is nothing tying "cost 1000000" to a real holding. The invariants
99// here prove that the postings follow from the stored schedules; they
100// prove nothing about the world.
101//
102// # Why Rederive and the invariants are free reads
103//
104// r/books exposes Rederive as an owner-only crossing function, to contain the
105// cost of a full re-walk. This realm diverges, deliberately: the derivation
106// IS the product, and a claim only its owner can check is not a claim worth
107// making. [Rederive], [Invariant] and [CrossFoot] are all free reads, so any
108// third party can verify a register from vm/qeval without the owner's key.
109//
110// The bound above is what makes that affordable, and a bound is not a price.
111// Measured under the pinned onyx toolchain at the register cap — 64 assets on
112// MaxLife declining schedules, the worst case this realm can hold:
113//
114// Render per call: 106,421,272 gas
115// Rederive per call: 9,357,860,004 gas
116//
117// So the design choice is stated with its cost rather than only its reason.
118// Render is an ordinary rich-page figure; Rederive at cap is two orders of
119// magnitude above it, falls on the queried node rather than the caller, and
120// is reachable with no key and no fee. Whether a given RPC's query gas
121// ceiling admits it AT THE CAP is a property of that node and is not measured
122// here — if it refuses, the third-party-verification claim above fails
123// exactly at the bound it was designed around. [Invariant] is per-asset and
124// cheap; [CrossFoot] walks the assets only, not the postings.
125//
126// # Deploy order
127//
128// This realm imports p/journal and p/depreciation. Neither is deployed yet,
129// so the order is p/journal, p/depreciation, then this realm.
130package assets
131
132import (
133 "chain/runtime"
134 "chain/runtime/unsafe"
135 "strconv"
136 "strings"
137
138 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation"
139 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal"
140 "gno.land/p/nt/avl/v0"
141 "gno.land/p/nt/markdown/sanitize/v0"
142)
143
144// RealmPath is this realm's own path, used to build Render links.
145const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/assets"
146
147// The fixed chart. The codes follow r/books' convention, where an account's
148// LEADING DIGIT fixes its kind: 1 asset, 2 liability, 3 equity, 4 revenue,
149// 5 expense. A six-series code would be an invalid account under that
150// convention, which is why the expense leg is 5500 and not the 6500 a
151// textbook chart would use.
152const (
153 ExpenseCode = "5500"
154 ExpenseName = "Depreciation expense"
155
156 AccumCode = "1590"
157 AccumName = "Accumulated depreciation"
158)
159
160// MaxAssetsPerRegister caps one owner's register. CrossFoot and Render both
161// walk every asset, and this bound is what keeps those reads cheap for
162// everyone.
163const MaxAssetsPerRegister = 64
164
165// MaxEntriesPerRegister caps one owner's journal, far below journal's
166// MaxEntriesLimit of 1000000 because RederiveFromJournal is linear in the
167// posting count and is a free read here.
168//
169// The bound is reachable and the arithmetic is worth stating: an asset posts
170// at most one entry per completed period, so MaxAssetsPerRegister assets with
171// MaxLife-period schedules could want 64*1200 = 76800 entries. A register
172// that fills refuses further charges rather than dropping history. There is
173// deliberately no Archive — it would destroy the record the invariants are
174// checked against — so the remedy is a second register under a second
175// address, which is a real limitation and not a hidden one.
176const MaxEntriesPerRegister = 8192
177
178// MaxLabelLen bounds an asset's human label. Every label is a candidate for
179// Render output.
180const MaxLabelLen = 64
181
182// MinPeriodBlocks is the shortest period an asset may use. Matches the floor
183// payroll, budgets and reimbursements already use. A period of 0 is not a
184// special case here — it is simply invalid, because an asset with no period
185// has no schedule to derive anything from.
186const MinPeriodBlocks = int64(100)
187
188// RenderAssetLimit caps how many assets one Render call tabulates.
189const RenderAssetLimit = 25
190
191// register is one owner's asset register and the book its charges post to.
192//
193// The book is a POINTER and is never returned by any exported function —
194// p/journal consumer obligation 4: a *Book is a mutable handle, and handing
195// one across a realm boundary would hand out the right to post.
196type register struct {
197 book *journal.Book
198 assets *avl.Tree // decimal id -> *asset
199 nextID uint64
200 openedAt int64
201}
202
203// asset is one depreciable item.
204type asset struct {
205 // id is carried in the value as well as the tree key, because the key
206 // is zero-padded for ordering and parsing it back would be a second
207 // encoding to keep in step with the first.
208 id uint64
209 label string
210 sched depreciation.Schedule
211
212 // placedAt is the chain height period 0 begins at, and period is the
213 // period length in heights. Both are fixed at placement: a schedule
214 // whose clock could be edited afterwards would make every historical
215 // charge unexplainable from current state.
216 placedAt int64
217 period int64
218
219 // booked is the high-water mark — whole periods already charged. It
220 // only ever increases, and that is the whole of the idempotence
221 // argument.
222 booked int32
223
224 // amount is the cumulative expense posted for this asset. The
225 // per-asset invariant is amount == sched.AccumulatedAt(booked).
226 amount int64
227
228 retired bool
229 retiredAt int64
230}
231
232// regs maps an owner's address string to that owner's *register.
233var regs = avl.NewTree()
234
235// regCount is maintained alongside the tree so the index page never has to
236// iterate it.
237var regCount int
238
239// ---------------------------------------------------------------------------
240// Guards
241// ---------------------------------------------------------------------------
242
243// refuseCoins panics if a user call arrives carrying coins.
244//
245// This realm has no withdrawal path, so accepted coins would be stranded
246// permanently. The guard is on IsUserCall rather than IsUser deliberately:
247// OriginSend is only meaningful for a direct MsgCall, and the MsgRun
248// ephemeral can consume the envelope before forwarding control, so IsUser
249// would be the wrong predicate.
250//
251// The IsCurrent() check is a separate concern and is not about coins: this is
252// a non-crossing helper reading identity off a realm PARAMETER, which is the
253// designation-forgery shape (security.md Class 2) whether or not a caller
254// exercising it exists today. It is a no-op at every call site here — each is
255// a crossing entrypoint forwarding its OWN first cur, runtime-current by
256// construction — and is kept because this realm is IMMUTABLE after addpkg, so
257// a call site added later cannot be made safe later. p/coinio guards the same
258// shape in Receive, Payout and Sweep; this is that house precedent applied.
259//
260// No count of the call sites is written down, per CLAUDE.md § 4a.
261func refuseCoins(cur realm) {
262 if !cur.IsCurrent() {
263 panic("assets: refuseCoins called with a realm that is not current")
264 }
265 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
266 panic("assets: this realm holds no coins and has no withdrawal path; send none")
267 }
268}
269
270// mustOwnRegister returns the CALLER'S register, and there is no variant that
271// returns anyone else's. Every mutating entrypoint goes through this, so
272// ownership is implicit in the call.
273//
274// "Ownership is never a parameter" is what this comment used to conclude, and
275// it is true of the SIGNATURE and was never true of the realm VALUE: the owner
276// is derived from cur, so an uncurrent cur IS an ownership parameter. That is
277// the same Class 2 shape refuseCoins carries above and the more consequential
278// half of it, because this is the function that decides who owns the register
279// rather than one that only refuses coins. The guard is a no-op today for the
280// same reason, kept for the same reason, and its call sites are likewise not
281// counted here. See AUDIT.md.
282func mustOwnRegister(cur realm) *register {
283 if !cur.IsCurrent() {
284 panic("assets: mustOwnRegister called with a realm that is not current")
285 }
286 owner := cur.Previous().Address()
287 r, ok := reg(owner)
288 if !ok {
289 panic("assets: no register for " + owner.String() + "; call OpenRegister first")
290 }
291 return r
292}
293
294// reg looks an owner's register up. avl's Get returns a bare `any` and
295// signals absence with nil, so the nil test IS the lookup result and not a
296// defensive extra — every value this realm stores is a non-nil pointer.
297func reg(owner address) (*register, bool) {
298 v := regs.Get(owner.String())
299 if v == nil {
300 return nil, false
301 }
302 return v.(*register), true
303}
304
305func mustReg(owner address) *register {
306 r, ok := reg(owner)
307 if !ok {
308 panic("assets: no register for " + owner.String())
309 }
310 return r
311}
312
313func mustAsset(r *register, id uint64) *asset {
314 v := r.assets.Get(idKey(id))
315 if v == nil {
316 panic("assets: no asset " + strconv.FormatUint(id, 10))
317 }
318 return v.(*asset)
319}
320
321// idKey encodes an id so that avl's lexical key order matches numeric id
322// order — the same encoding p/journal uses for entry ids, and for the same
323// reason: without it, Render would list asset 10 before asset 2. 20 digits
324// covers math.MaxUint64.
325func idKey(id uint64) string {
326 s := strconv.FormatUint(id, 10)
327 const width = 20
328 if len(s) >= width {
329 return s
330 }
331 return zeros[:width-len(s)] + s
332}
333
334const zeros = "00000000000000000000"
335
336func checkLabel(label string) {
337 if strings.TrimSpace(label) == "" {
338 panic("assets: label must not be blank")
339 }
340 if len(label) > MaxLabelLen {
341 panic("assets: label too long, max " + strconv.Itoa(MaxLabelLen))
342 }
343}
344
345func checkPeriod(periodBlocks int64) {
346 if periodBlocks < MinPeriodBlocks {
347 panic("assets: periodBlocks must be at least " +
348 strconv.FormatInt(MinPeriodBlocks, 10) + " blocks")
349 }
350}
351
352// ---------------------------------------------------------------------------
353// Writes
354// ---------------------------------------------------------------------------
355
356// OpenRegister creates the caller's register and opens its two accounts. It
357// panics if the caller already has one.
358//
359// The chart is opened HERE rather than on demand, and the book is created
360// with a cap of exactly two accounts. Both facts are load-bearing: they are
361// why this realm is its book's only writer and why the CrossFoot identity is
362// exact rather than approximate.
363func OpenRegister(cur realm) {
364 refuseCoins(cur)
365 owner := cur.Previous().Address()
366 key := owner.String()
367 if regs.Has(key) {
368 panic("assets: this address already has a register")
369 }
370
371 b := journal.MustNew(2, MaxEntriesPerRegister)
372 b.MustOpen(ExpenseCode, ExpenseName, journal.Expense)
373 b.MustOpen(AccumCode, AccumName, journal.Asset)
374
375 regs.Set(key, ®ister{
376 book: b,
377 assets: avl.NewTree(),
378 nextID: 1,
379 openedAt: runtime.ChainHeight(),
380 })
381 regCount++
382}
383
384// Place records a straight-line asset placed in service at the current
385// height, and returns its id.
386//
387// Nothing is posted: at placement zero periods have completed, so the charge
388// is zero and a zero posting is not a valid journal entry. The first charge
389// becomes bookable at placedAt + periodBlocks.
390func Place(cur realm, label string, cost, salvage int64, life int32, periodBlocks int64) uint64 {
391 refuseCoins(cur)
392 r := mustOwnRegister(cur)
393 checkLabel(label)
394 checkPeriod(periodBlocks)
395
396 s, err := depreciation.New(cost, salvage, life)
397 if err != nil {
398 panic(err)
399 }
400 return place(r, label, s, periodBlocks)
401}
402
403// PlaceDeclining records a declining-balance asset charging num/den of the
404// remaining carrying amount each period. Double-declining over a 10-period
405// life is num=2, den=10.
406//
407// Two properties of that method are inherited from p/depreciation and are
408// visible in this realm's numbers, so they are repeated here. The final
409// period absorbs whatever remains above salvage, so the last charge can be
410// much larger than the one before it. And a schedule whose per-period charge
411// truncates to zero (cost*num < den) never depreciates until that final
412// period — [Due] reads 0 for its whole life, which is the schedule behaving
413// as specified and not a stuck register.
414func PlaceDeclining(cur realm, label string, cost, salvage int64, life int32, num, den, periodBlocks int64) uint64 {
415 refuseCoins(cur)
416 r := mustOwnRegister(cur)
417 checkLabel(label)
418 checkPeriod(periodBlocks)
419
420 s, err := depreciation.NewDeclining(cost, salvage, life, num, den)
421 if err != nil {
422 panic(err)
423 }
424 return place(r, label, s, periodBlocks)
425}
426
427func place(r *register, label string, s depreciation.Schedule, periodBlocks int64) uint64 {
428 if r.assets.Size() >= MaxAssetsPerRegister {
429 panic("assets: register is full, max " + strconv.Itoa(MaxAssetsPerRegister))
430 }
431 at := runtime.ChainHeight()
432
433 // Establish the end-of-life arithmetic NOW so FullyDepreciatedAt is a
434 // total read for the asset's whole life. At chain heights it cannot
435 // overflow, and that is exactly why checking is cheap and omitting it
436 // would be a guess.
437 if _, err := s.FullyDepreciatedAt(at, periodBlocks); err != nil {
438 panic(err)
439 }
440
441 id := r.nextID
442 r.nextID++
443 r.assets.Set(idKey(id), &asset{
444 id: id,
445 label: label,
446 sched: s,
447 placedAt: at,
448 period: periodBlocks,
449 })
450 return id
451}
452
453// BookThrough posts the depreciation charge for every period that has
454// completed since this asset was last booked, and returns the period the
455// asset is now booked through.
456//
457// It is IDEMPOTENT: calling it twice in one block, or twice in one period,
458// posts once. Calling it after skipping ten periods posts the whole span as
459// one entry, for the same total a caller who ran it every period would have
460// reached — p/depreciation's Charge telescopes exactly, so booking cadence
461// cannot change the total booked.
462//
463// A ZERO charge advances the mark and posts nothing. That is not a shortcut:
464// journal rejects a posting of zero (ErrInvalidAmount), while a degenerate
465// declining schedule legitimately charges zero, and the invariant is
466// preserved exactly because AccumulatedAt is equal at both marks in precisely
467// that case.
468//
469// Panics if the asset is retired, or if the book is full.
470func BookThrough(cur realm, id uint64) int32 {
471 refuseCoins(cur)
472 r := mustOwnRegister(cur)
473 a := mustAsset(r, id)
474 if a.retired {
475 panic("assets: asset " + strconv.FormatUint(id, 10) +
476 " is retired and accrues no further charges")
477 }
478 n := elapsed(a)
479 if n <= a.booked {
480 return a.booked
481 }
482 bookSpan(r, a, n)
483 return a.booked
484}
485
486// Retire books the asset through the current period and then stops it
487// permanently. It returns the final amount posted, which may be zero.
488//
489// The booking is not optional and not separable: see the package doc. After
490// this call the asset accrues nothing, BookThrough on it panics, and every
491// historical charge stands.
492//
493// An asset already at or past the end of its life still retires cleanly —
494// CarryingAt clamps at Salvage from Life onwards, so the final charge is
495// whatever had not yet been booked and is zero if nothing had.
496//
497// IT PANICS IF THE BOOK IS FULL AND A CHARGE IS DUE, AND THAT REFUSAL IS
498// PERMANENT. The booking is not separable, journal's Post is fail-closed
499// before it writes, and a panic reverts the whole transaction — so an asset
500// with an unbooked charge on a register that has reached
501// MaxEntriesPerRegister can never be retired. BookThrough's identical panic
502// is harmless (the charge simply stays unbooked and can be booked later if
503// the book ever has room, which it never will, there being no archive); this
504// one strands the asset permanently in the live state.
505//
506// It is reachable, and the arithmetic is the same one MaxEntriesPerRegister
507// carries: about seven MaxLife assets booked period-by-period fill the book.
508// The remedy is the same one the entry cap already has — a second register
509// under a second address — and it has to be arranged BEFORE the book fills,
510// which is why Capacity is exported and why the register page prints the
511// entry headroom. Making Retire freeze without booking would be the obvious
512// alternative and is refused: it would forfeit a charge that was genuinely
513// incurred, which is the one thing this realm exists not to do.
514func Retire(cur realm, id uint64) int64 {
515 refuseCoins(cur)
516 r := mustOwnRegister(cur)
517 a := mustAsset(r, id)
518 if a.retired {
519 panic("assets: asset " + strconv.FormatUint(id, 10) + " is already retired")
520 }
521
522 before := a.amount
523 if n := elapsed(a); n > a.booked {
524 bookSpan(r, a, n)
525 }
526 a.retired = true
527 a.retiredAt = runtime.ChainHeight()
528 return a.amount - before
529}
530
531// bookSpan posts the charge for (a.booked, n] and advances the mark. It is
532// the only writer of a.amount and a.booked, and the only caller of Post —
533// which is what makes CrossFoot an exact identity rather than a heuristic.
534func bookSpan(r *register, a *asset, n int32) {
535 amt, err := a.sched.Charge(a.booked, n)
536 if err != nil {
537 panic(err)
538 }
539 if amt > 0 {
540 memo := "depreciation: asset " + strconv.FormatUint(a.id, 10) +
541 ", periods " + strconv.FormatInt(int64(a.booked), 10) +
542 "-" + strconv.FormatInt(int64(n), 10)
543 postings := []journal.Posting{
544 {Account: ExpenseCode, Side: journal.Debit, Amount: amt},
545 {Account: AccumCode, Side: journal.Credit, Amount: amt},
546 }
547 if _, err := r.book.Post(runtime.ChainHeight(), memo, postings); err != nil {
548 panic(err)
549 }
550 // a.amount + amt cannot overflow: the sum of telescoping charges
551 // from period 0 is AccumulatedAt(n), which is at most Basis,
552 // which is at most Cost.
553 a.amount += amt
554 }
555 a.booked = n
556}
557
558// elapsed is the whole periods completed for an asset at the current height.
559//
560// Periods cannot fail here: period is at least MinPeriodBlocks (> 0), the
561// current height is never below placedAt because placedAt was the height at
562// placement and heights do not decrease, and the quotient of two chain
563// heights cannot exceed maxInt32. The error is still read and panicked on
564// rather than discarded, because every one of those three facts is an
565// argument and not a check.
566func elapsed(a *asset) int32 {
567 n, err := depreciation.Periods(a.placedAt, runtime.ChainHeight(), a.period)
568 if err != nil {
569 panic(err)
570 }
571 return n
572}
573
574// ---------------------------------------------------------------------------
575// Reads — all free, all taking an owner
576// ---------------------------------------------------------------------------
577
578// HasRegister reports whether an address has a register.
579func HasRegister(owner address) bool { return regs.Has(owner.String()) }
580
581// Registers is how many registers exist. There is deliberately no way to
582// enumerate them or to aggregate across them: such a total would be the sum
583// of every stranger's self-reported cost figures.
584func Registers() int { return regCount }
585
586// OpenedAt is the height a register was created.
587func OpenedAt(owner address) int64 { return mustReg(owner).openedAt }
588
589// AssetCount is how many assets a register holds, retired ones included.
590func AssetCount(owner address) int { return mustReg(owner).assets.Size() }
591
592// Height is the chain height this realm dates entries and periods with.
593// Exposed so a caller can predict what BookThrough will book.
594func Height() int64 { return runtime.ChainHeight() }
595
596// AssetInfo reports an asset's whole stored state in one read.
597//
598// method is "straight-line" or "declining-balance"; rateNum and rateDen are
599// 0 for a straight-line asset.
600func AssetInfo(owner address, id uint64) (
601 label string,
602 cost, salvage int64,
603 life int32,
604 method string,
605 rateNum, rateDen int64,
606 placedAt, periodBlocks int64,
607 bookedThrough int32,
608 bookedAmount int64,
609 retired bool,
610 retiredAt int64,
611) {
612 a := mustAsset(mustReg(owner), id)
613 return a.label, a.sched.Cost, a.sched.Salvage, a.sched.Life,
614 a.sched.Method.String(), a.sched.RateNum, a.sched.RateDen,
615 a.placedAt, a.period, a.booked, a.amount, a.retired, a.retiredAt
616}
617
618// Label is an asset's label.
619func Label(owner address, id uint64) string { return mustAsset(mustReg(owner), id).label }
620
621// BookedThrough is the high-water mark: the whole periods already charged.
622func BookedThrough(owner address, id uint64) int32 {
623 return mustAsset(mustReg(owner), id).booked
624}
625
626// BookedAmount is the cumulative expense posted for one asset.
627func BookedAmount(owner address, id uint64) int64 {
628 return mustAsset(mustReg(owner), id).amount
629}
630
631// PeriodsElapsed is the whole periods completed for an asset at the current
632// height, whether or not they have been booked. For a retired asset this
633// keeps increasing while BookedThrough does not, which is the intended
634// difference and is why both are exported.
635func PeriodsElapsed(owner address, id uint64) int32 {
636 return elapsed(mustAsset(mustReg(owner), id))
637}
638
639// Due is the charge BookThrough would post for an asset right now. It is 0
640// for a retired asset, for an asset with no newly-completed period, and for a
641// degenerate declining schedule that has not reached its final period.
642func Due(owner address, id uint64) int64 {
643 a := mustAsset(mustReg(owner), id)
644 if a.retired {
645 return 0
646 }
647 n := elapsed(a)
648 if n <= a.booked {
649 return 0
650 }
651 amt, err := a.sched.Charge(a.booked, n)
652 if err != nil {
653 panic(err)
654 }
655 return amt
656}
657
658// CarryingAt is an asset's carrying amount after n whole periods: Cost at
659// n <= 0, Salvage at every n >= Life. It reads the SCHEDULE and is
660// independent of what has been booked.
661func CarryingAt(owner address, id uint64, n int32) int64 {
662 c, err := mustAsset(mustReg(owner), id).sched.CarryingAt(n)
663 if err != nil {
664 panic(err)
665 }
666 return c
667}
668
669// AccumulatedAt is Cost - CarryingAt(n): what the contra-asset balance for
670// one asset SHOULD be once period n is booked.
671func AccumulatedAt(owner address, id uint64, n int32) int64 {
672 acc, err := mustAsset(mustReg(owner), id).sched.AccumulatedAt(n)
673 if err != nil {
674 panic(err)
675 }
676 return acc
677}
678
679// FullyDepreciatedAt is the height at which an asset first carries only its
680// salvage value: placedAt + Life*periodBlocks. Validated at placement, so it
681// cannot fail afterwards.
682func FullyDepreciatedAt(owner address, id uint64) int64 {
683 a := mustAsset(mustReg(owner), id)
684 t, err := a.sched.FullyDepreciatedAt(a.placedAt, a.period)
685 if err != nil {
686 panic(err)
687 }
688 return t
689}
690
691// IsRetired reports whether an asset has been retired.
692func IsRetired(owner address, id uint64) bool {
693 return mustAsset(mustReg(owner), id).retired
694}
695
696// ---------------------------------------------------------------------------
697// Reads — the book
698// ---------------------------------------------------------------------------
699
700// Balance is an account's balance in its NORMAL direction. For 1590 that is
701// debit-normal and so reads negative; [AccumulatedCredit] is the figure a
702// reader of a contra-asset wants.
703func Balance(owner address, code string) (int64, bool) {
704 a, ok := mustReg(owner).book.Account(code)
705 if !ok {
706 return 0, false
707 }
708 return a.Balance(), true
709}
710
711// RawBalance is an account's Debits - Credits, with no normal-direction
712// adjustment.
713func RawBalance(owner address, code string) (int64, bool) {
714 a, ok := mustReg(owner).book.Account(code)
715 if !ok {
716 return 0, false
717 }
718 return a.RawDelta(), true
719}
720
721// AccountKind is an account's kind as a word: "asset" for 1590, "expense"
722// for 5500. The second result is false for a code this chart does not hold.
723//
724// It exists because the chart's kinds are otherwise unobservable from
725// outside. Both accounts are debit-normal, so neither [Balance] nor
726// [Equation] can distinguish them, and the package doc's claim that 1590 is
727// a contra-ASSET rather than a second expense account would be an assertion
728// in a comment with nothing behind it. Opening 1590 as an Expense instead
729// would leave every other read in this file identical.
730func AccountKind(owner address, code string) (string, bool) {
731 a, ok := mustReg(owner).book.Account(code)
732 if !ok {
733 return "", false
734 }
735 return a.Kind.String(), true
736}
737
738// AccumulatedCredit is the POSITIVE accumulated-depreciation figure: the
739// credit balance of 1590, i.e. -RawDelta.
740func AccumulatedCredit(owner address) int64 {
741 a, ok := mustReg(owner).book.Account(AccumCode)
742 if !ok {
743 panic("assets: register has no " + AccumCode + " account")
744 }
745 return -a.RawDelta()
746}
747
748// ExpenseDebit is the total depreciation expense recognised: the debit
749// balance of 5500.
750func ExpenseDebit(owner address) int64 {
751 a, ok := mustReg(owner).book.Account(ExpenseCode)
752 if !ok {
753 panic("assets: register has no " + ExpenseCode + " account")
754 }
755 return a.Balance()
756}
757
758// EntryCount is how many entries a register's book holds.
759func EntryCount(owner address) int { return mustReg(owner).book.EntryCount() }
760
761// Capacity reports how many more assets and entries a register can take.
762func Capacity(owner address) (assetsLeft, entriesLeft int) {
763 r := mustReg(owner)
764 return MaxAssetsPerRegister - r.assets.Size(),
765 MaxEntriesPerRegister - r.book.EntryCount()
766}
767
768// Totals is the book's total debits and credits. They are equal after every
769// charge, because every entry is a matched pair.
770func Totals(owner address) (debits, credits int64) {
771 b := mustReg(owner).book
772 return b.TotalDebits(), b.TotalCredits()
773}
774
775// IsBalanced reports whether the book's debits equal its credits.
776func IsBalanced(owner address) bool { return mustReg(owner).book.IsBalanced() }
777
778// Equation is the accounting equation: debit-normal against credit-normal.
779// Both sides offset at 0 for this chart, because the only entry is an
780// expense debit against a contra-ASSET credit — both legs are debit-normal
781// accounts, so the expense adds and the contra subtracts.
782func Equation(owner address) (debitNormal, creditNormal int64, offsets bool) {
783 return mustReg(owner).book.AccountingEquation()
784}
785
786// Rederive re-walks every posting in the book and reports whether the
787// account totals agree with the journal.
788//
789// It is a FREE read, unlike r/books' owner-only equivalent. The divergence is
790// deliberate: this realm's claim is that its postings follow from its stored
791// schedules, and a claim only the owner can check is not worth making. The
792// cost is linear in the posting count, which is what MaxEntriesPerRegister
793// bounds.
794func Rederive(owner address) (debits, credits int64, agrees bool) {
795 return mustReg(owner).book.RederiveFromJournal()
796}
797
798// ---------------------------------------------------------------------------
799// The invariants
800// ---------------------------------------------------------------------------
801
802// Invariant is the per-asset derivation check:
803//
804// bookedAmount == schedule.AccumulatedAt(bookedThrough)
805//
806// It returns the two figures and whether they agree. A false here means the
807// realm posted an amount its own schedule does not imply, which no code path
808// in this realm can produce — which is exactly why it is worth exporting:
809// the claim is checkable by a third party rather than asserted in a comment.
810func Invariant(owner address, id uint64) (booked, derived int64, agrees bool) {
811 a := mustAsset(mustReg(owner), id)
812 derived, err := a.sched.AccumulatedAt(a.booked)
813 if err != nil {
814 panic(err)
815 }
816 return a.amount, derived, a.amount == derived
817}
818
819// CrossFoot is the register-wide check: the sum of every asset's booked
820// amount against the credit balance of 1590.
821//
822// The identity is EXACT, not approximate, and the reason is structural: this
823// realm exposes no general posting entrypoint, so it is its book's only
824// writer and bookSpan is the only code that posts. Adding a general Post
825// would destroy this check, which is the cost of adding one.
826//
827// retired assets are included: their charges were really incurred and the
828// 1590 credit they produced does not disappear when the asset stops.
829func CrossFoot(owner address) (sumAssets, accumulated int64, agrees bool) {
830 r := mustReg(owner)
831 var sum int64
832 r.assets.Iterate("", "", func(_ string, v any) bool {
833 sum += v.(*asset).amount
834 return false
835 })
836 acc, ok := r.book.Account(AccumCode)
837 if !ok {
838 panic("assets: register has no " + AccumCode + " account")
839 }
840 credit := -acc.RawDelta()
841 return sum, credit, sum == credit
842}
843
844// ---------------------------------------------------------------------------
845// Render
846// ---------------------------------------------------------------------------
847
848// Render shows the realm index at "/" and one owner's register at
849// "/<address>".
850func Render(path string) string {
851 p := strings.TrimSpace(strings.Trim(path, "/"))
852 if p == "" {
853 return renderIndex()
854 }
855 return renderRegister(address(p))
856}
857
858func renderIndex() string {
859 var sb strings.Builder
860 sb.WriteString("# assets — a fixed-asset register with derived postings\n\n")
861 sb.WriteString("Depreciation schedules held as state, charges **derived** ")
862 sb.WriteString("from the schedule and the chain height, and **no custody ")
863 sb.WriteString("of any asset**.\n\n")
864
865 sb.WriteString("> A general ledger takes the amount from its caller, so it ")
866 sb.WriteString("cannot tell a double-booked month from a correct one. This ")
867 sb.WriteString("realm never takes an amount: it keeps a per-asset ")
868 sb.WriteString("high-water mark, so a charge can be neither repeated nor ")
869 sb.WriteString("skipped. That is the whole product, and it proves ")
870 sb.WriteString("arithmetic — not truth. Nothing here ties a cost figure ")
871 sb.WriteString("to a real holding.\n\n")
872
873 sb.WriteString("## State\n\n")
874 sb.WriteString("- registers opened: **" + strconv.Itoa(regCount) + "**\n")
875 sb.WriteString("- chain height: **" + strconv.FormatInt(runtime.ChainHeight(), 10) + "**\n\n")
876
877 sb.WriteString("## The chart is two accounts and is fixed\n\n")
878 sb.WriteString("| code | name | kind | normal |\n")
879 sb.WriteString("|---|---|---|---|\n")
880 sb.WriteString("| `" + ExpenseCode + "` | " + ExpenseName + " | expense | debit |\n")
881 sb.WriteString("| `" + AccumCode + "` | " + AccumName + " | asset (**contra**) | debit |\n\n")
882 sb.WriteString("Every entry is a debit to `" + ExpenseCode + "` against an ")
883 sb.WriteString("equal credit to `" + AccumCode + "`. There is no way to open ")
884 sb.WriteString("a third account, and the **cost basis never reaches the ")
885 sb.WriteString("book** — booking it would need a liability or an equity ")
886 sb.WriteString("plug asserting how the asset was funded, which this realm ")
887 sb.WriteString("does not know.\n\n")
888 sb.WriteString("`" + AccumCode + "` is an asset account, so its *normal* ")
889 sb.WriteString("balance reads **negative**. The positive figure is ")
890 sb.WriteString("`AccumulatedCredit`.\n\n")
891
892 sb.WriteString("## Bounds\n\n")
893 sb.WriteString("| bound | value |\n")
894 sb.WriteString("|---|---|\n")
895 sb.WriteString("| assets per register | " + strconv.Itoa(MaxAssetsPerRegister) + " |\n")
896 sb.WriteString("| entries per register | " + strconv.Itoa(MaxEntriesPerRegister) + " |\n")
897 sb.WriteString("| label length | " + strconv.Itoa(MaxLabelLen) + " |\n")
898 sb.WriteString("| shortest period | " + strconv.FormatInt(MinPeriodBlocks, 10) + " blocks |\n\n")
899 sb.WriteString("One asset is booked per call: the schedule loop's bound is ")
900 sb.WriteString("unpriced upstream, so a bulk entrypoint would multiply an ")
901 sb.WriteString("unpriced cost by the asset count.\n\n")
902
903 sb.WriteString("## Reading a register\n\n")
904 sb.WriteString("`" + RealmPath + ":<address>`\n")
905 return sb.String()
906}
907
908func renderRegister(owner address) string {
909 r, ok := reg(owner)
910 if !ok {
911 return "# No register\n\nNo register exists for `" +
912 sanitize.InlineText(owner.String()) + "`.\n"
913 }
914
915 var sb strings.Builder
916 sb.WriteString("# Register of " + sanitize.InlineText(owner.String()) + "\n\n")
917 sb.WriteString("- opened at height: **" + strconv.FormatInt(r.openedAt, 10) + "**\n")
918 sb.WriteString("- chain height: **" + strconv.FormatInt(runtime.ChainHeight(), 10) + "**\n")
919 sb.WriteString("- assets: **" + strconv.Itoa(r.assets.Size()) + "** / " +
920 strconv.Itoa(MaxAssetsPerRegister) + "\n")
921 sb.WriteString("- entries: **" + strconv.Itoa(r.book.EntryCount()) + "** / " +
922 strconv.Itoa(MaxEntriesPerRegister) + "\n\n")
923
924 sum, credit, agrees := CrossFoot(owner)
925 sb.WriteString("## Cross-foot\n\n")
926 sb.WriteString("| figure | value |\n|---|---|\n")
927 sb.WriteString("| sum of booked amounts | " + strconv.FormatInt(sum, 10) + " |\n")
928 sb.WriteString("| `" + AccumCode + "` credit balance | " + strconv.FormatInt(credit, 10) + " |\n")
929 sb.WriteString("| agrees | **" + yesno(agrees) + "** |\n")
930 sb.WriteString("| book balanced | **" + yesno(r.book.IsBalanced()) + "** |\n\n")
931
932 sb.WriteString("## Assets\n\n")
933 if r.assets.Size() == 0 {
934 sb.WriteString("_None placed yet._\n")
935 return sb.String()
936 }
937 sb.WriteString("| id | label | method | cost | salvage | life | period | booked | amount | derived | ok | state |\n")
938 sb.WriteString("|---|---|---|---|---|---|---|---|---|---|---|---|\n")
939
940 shown := 0
941 total := r.assets.Size()
942 r.assets.Iterate("", "", func(_ string, v any) bool {
943 if shown >= RenderAssetLimit {
944 return true
945 }
946 a := v.(*asset)
947 derived, err := a.sched.AccumulatedAt(a.booked)
948 ok := err == nil && derived == a.amount
949 state := "live"
950 if a.retired {
951 state = "retired at " + strconv.FormatInt(a.retiredAt, 10)
952 }
953 // The avl key is zero-padded to width 20 so lexical order matches
954 // numeric order; the id column must print the id, not the key.
955 sb.WriteString("| " + strconv.FormatUint(a.id, 10) + " | " + cell(a.label) + " | " +
956 cell(a.sched.Method.String()) + " | " +
957 strconv.FormatInt(a.sched.Cost, 10) + " | " +
958 strconv.FormatInt(a.sched.Salvage, 10) + " | " +
959 strconv.FormatInt(int64(a.sched.Life), 10) + " | " +
960 strconv.FormatInt(a.period, 10) + " | " +
961 strconv.FormatInt(int64(a.booked), 10) + " | " +
962 strconv.FormatInt(a.amount, 10) + " | " +
963 strconv.FormatInt(derived, 10) + " | " +
964 yesno(ok) + " | " + state + " |\n")
965 shown++
966 return false
967 })
968 if shown < total {
969 sb.WriteString("\n_Showing " + strconv.Itoa(shown) + " of " +
970 strconv.Itoa(total) + " assets._\n")
971 }
972 return sb.String()
973}
974
975// cell renders a caller-supplied string into a table cell. A blank value
976// would collapse the column, so it is shown as a dash.
977func cell(s string) string {
978 if strings.TrimSpace(s) == "" {
979 return "—"
980 }
981 return sanitize.TableCell(s)
982}
983
984func yesno(b bool) string {
985 if b {
986 return "yes"
987 }
988 return "no"
989}