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.

books.gno

39.84 Kb · 999 lines
  1// Package books is a per-owner general ledger: an address opens a chart of
  2// accounts and posts balanced double-entry entries against it.
  3//
  4// # The one idea
  5//
  6// Every other ledger-like realm in this portfolio is SINGLE-PURPOSE and its
  7// state shape IS its question. invoices tracks receivables. payroll tracks
  8// accrued wages. budgets tracks allocations. fee_split tracks shares. Each is
  9// a correct answer to one question and structurally cannot answer a second,
 10// because there is nowhere to put a transaction that is not already one of
 11// the modelled kinds.
 12//
 13// This realm has no opinion about what the accounts mean. The owner defines
 14// the chart; the realm enforces that every entry balances, that it cannot be
 15// altered after the fact, and that only the owner may write it.
 16//
 17// # NO CUSTODY — and refusing coins is not the same as not holding them
 18//
 19// This realm holds nothing. There is no coinio import, no payable path, no
 20// fee, no withdrawal, no admin.
 21//
 22// So every mutating entrypoint REFUSES a transaction that arrives carrying
 23// coins. A realm with no withdrawal path that silently accepts ugnot strands
 24// it permanently, and "we don't hold funds" would then be true of the design
 25// and false of the balance. Having no custody is not the same as being safe
 26// to send coins to, and the difference is one guard.
 27//
 28// # A BOOK IS A SET OF CLAIMS BY ITS OWNER
 29//
 30// Read this before trusting any number this realm returns.
 31//
 32// With no custody there is no solvency invariant tying "Cash 5000" to 5000
 33// real ugnot anywhere. A book can be internally perfect and describe nothing.
 34// IsBalanced proves arithmetic, never truth.
 35//
 36// Consequently this realm exposes NO CROSS-OWNER AGGREGATE. Summing every
 37// book's debits would be summing every stranger's self-reported assertion,
 38// and the getter's existence would invite reading it as a signal. What it
 39// exposes instead is owner-scoped, which is the only question a consumer can
 40// act on — and a consumer acting on its OWN book is trusting only itself.
 41//
 42// # The account code DETERMINES the account kind
 43//
 44// An account's code's leading digit fixes its kind, by the standard
 45// chart-of-accounts convention:
 46//
 47//	1xxx -> Asset       2xxx -> Liability   3xxx -> Equity
 48//	4xxx -> Revenue     5xxx -> Expense
 49//
 50// There is no kind parameter. This is deliberate and it is the realm's answer
 51// to p/journal consumer obligation 7, which exists because of that package's
 52// audit finding Y1: NO CHECK IN p/journal VALIDATES AN ACCOUNT'S KIND. An
 53// account opened Asset that should have been Liability reports a sign-flipped
 54// Balance() forever while IsBalanced, RederiveFromJournal and
 55// AccountingEquation all three stay healthy — every one of them is built on
 56// Debits and Credits, which the kind does not touch.
 57//
 58// Deriving the kind does not eliminate that hazard. It MOVES it: after the
 59// derivation the kind cannot disagree with the code, so the unauditable
 60// question "is this kind right?" becomes the visible question "is this code
 61// right?", which any reader of the chart can check against the convention
 62// above. That is the whole claim and it is deliberately not stated as more.
 63//
 64// The cost is a mandatory numbering convention: a code whose first character
 65// is not 1-5 is refused. For a realm whose purpose is auditable books, that
 66// is the feature.
 67//
 68// # Entries are append-only and are corrected by REVERSAL
 69//
 70// There is no edit and no delete. A wrong entry is corrected by posting its
 71// mirror image, which leaves both the error and the correction readable. Each
 72// entry may be reversed at most once; a reversal is itself an entry and is
 73// itself reversible once.
 74//
 75// # Periods
 76//
 77// An owner may seal everything up to a height with CloseThrough, after which
 78// no entry may be dated at or before it. The seal only moves forward. This is
 79// the owner's own discipline, not a protocol rule — nothing forces an owner to
 80// close anything.
 81//
 82// # The clock is the chain's
 83//
 84// Entries are dated with runtime.ChainHeight() and never with a caller-
 85// supplied value, satisfying p/journal obligations 2 and 3. Heights
 86// throughout, never seconds. A realm that let a caller choose the date would
 87// have no period discipline at all.
 88//
 89// # Caps, and why they are far below the engine's
 90//
 91// p/journal permits 10 000 accounts and 1 000 000 entries. Its AUDIT.md names
 92// the gas ceiling on RederiveFromJournal at full cap — a worst case of 64M
 93// posting visits in one call — as UNMEASURED and does not claim it. So this
 94// realm sets its own caps far below, and exposes Rederive as an explicit
 95// owner-callable rather than running it inside a getter where another party
 96// would pay for it.
 97//
 98// The caps are per-owner, never global. A global cap on books would be a
 99// griefing vector: one address could fill it and lock everyone else out.
100// Per-owner, an owner can exhaust their own capacity and nobody else's, and
101// the cost of doing so is gas plus a storage deposit per record.
102//
103// # Realms may keep books too, for free
104//
105// The owner is cur.Previous().Address(), so a realm that calls in keeps books
106// under its own address with no extra authorization code. That is the
107// realm-to-realm integration surface, and it arrived as a property of the
108// owner-only rule rather than as a feature.
109//
110// # What this realm does NOT do
111//
112//   - It does not hold, move, or price any asset.
113//   - It does not verify that an entry describes a real event.
114//   - It does not validate that the right transaction went in the right
115//     account. The derived kind makes the CODE auditable, not the judgement.
116//   - It has no admin. The deploying namespace has no privilege here at all:
117//     no setter, no pause, no ability to post, reverse, or read anything a
118//     stranger cannot read.
119package books
120
121import (
122	"chain/runtime"
123	"chain/runtime/unsafe"
124	"strconv"
125	"strings"
126
127	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal"
128	"gno.land/p/nt/avl/v0"
129	"gno.land/p/nt/markdown/sanitize/v0"
130)
131
132// RealmPath is this realm's own path, used to build Render links.
133const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/books"
134
135// MaxAccountsPerBook caps one owner's chart. Far below journal's
136// MaxAccountsLimit of 10000: IsBalanced sums the whole chart, and this bound
137// is what keeps that read cheap for everyone.
138const MaxAccountsPerBook = 128
139
140// MaxEntriesPerBook caps one owner's journal. Far below journal's
141// MaxEntriesLimit of 1000000, because that limit's interaction with
142// RederiveFromJournal is unmeasured — see the package doc.
143//
144// A book that fills up refuses further entries rather than dropping history.
145// The remedy is a fresh book with opening balances carried forward; there is
146// deliberately no Archive, because it would destroy history and needs more
147// design than it has had.
148const MaxEntriesPerBook = 4096
149
150// MaxLegsPerEntry caps one entry's postings. journal permits 64; this is
151// lower because an entry is parsed from a single string argument and rendered
152// in full, and both of those costs are linear in the leg count.
153const MaxLegsPerEntry = 16
154
155// MaxCodeLen and MaxNameLen bound an account's identifiers. Both are at or
156// below journal's own limits, so the engine's validation is a backstop here
157// rather than the only check.
158const (
159	MaxCodeLen = 16
160	MaxNameLen = 128
161)
162
163// MaxMemoLen bounds an entry's free text. Below journal's MaxMemoLen of 1024
164// because every memo is a candidate for Render output.
165const MaxMemoLen = 256
166
167// RenderEntryLimit caps how many entries one Render call emits. A book may
168// hold MaxEntriesPerBook of them and rendering all of them would make the
169// page cost grow without bound.
170const RenderEntryLimit = 25
171
172// books maps an owner's address string to that owner's *journal.Book.
173//
174// The value is a POINTER and is never returned by any exported function —
175// journal consumer obligation 4: a *Book is a mutable handle, and handing one
176// across a realm boundary would hand out the right to post and reverse.
177var books = avl.NewTree()
178
179// openedAt records the height each book was created, for Render.
180var openedAt = avl.NewTree()
181
182// bookCount is maintained alongside the tree so the index page never has to
183// iterate it.
184var bookCount int
185
186// ---------------------------------------------------------------------------
187// Guards
188// ---------------------------------------------------------------------------
189
190// refuseCoins panics if a user call arrives carrying coins.
191//
192// This realm has no withdrawal path, so accepted coins would be stranded
193// permanently. The guard is on IsUserCall rather than IsUser deliberately:
194// OriginSend is only meaningful for a direct MsgCall, and the MsgRun
195// ephemeral can consume the envelope before forwarding control, so IsUser
196// would be the wrong predicate. See p/journal's audit note and security.md.
197func refuseCoins(cur realm) {
198	// A no-op at every one of today's call sites: each is a crossing entrypoint
199	// forwarding its OWN first cur, which is runtime-current by construction
200	// and stays current through a non-crossing call. It is here because this
201	// realm is IMMUTABLE after addpkg, so a call site added later cannot be
202	// made safe later — and a helper that reads Previous() off an unchecked
203	// realm parameter is a designation-forgery shape (security.md Class 2)
204	// whether or not it is reachable today.
205	//
206	// No count of the call sites is written here, per CLAUDE.md § 4a: the
207	// number is the one thing about this comment that an edit elsewhere in the
208	// file can falsify.
209	if !cur.IsCurrent() {
210		panic("books: refuseCoins called with a realm that is not current")
211	}
212	if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
213		panic("books: this realm holds no coins and has no withdrawal path; send none")
214	}
215}
216
217// ---------------------------------------------------------------------------
218// Account kinds, derived from the code
219// ---------------------------------------------------------------------------
220
221// kindFromCode maps a code's leading digit to its account kind. The second
222// result is false for any code that does not begin 1-5.
223//
224// This is the whole of the realm's answer to journal obligation 7. See the
225// package doc for what it does and does not buy.
226func kindFromCode(code string) (journal.Kind, bool) {
227	if code == "" {
228		return 0, false
229	}
230	switch code[0] {
231	case '1':
232		return journal.Asset, true
233	case '2':
234		return journal.Liability, true
235	case '3':
236		return journal.Equity, true
237	case '4':
238		return journal.Revenue, true
239	case '5':
240		return journal.Expense, true
241	}
242	return 0, false
243}
244
245// KindOf reports the kind a code would be given, without opening anything.
246// A consumer can call this before OpenAccount to check its own numbering.
247func KindOf(code string) string {
248	k, ok := kindFromCode(code)
249	if !ok {
250		return "invalid"
251	}
252	return k.String()
253}
254
255// ---------------------------------------------------------------------------
256// Writes
257// ---------------------------------------------------------------------------
258
259// OpenBook creates the caller's book. It panics if the caller already has one.
260//
261// Separating this from OpenAccount is deliberate: creating a book is the call
262// that pays the storage deposit for the owner's record, and a caller should
263// see that cost on its own rather than hidden inside their first account.
264func OpenBook(cur realm) {
265	refuseCoins(cur)
266	owner := cur.Previous().Address()
267	key := owner.String()
268	if books.Has(key) {
269		panic("books: this address already has a book")
270	}
271	b := journal.MustNew(MaxAccountsPerBook, MaxEntriesPerBook)
272	books.Set(key, b)
273	openedAt.Set(key, runtime.ChainHeight())
274	bookCount++
275}
276
277// OpenAccount adds an account to the caller's book. The KIND IS DERIVED from
278// the code's leading digit; there is no kind parameter. See the package doc.
279func OpenAccount(cur realm, code, name string) {
280	refuseCoins(cur)
281	b := mustOwnBook(cur)
282
283	if code == "" {
284		panic("books: account code must not be empty")
285	}
286	if len(code) > MaxCodeLen {
287		panic("books: account code too long, max " + strconv.Itoa(MaxCodeLen))
288	}
289	if len(name) > MaxNameLen {
290		panic("books: account name too long, max " + strconv.Itoa(MaxNameLen))
291	}
292	kind, ok := kindFromCode(code)
293	if !ok {
294		panic("books: account code must begin 1-5 (1 asset, 2 liability, " +
295			"3 equity, 4 revenue, 5 expense); got " + code)
296	}
297	// journal validates length, duplication and the chart cap itself; its
298	// error is returned rather than swallowed so the caller sees which bound
299	// they hit.
300	if err := b.Open(code, name, kind); err != nil {
301		panic("books: " + err.Error())
302	}
303}
304
305// PostSimple posts the two-leg entry that is the overwhelmingly common case:
306// debit one account, credit another, for the same amount.
307//
308// It exists beside PostMulti because the two-leg form cannot be mis-encoded.
309// A caller who wants one debit and one credit should never have to get a leg
310// specification's syntax right.
311func PostSimple(cur realm, debitCode, creditCode string, amount int64, memo string) uint64 {
312	refuseCoins(cur)
313	b := mustOwnBook(cur)
314	checkMemo(memo)
315	if amount <= 0 {
316		panic("books: amount must be positive")
317	}
318	if debitCode == creditCode {
319		// journal would accept this and fold both legs, netting to zero
320		// against one account. It is always a caller mistake, so it is
321		// refused here rather than recorded as a no-op entry.
322		panic("books: debit and credit accounts must differ")
323	}
324	postings := []journal.Posting{
325		{Account: debitCode, Side: journal.Debit, Amount: amount},
326		{Account: creditCode, Side: journal.Credit, Amount: amount},
327	}
328	id, err := b.Post(runtime.ChainHeight(), memo, postings)
329	if err != nil {
330		panic("books: " + err.Error())
331	}
332	return id
333}
334
335// PostMulti posts an entry of up to MaxLegsPerEntry legs, specified as a
336// comma-separated list of colon-separated triples:
337//
338//	"1000:D:500,4000:C:300,4100:C:200"
339//
340// Side is D or C, case-insensitive. Amounts are positive integers in the
341// owner's chosen unit. The string form is used because a crossing function
342// cannot usefully take a slice over MsgCall; treasury_board encodes a list
343// the same way.
344//
345// Debits must equal credits, and journal names which side is short.
346//
347// A leg may name an account another leg already named — journal folds them,
348// and "1000:D:500,1000:C:200,4000:C:300" is a legitimate net +300 to 1000.
349// What is refused is an entry in which EVERY leg names the same account: it
350// balances only by netting to exactly zero, so it is the PostSimple mistake
351// wearing the multi-leg syntax. The narrow rule is deliberate — a blanket
352// "no repeated account" would reject the legitimate fold above.
353func PostMulti(cur realm, legs, memo string) uint64 {
354	refuseCoins(cur)
355	b := mustOwnBook(cur)
356	checkMemo(memo)
357	postings := parseLegs(legs)
358	if allOneAccount(postings) {
359		panic("books: every leg names " + postings[0].Account +
360			"; such an entry can only net to zero")
361	}
362	id, err := b.Post(runtime.ChainHeight(), memo, postings)
363	if err != nil {
364		panic("books: " + err.Error())
365	}
366	return id
367}
368
369// Reverse posts the mirror image of an existing entry in the caller's book,
370// dated now. The original is left exactly as it was: this is a correction,
371// not an edit.
372//
373// An entry may be reversed at most once. The reversal is itself an entry and
374// may itself be reversed once.
375func Reverse(cur realm, id uint64, memo string) uint64 {
376	refuseCoins(cur)
377	b := mustOwnBook(cur)
378	checkMemo(memo)
379	newID, err := b.Reverse(id, runtime.ChainHeight(), memo)
380	if err != nil {
381		panic("books: " + err.Error())
382	}
383	return newID
384}
385
386// CloseThrough seals the caller's book up to and including a height: no entry
387// may afterwards be dated at or before it. The seal only ever moves forward.
388//
389// Passing a height at or beyond the current one seals the present and makes
390// the book immediately unpostable until the chain advances past it. That is
391// permitted — it is the owner's own book — and it is called out because it is
392// the one way to brick a book without filling it.
393func CloseThrough(cur realm, through int64) {
394	refuseCoins(cur)
395	b := mustOwnBook(cur)
396	if err := b.Close(through); err != nil {
397		panic("books: " + err.Error())
398	}
399}
400
401// Rederive runs journal's STRONG invariant check over the caller's own book:
402// it replays every posting of every entry and compares the result against
403// both accumulators.
404//
405// It is a crossing function rather than a getter on purpose. The check is
406// O(all postings ever) and the owner is the party who should pay for it —
407// exposing it as a free read would let anyone make an owner's book expensive
408// to query. IsBalanced is the cheap check and is a free read.
409//
410// Returns the replayed totals and whether they agree.
411func Rederive(cur realm) (debits, credits int64, agrees bool) {
412	refuseCoins(cur)
413	b := mustOwnBook(cur)
414	return b.RederiveFromJournal()
415}
416
417// ---------------------------------------------------------------------------
418// Internals
419// ---------------------------------------------------------------------------
420
421// mustOwnBook returns the CALLER'S book, and there is no variant that returns
422// anyone else's.
423//
424// Authorization is structural rather than a check: the owner is derived from
425// cur.Previous().Address() and no mutating function takes an owner parameter,
426// so there is no address for a caller to forge. journal obligation 5.
427//
428// "No address to forge" is true of the SIGNATURE and was never true of the
429// realm VALUE, which is the second half of the same Class 2 shape refuseCoins
430// carries above — and the more consequential half, because this is the
431// function that decides who owns the book rather than one that only refuses
432// coins. The guard is a no-op today for the same reason as refuseCoins's:
433// every call site forwards a crossing entrypoint's own first cur. It is kept
434// for the same reason too, and its call-site count is likewise not written
435// down. See AUDIT.md § O.
436func mustOwnBook(cur realm) *journal.Book {
437	if !cur.IsCurrent() {
438		panic("books: mustOwnBook called with a realm that is not current")
439	}
440	owner := cur.Previous().Address()
441	b, ok := books.Get(owner.String()).(*journal.Book)
442	if !ok {
443		panic("books: no book for this address; call OpenBook first")
444	}
445	return b
446}
447
448// book returns an owner's book for READ-ONLY use by the getters below.
449//
450// The pointer never leaves this file: every exported getter copies scalars or
451// journal's by-value Account/Entry out of it. Obligation 4.
452//
453// avl.Tree.Get returns ONE value, not (any, bool): it cannot distinguish a
454// nil-valued key from an absent one, which is the same trap journal's
455// `reversed` field comment names from the other side. The two-value TYPE
456// ASSERTION is what carries the existence answer here, in one traversal —
457// `ok` is false for an absent key and for a present-but-wrong-typed value
458// alike, and the second is impossible because only this file ever Sets.
459// Do not "fix" this into Has-then-Get: that is two traversals for the same
460// answer, and a reader would then owe themselves a proof that nothing can
461// change between them.
462func book(owner address) (*journal.Book, bool) {
463	b, ok := books.Get(owner.String()).(*journal.Book)
464	if !ok {
465		return nil, false
466	}
467	return b, true
468}
469
470func mustBook(owner address) *journal.Book {
471	b, ok := book(owner)
472	if !ok {
473		panic("books: no book for " + owner.String())
474	}
475	return b
476}
477
478// allOneAccount reports whether every posting names the same account.
479//
480// parseLegs has already refused a list shorter than two, so this is never
481// asked of a single-element slice and the loop below always compares.
482func allOneAccount(postings []journal.Posting) bool {
483	for _, p := range postings[1:] {
484		if p.Account != postings[0].Account {
485			return false
486		}
487	}
488	return true
489}
490
491func checkMemo(memo string) {
492	if len(memo) > MaxMemoLen {
493		panic("books: memo too long, max " + strconv.Itoa(MaxMemoLen))
494	}
495}
496
497// parseLegs decodes the "code:side:amount,..." form of PostMulti.
498//
499// Every failure is a panic naming the offending leg by its 1-based position,
500// because a caller who mistyped one triple in a list of eight needs to know
501// which one. A silently-dropped leg would produce an unbalanced entry and the
502// error would surface as "debits do not equal credits", which points at the
503// wrong thing.
504func parseLegs(legs string) []journal.Posting {
505	if strings.TrimSpace(legs) == "" {
506		panic("books: no legs given; format is \"1000:D:500,4000:C:500\"")
507	}
508	parts := strings.Split(legs, ",")
509	if len(parts) < 2 {
510		panic("books: an entry needs at least two legs")
511	}
512	if len(parts) > MaxLegsPerEntry {
513		panic("books: too many legs, max " + strconv.Itoa(MaxLegsPerEntry))
514	}
515	out := make([]journal.Posting, 0, len(parts))
516	for i, raw := range parts {
517		at := " at leg " + strconv.Itoa(i+1)
518		f := strings.Split(strings.TrimSpace(raw), ":")
519		if len(f) != 3 {
520			panic("books: expected code:side:amount" + at)
521		}
522		code := strings.TrimSpace(f[0])
523		if code == "" {
524			panic("books: empty account code" + at)
525		}
526		var side journal.Side
527		switch strings.ToUpper(strings.TrimSpace(f[1])) {
528		case "D", "DR", "DEBIT":
529			side = journal.Debit
530		case "C", "CR", "CREDIT":
531			side = journal.Credit
532		default:
533			panic("books: side must be D or C" + at)
534		}
535		amount, err := strconv.ParseInt(strings.TrimSpace(f[2]), 10, 64)
536		if err != nil {
537			panic("books: amount is not an integer" + at)
538		}
539		if amount <= 0 {
540			panic("books: amount must be positive" + at)
541		}
542		out = append(out, journal.Posting{Account: code, Side: side, Amount: amount})
543	}
544	return out
545}
546
547// ---------------------------------------------------------------------------
548// Reads — all owner-scoped, by §4.2 of DISCOVERY.md
549// ---------------------------------------------------------------------------
550
551// HasBook reports whether an address has opened a book.
552func HasBook(owner address) bool { return books.Has(owner.String()) }
553
554// Books is the number of books that exist. It is a count and nothing more:
555// there is deliberately no way to enumerate them or aggregate across them.
556func Books() int { return bookCount }
557
558// OpenedAt is the height an owner's book was created.
559func OpenedAt(owner address) int64 {
560	h, ok := openedAt.Get(owner.String()).(int64)
561	if !ok {
562		panic("books: no book for " + owner.String())
563	}
564	return h
565}
566
567// Balance returns an account's balance in its NORMAL direction: positive
568// means a debit balance for an asset or expense, and a credit balance for a
569// liability, equity or revenue account.
570//
571// The second result distinguishes "no such account" from "zero".
572func Balance(owner address, code string) (int64, bool) {
573	b, ok := book(owner)
574	if !ok {
575		return 0, false
576	}
577	return b.Balance(code)
578}
579
580// RawBalance returns an account's debits minus credits, without the normal-
581// direction sign convention. Exposed because a consumer reconciling against
582// an external system usually wants the raw figure.
583func RawBalance(owner address, code string) (int64, bool) {
584	b, ok := book(owner)
585	if !ok {
586		return 0, false
587	}
588	a, found := b.Account(code)
589	if !found {
590		return 0, false
591	}
592	return a.RawDelta(), true
593}
594
595// AccountInfo returns an account's full state. journal returns Account BY
596// VALUE, so this hands out a copy and no pointer into stored state.
597func AccountInfo(owner address, code string) (name, kind string, debits, credits, balance int64) {
598	a, ok := mustBook(owner).Account(code)
599	if !ok {
600		panic("books: no account " + code + " for " + owner.String())
601	}
602	return a.Name, a.Kind.String(), a.Debits, a.Credits, a.Balance()
603}
604
605// HasAccount reports whether an owner's chart contains a code.
606func HasAccount(owner address, code string) bool {
607	b, ok := book(owner)
608	if !ok {
609		return false
610	}
611	_, found := b.Account(code)
612	return found
613}
614
615// Accounts is the number of accounts in an owner's chart.
616func Accounts(owner address) int { return mustBook(owner).AccountCount() }
617
618// Codes lists an owner's account codes in ascending code order, beginning at
619// from INCLUSIVE and returning at most limit of them. A limit <= 0 returns
620// nothing; a limit above the chart cap is clamped to it. Page by passing the
621// last code received back as from, which re-reads that one code.
622//
623// # Why this exists, and why a counter was not enough
624//
625// Accounts above returns HOW MANY accounts a chart holds, and AccountInfo
626// answers for a code the caller already knows. Neither lets a caller DISCOVER
627// a code. Without this, a trial balance, a balance sheet and an income
628// statement are all unbuildable from outside this realm however many times
629// the chart is interrogated — a consumer could only report on accounts it had
630// independently been told about, which for a chart the OWNER defines is no
631// guarantee at all.
632//
633// # Why not simply export journal's iterator
634//
635// journal.Book has IterateAccounts(fn func(Account) bool), and exporting a
636// realm entrypoint that takes fn from the caller would be the shortest code
637// here and a security defect. A caller-supplied callback is the "callback
638// params" class, and journal's own doc records the sharp edge: fn runs under
639// the CALLING REALM'S STORAGE AUTHORITY, because the receiver is stamped with
640// this realm's PkgID and a top-level fn has no declaring realm to anchor it
641// elsewhere. A hostile fn would execute holding r/books' own authority.
642//
643// So the iterator stays internal and this returns VALUES. The closure below
644// is written by this realm, appends to a local slice, and re-enters no
645// mutator — which is exactly the condition journal's doc attaches to it.
646//
647// Codes is a free read and NOT a crossing function, so an owner does not pay
648// for a consumer's paging. The cost ceiling is the chart cap: the underlying
649// iterator has no seek, so `from` is honoured by skipping, and one call walks
650// the whole chart however small limit is. A caller paging a 128-account chart
651// 8 codes at a time therefore does 16 full walks, not one. That is a
652// deliberate trade for not touching p/journal, whose Codes-with-seek would
653// have re-opened Gate A and the sabotage sweeps for all three of its
654// importers.
655func Codes(owner address, from string, limit int) []string {
656	b := mustBook(owner)
657	if limit <= 0 {
658		return nil
659	}
660	if limit > MaxAccountsPerBook {
661		limit = MaxAccountsPerBook
662	}
663	out := make([]string, 0, limit)
664	b.IterateAccounts(func(a journal.Account) bool {
665		if a.Code < from {
666			return false
667		}
668		out = append(out, a.Code)
669		return len(out) >= limit
670	})
671	return out
672}
673
674// EntryCount is the number of entries in an owner's journal.
675func EntryCount(owner address) int { return mustBook(owner).EntryCount() }
676
677// EntryInfo returns one entry's header. Postings are returned separately by
678// EntryLegs, because an entry's leg count is variable and a crossing-function
679// return cannot carry a slice usefully.
680func EntryInfo(owner address, id uint64) (at int64, memo string, legs int, reversalOf uint64, total int64) {
681	e, ok := mustBook(owner).Entry(id)
682	if !ok {
683		panic("books: no entry " + strconv.FormatUint(id, 10))
684	}
685	return e.At, e.Memo, len(e.Postings), e.ReversalOf, e.Total()
686}
687
688// EntryLegs returns one entry's postings in the "code:side:amount,..." form
689// PostMulti accepts, so an entry can be read and re-posted without a
690// separate encoding.
691func EntryLegs(owner address, id uint64) string {
692	e, ok := mustBook(owner).Entry(id)
693	if !ok {
694		panic("books: no entry " + strconv.FormatUint(id, 10))
695	}
696	var sb strings.Builder
697	for i, p := range e.Postings {
698		if i > 0 {
699			sb.WriteString(",")
700		}
701		sb.WriteString(p.Account)
702		if p.Side == journal.Debit {
703			sb.WriteString(":D:")
704		} else {
705			sb.WriteString(":C:")
706		}
707		sb.WriteString(strconv.FormatInt(p.Amount, 10))
708	}
709	return sb.String()
710}
711
712// IsReversed reports whether an entry has already been reversed. An entry may
713// be reversed at most once, so this is also "may this still be reversed".
714func IsReversed(owner address, id uint64) bool {
715	return mustBook(owner).IsReversed(id)
716}
717
718// Totals returns an owner's running debit and credit totals.
719func Totals(owner address) (debits, credits int64) {
720	b := mustBook(owner)
721	return b.TotalDebits(), b.TotalCredits()
722}
723
724// IsBalanced is the CHEAP invariant and this realm's live-verification probe.
725//
726// It compares two independently-maintained accumulators: the per-account
727// totals summed across the chart, against the book's own running totals.
728// They are written by different code paths, so a drift between them is a real
729// defect and this finds it.
730//
731// What it does NOT do is re-read the journal, so a defect that corrupted both
732// accumulators identically would survive it. That is Rederive's job, and
733// Rederive is a crossing function because the owner should pay for it.
734//
735// And the ceiling on both: this proves the book is self-consistent, never
736// that its entries describe real events. See the package doc.
737func IsBalanced(owner address) bool { return mustBook(owner).IsBalanced() }
738
739// Equation partitions an owner's chart BY ACCOUNT KIND: the summed raw delta
740// of every debit-normal account (Asset, Expense), the same for every
741// credit-normal one (Liability, Equity, Revenue), and whether the two offset.
742//
743// The two FIGURES are why this exists. They are the balance-sheet and
744// income-statement partition of the chart, and no other accessor on this
745// realm exposes it — Totals sums debits and credits without regard to kind,
746// and AccountInfo answers one code at a time.
747//
748// # `offsets` IS A VACUOUS TRUE ON THIS REALM — do not quote it as a check
749//
750// Read this before using the third result for anything.
751//
752// The kind partition is TOTAL: journal's Kind is 1..5, IsDebitNormal is
753// `k == Asset || k == Expense`, so every account lands in exactly one of the
754// two sums and neither can omit one. Therefore
755//
756//	debitNormal + creditNormal  ==  sum of RawDelta over ALL accounts
757//	                            ==  totalDebits - totalCredits
758//
759// and this realm refuses an entry that does not balance, so that difference
760// is always zero. `offsets` is consequently TRUE for every book this realm
761// can produce, and it is true for a chart whose accounts are classified
762// nonsensically just as readily as for a correct one.
763//
764// This is the `HasValidSignature` shape §7b already names: a predicate that
765// is a constant under current conditions, and so is evidence of nothing. The
766// suite pins the vacuity deliberately rather than asserting true and reading
767// green — a test that cannot fail is the thing that makes this dangerous.
768//
769// What `offsets` DOES still distinguish is arithmetic overflow, where journal
770// returns (0, 0, false). So the honest reading is **"the two sums are
771// trustworthy"**, never "the classification is right". A consumer wanting a
772// genuine classification check must compare the kinds against its own
773// expectation, which is the statement consumer's job and not this realm's.
774//
775// Both figures are RAW deltas (debits - credits), not normalised balances, so
776// a debit-normal total is normally positive and a credit-normal total normally
777// negative. A credit-normal account carrying a positive raw delta is a contra
778// account, which is legitimate, and is why neither figure is forced
779// non-negative.
780func Equation(owner address) (debitNormal, creditNormal int64, offsets bool) {
781	return mustBook(owner).AccountingEquation()
782}
783
784// ClosedThrough returns the height an owner's book is sealed to, and whether
785// it has ever been closed. The second result matters: a book closed through
786// height 0 is not the same as a book that has never been closed.
787func ClosedThrough(owner address) (int64, bool) {
788	return mustBook(owner).ClosedThrough()
789}
790
791// Capacity returns an owner's remaining headroom, so a consumer can see a
792// full book coming rather than discovering it on a refused entry.
793func Capacity(owner address) (accountsLeft, entriesLeft int) {
794	b := mustBook(owner)
795	return MaxAccountsPerBook - b.AccountCount(), MaxEntriesPerBook - b.EntryCount()
796}
797
798// Height is the chain height this realm dates entries with. Exposed so a
799// caller can predict what CloseThrough will seal.
800func Height() int64 { return runtime.ChainHeight() }
801
802// ---------------------------------------------------------------------------
803// Render
804// ---------------------------------------------------------------------------
805
806// Render serves the index at "" and one owner's book at "<address>".
807//
808// Every caller-supplied string — account names and entry memos — goes through
809// the ecosystem sanitizer. Free text reaching Render unescaped is the one
810// injection-shaped surface a realm with no custody still has.
811func Render(path string) string {
812	p := strings.TrimSpace(strings.Trim(path, "/"))
813	if p == "" {
814		return renderIndex()
815	}
816	return renderBook(address(p))
817}
818
819func renderIndex() string {
820	var sb strings.Builder
821	sb.WriteString("# books — a per-owner general ledger\n\n")
822	sb.WriteString("Double-entry books. Append-only, corrected by reversal, ")
823	sb.WriteString("and **no custody of any asset**.\n\n")
824
825	sb.WriteString("> A book is a set of claims by its owner. With no custody ")
826	sb.WriteString("there is no solvency invariant tying a balance to a real ")
827	sb.WriteString("holding — a book can be internally perfect and describe ")
828	sb.WriteString("nothing. The invariants here prove arithmetic, not truth.\n\n")
829
830	sb.WriteString("## State\n\n")
831	sb.WriteString("- books opened: **" + strconv.Itoa(bookCount) + "**\n")
832	sb.WriteString("- chain height: **" + strconv.FormatInt(runtime.ChainHeight(), 10) + "**\n\n")
833	sb.WriteString("There is deliberately no way to enumerate books or to ")
834	sb.WriteString("aggregate across them: such a total would be the sum of ")
835	sb.WriteString("every stranger's self-reported assertion.\n\n")
836
837	sb.WriteString("## The chart-of-accounts convention\n\n")
838	sb.WriteString("An account's **code determines its kind** — there is no kind ")
839	sb.WriteString("parameter:\n\n")
840	sb.WriteString("| code | kind | normal balance |\n")
841	sb.WriteString("|---|---|---|\n")
842	sb.WriteString("| `1xxx` | asset | debit |\n")
843	sb.WriteString("| `2xxx` | liability | credit |\n")
844	sb.WriteString("| `3xxx` | equity | credit |\n")
845	sb.WriteString("| `4xxx` | revenue | credit |\n")
846	sb.WriteString("| `5xxx` | expense | debit |\n\n")
847
848	sb.WriteString("## Caps, per owner\n\n")
849	sb.WriteString("| bound | value |\n|---|---|\n")
850	sb.WriteString("| accounts per book | " + strconv.Itoa(MaxAccountsPerBook) + " |\n")
851	sb.WriteString("| entries per book | " + strconv.Itoa(MaxEntriesPerBook) + " |\n")
852	sb.WriteString("| legs per entry | " + strconv.Itoa(MaxLegsPerEntry) + " |\n\n")
853
854	sb.WriteString("## Usage\n\n")
855	sb.WriteString("```\nOpenBook()\nOpenAccount(\"1000\", \"Cash\")\n")
856	sb.WriteString("OpenAccount(\"4000\", \"Sales\")\n")
857	sb.WriteString("PostSimple(\"1000\", \"4000\", 5000, \"invoice 17 settled\")\n")
858	sb.WriteString("PostMulti(\"1000:D:500,4000:C:300,4100:C:200\", \"split sale\")\n```\n\n")
859	sb.WriteString("View a book at `" + RealmPath + ":<address>`.\n")
860	return sb.String()
861}
862
863// cell prepares caller-supplied text for a GFM table cell, truncating it for
864// display first. It is the `invoices` helper of the same name, adopted verbatim
865// rather than re-derived, because the two realms face the identical slot.
866//
867// TableCell rather than InlineText, and the SLOT is the reason: these bytes
868// land between two `|` delimiters, and an unescaped `|` opens a new column and
869// shifts every column to its right. InlineText does not escape the pipe —
870// deliberately, per the sanitizer's own doc, so that TableCell can escape it
871// without double-escaping. Measured on onyx-1 with chain_id_bound: true:
872//
873//	InlineText("Cash | **forged** | x") -> `Cash | \*\*forged\*\* | x`   pipes LIVE
874//	TableCell ("Cash | **forged** | x") -> `Cash \| \*\*forged\*\* \| x`  escaped
875//
876// This realm shipped InlineText here first and an adversarial audit found it.
877// An owner could forge columns in their own book's chart or journal — and the
878// victim is a THIRD PARTY reading that book, which attacks the one thing this
879// realm exists to provide. The account CODE needs this as much as the name
880// does: a GFM row is split before inline parsing runs, so a `|` inside the
881// backtick span at the code column is not protected by the backticks, and
882// kindFromCode constrains only code[0].
883//
884// Applied to caller-supplied text only. `Kind.String()` and the `note` column
885// are realm-constructed from an enum and a uint64, so sanitizing them would be
886// noise rather than slot-matching — unlike `invoices`, which passes a bech32
887// address through this helper precisely because that value IS caller-derived.
888//
889// TRUNCATE BEFORE ESCAPING, and on a RUNE boundary. Escaping first and cutting
890// afterwards can slice a `\|` in half and leave the cell ending in a bare
891// backslash; cutting on a byte boundary can split a multi-byte rune and emit
892// invalid UTF-8. By the time the sanitizer sees the string the damage is
893// upstream of it, so neither is something it is asked to repair.
894func cell(s string) string {
895	const maxRunes = 48
896	if r := []rune(s); len(r) > maxRunes {
897		s = string(r[:maxRunes]) + "…"
898	}
899	return sanitize.TableCell(s)
900}
901
902func renderBook(owner address) string {
903	b, ok := book(owner)
904	if !ok {
905		return "# books\n\nNo book for `" +
906			sanitize.InlineText(owner.String()) + "`.\n"
907	}
908
909	var sb strings.Builder
910	sb.WriteString("# Book of " + sanitize.InlineText(owner.String()) + "\n\n")
911
912	// Invariant first, with its ceiling stated beside it rather than below.
913	if b.IsBalanced() {
914		sb.WriteString("**IsBalanced: true** — the per-account totals agree ")
915		sb.WriteString("with the running totals. This is arithmetic ")
916		sb.WriteString("self-consistency and says nothing about whether the ")
917		sb.WriteString("entries describe real events.\n\n")
918	} else {
919		sb.WriteString("**IsBalanced: FALSE** — the accumulators have drifted. ")
920		sb.WriteString("This is a defect; do not rely on any figure below.\n\n")
921	}
922
923	d, c := b.TotalDebits(), b.TotalCredits()
924	sb.WriteString("| | |\n|---|---|\n")
925	sb.WriteString("| total debits | " + strconv.FormatInt(d, 10) + " |\n")
926	sb.WriteString("| total credits | " + strconv.FormatInt(c, 10) + " |\n")
927	sb.WriteString("| accounts | " + strconv.Itoa(b.AccountCount()) + " / " +
928		strconv.Itoa(MaxAccountsPerBook) + " |\n")
929	sb.WriteString("| entries | " + strconv.Itoa(b.EntryCount()) + " / " +
930		strconv.Itoa(MaxEntriesPerBook) + " |\n")
931	if through, has := b.ClosedThrough(); has {
932		sb.WriteString("| closed through | height " +
933			strconv.FormatInt(through, 10) + " |\n")
934	} else {
935		sb.WriteString("| closed through | never closed |\n")
936	}
937	sb.WriteString("\n")
938
939	// Chart of accounts, in ascending code order — avl, so deterministic.
940	sb.WriteString("## Chart of accounts\n\n")
941	if b.AccountCount() == 0 {
942		sb.WriteString("_No accounts yet._\n\n")
943	} else {
944		sb.WriteString("| code | name | kind | debits | credits | balance |\n")
945		sb.WriteString("|---|---|---|---|---|---|\n")
946		b.IterateAccounts(func(a journal.Account) bool {
947			sb.WriteString("| `" + cell(a.Code) + "` | " +
948				cell(a.Name) + " | " + a.Kind.String() + " | " +
949				strconv.FormatInt(a.Debits, 10) + " | " +
950				strconv.FormatInt(a.Credits, 10) + " | " +
951				strconv.FormatInt(a.Balance(), 10) + " |\n")
952			return false
953		})
954		sb.WriteString("\nBalances are in each account's **normal direction**.\n\n")
955	}
956
957	// The most recent entries, newest first, bounded.
958	sb.WriteString("## Journal\n\n")
959	total := b.EntryCount()
960	if total == 0 {
961		sb.WriteString("_No entries yet._\n")
962		return sb.String()
963	}
964	shown := total
965	if shown > RenderEntryLimit {
966		shown = RenderEntryLimit
967	}
968	// Entries are ids 1..nextID-1 and none are ever removed, so the newest
969	// `shown` of them start at total-shown+1.
970	from := uint64(total-shown) + 1
971	recent := b.Entries(from, shown)
972
973	sb.WriteString("| id | height | memo | legs | total | note |\n")
974	sb.WriteString("|---|---|---|---|---|---|\n")
975	for i := len(recent) - 1; i >= 0; i-- {
976		e := recent[i]
977		note := ""
978		if e.IsReversal() {
979			note = "reverses #" + strconv.FormatUint(e.ReversalOf, 10)
980		} else if b.IsReversed(e.ID) {
981			note = "reversed"
982		}
983		memo := cell(e.Memo)
984		if memo == "" {
985			memo = "_none_"
986		}
987		sb.WriteString("| " + strconv.FormatUint(e.ID, 10) + " | " +
988			strconv.FormatInt(e.At, 10) + " | " + memo + " | " +
989			strconv.Itoa(len(e.Postings)) + " | " +
990			strconv.FormatInt(e.Total(), 10) + " | " + note + " |\n")
991	}
992	if total > shown {
993		sb.WriteString("\n_Showing the " + strconv.Itoa(shown) + " most recent of " +
994			strconv.Itoa(total) + " entries._\n")
995	}
996	sb.WriteString("\nEntries are append-only. A wrong entry is corrected by ")
997	sb.WriteString("posting its reversal, which leaves both readable.\n")
998	return sb.String()
999}