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.

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, &register{
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}