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}