statements.gno
48.53 Kb · 1046 lines
1// Package statements publishes classified financial statements over a chart
2// of accounts this realm does not own, cannot write, and did not supply.
3//
4// # A PUBLISHED STATEMENT IS A CLAIM ABOUT A CLAIM
5//
6// Read this before trusting any figure this realm returns, and read it before
7// publishing anything here.
8//
9// [r/books] says of itself, correctly: *"a book is a set of claims by its
10// owner"*. It holds no asset, so nothing ties an account reading `Cash 5000`
11// to 5000 real ugnot anywhere. A statement derived from such a book inherits
12// that completely — and then **adds a layer of apparent authority**, because
13// a balance sheet at a stable on-chain path, stamped with a height and a
14// sequence number, looks like an audited artifact. It is a self-report of a
15// self-report.
16//
17// This realm performs no audit, verifies no event, and holds no custody. What
18// it guarantees is narrow and exact: the figures below are **derived** from
19// the owner's chart at a height, by code nobody can parameterise, and frozen.
20// That the chart describes anything real is not claimed here and is not
21// checkable from here.
22//
23// # Derived, not supplied — which is the whole reason this realm exists
24//
25// Every figure comes from [r/books] through its own read surface. A publisher
26// says *when to freeze*; they do not say what the statement says. The
27// alternative design — the publisher passes the rows in — was considered in
28// DISCOVERY.md § 4 and is fatal to the thesis: a statement whose inputs its
29// publisher typed is an assertion about nothing, and this realm would be a
30// pure function with a storage bill.
31//
32// The consequence is that **this is the first first-party realm-to-realm
33// import in this portfolio, and it is permanent.** Bytes are immutable after
34// addpkg, so these statements read the [r/books] at [BooksPath] forever. If
35// that realm ever gets a v2, published statements keep reading v1 — arguably
36// correct for an auditable artifact, and still a coupling nothing here has
37// carried before.
38//
39// # What is published and what is not
40//
41// [Publish] freezes the three figures a balance sheet needs, the three an
42// income statement needs, and the abnormality census. It does NOT freeze the
43// chart: account names, codes and the postings behind them stay in [r/books],
44// which keeps writing them.
45//
46// So [Verify] answers **"do the frozen figures still hold?"** and NOT **"is
47// the chart untouched?"**. The two differ and the difference is demonstrable:
48// a posting that moves 100 from one asset account to another leaves Assets,
49// Claims, the net and the row count all identical, and [Verify] still reads
50// true. It is a reconciliation, not a seal. The seal on the chart's own
51// history is [r/books]' append-only journal, and that is where to look.
52//
53// # The income statement is cumulative since inception
54//
55// [p/journal]'s Close is a backdating seal: it refuses entries dated at or
56// before a cutoff and sweeps no nominal account into equity. There is no
57// retained-earnings account and no closing entry anywhere in the stack, so
58// revenue and expense accumulate from the book's first posting forever.
59//
60// Every accessor and every rendered line therefore carries the word
61// cumulative. A reader who takes them for a period figure misreads every
62// statement this realm publishes. A period statement needs entry-level data;
63// see DISCOVERY.md § 6.8.
64//
65// # The balance sheet identity is NOT offered as a check
66//
67// Assets and Claims are both reported. Their equality is not exposed as a
68// verdict anywhere, because over a chart drawn from a [p/journal] Book it is
69// algebraically identical to `totalDebits == totalCredits`, which [r/books]
70// enforces on every post — a constant true, and true of a chart whose every
71// account is classified backwards. [p/statements]' package doc carries the
72// derivation. A `Balances()` here would be the fifth instance of the
73// vacuous-true class this project keeps finding in its own work.
74//
75// What ships instead is the census: [AbnormalCount] and [AbnormalIn] report
76// every account whose balance runs against its kind. That survives a balanced
77// book. Its incompleteness is exact and is stated at [AbnormalCount].
78//
79// # NO CUSTODY — and refusing coins is not the same as not holding them
80//
81// There is no coinio import, no payable path, no fee, no withdrawal, no
82// admin. So [Publish] REFUSES a transaction carrying coins: a realm with no
83// withdrawal path that silently accepts ugnot strands it permanently, and
84// "we hold no funds" would then be true of the design and false of the
85// balance.
86//
87// # No admin, and no cross-owner aggregate beyond a count
88//
89// The deploying namespace has no privilege here: no setter, no pause, no
90// ability to publish or to read anything a stranger cannot read. [Total] is a
91// count of publications and nothing more. [r/books] refuses to enumerate
92// books because such a total would sum every stranger's self-reported
93// assertion; the same reasoning applies one level up, so there is no way to
94// enumerate publishers and no cross-owner figure of any kind.
95//
96// # What this realm does NOT do
97//
98// - It does not hold, move, or price any asset.
99// - It does not verify that an entry describes a real event.
100// - It does not detect a same-side misclassification. See [AbnormalCount].
101// - It publishes two statements and a census — no cash-flow statement, no
102// statement of changes in equity, no notes, no comparatives.
103//
104// [r/books]: /r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/books
105// [p/journal]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal
106// [p/statements]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/statements
107package statements
108
109import (
110 "chain/runtime"
111 "chain/runtime/unsafe"
112 "strconv"
113 "strings"
114
115 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal"
116 stmt "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/statements"
117 "gno.land/p/nt/avl/v0"
118 "gno.land/p/nt/markdown/sanitize/v0"
119 "gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/books"
120)
121
122// RealmPath is this realm's own path, used to build Render links.
123const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/statements"
124
125// BooksPath is the ledger realm every figure here is derived from.
126//
127// It is taken BY VALUE from that realm's own exported constant rather than
128// typed out again, so this is the compile-time pin itself and not a second
129// copy of it that could drift. A reader checking which ledger a statement
130// describes is reading the import, not a string in a doc comment.
131const BooksPath = books.RealmPath
132
133// PublicationsAreUncapped records a decision by its absence, because every
134// other realm in this portfolio caps its per-owner state and a reader will
135// look for the cap here.
136//
137// There is none, and the three reasons are:
138//
139// - **No shared resource is consumed.** Keys are per owner, like r/books'
140// books. An owner can fill their own publication history and nobody
141// else's, and the cost of doing so is gas plus a storage deposit each
142// time. That is the same griefing argument r/books makes for its
143// per-owner caps, and here it is sufficient on its own.
144// - **Nothing iterates them.** Render reads the newest
145// [RenderPublicationLimit] by sequence number, by direct key lookup, so
146// page cost does not grow with the count. No accessor walks a publisher's
147// history.
148// - **There is no engine cap to mirror.** r/books caps because p/journal
149// caps and the interaction of its ceiling with RederiveFromJournal is
150// unmeasured. No such ceiling exists above this realm.
151//
152// A cap added only so a test could reach it would be worse than none.
153const PublicationsAreUncapped = true
154
155// RenderPublicationLimit caps how many of a publisher's statements one Render
156// call lists. Publications are unbounded (see [PublicationsAreUncapped]), so
157// this is what keeps a publisher's page cost flat.
158const RenderPublicationLimit = 20
159
160// RenderAbnormalLimit caps how many census rows one Render call emits. A
161// chart may hold books.MaxAccountsPerBook accounts and in a pathological book
162// every one of them is abnormal.
163const RenderAbnormalLimit = 25
164
165// ---------------------------------------------------------------------------
166// State
167// ---------------------------------------------------------------------------
168
169// record is one frozen statement. It stores the [stmt.Statement] whole rather
170// than copying its ten fields into a parallel struct: a second field set is a
171// second place for the two to disagree, and every field here is a value or a
172// slice this realm allocated.
173//
174// There is deliberately no seq field. One was written here and read nowhere —
175// found by the sabotage harness, which could not make dropping the assignment
176// fail any test. A stored copy of the sequence would be a second place for the
177// key and the record to disagree, and the key already carries it: every read
178// path reaches a record by constructing pubKey(owner, seq) from a seq it
179// already holds, so a field would only ever be checked against its own source.
180type record struct {
181 at int64
182 st stmt.Statement
183}
184
185// published maps "<owner>|<padded seq>" to *record.
186//
187// The pointer never leaves this file. Every accessor copies scalars out, so no
188// caller receives a handle onto stored state — the same discipline r/books
189// applies to its *journal.Book.
190var published = avl.NewTree()
191
192// counts maps an owner's address string to how many statements they have
193// published. It is the sequence allocator as well as the count.
194var counts = avl.NewTree()
195
196// pubCount is the global total, maintained alongside the tree so the index
197// page never iterates. It is a count and nothing more; see the package doc on
198// why there is no way to enumerate publishers.
199var pubCount int
200
201// ---------------------------------------------------------------------------
202// Guards
203// ---------------------------------------------------------------------------
204
205// refuseCoins panics if a user call arrives carrying coins. Adopted verbatim
206// from r/books, which faces the identical slot: no withdrawal path, so
207// accepted coins are stranded permanently.
208//
209// The guard is on IsUserCall rather than IsUser deliberately, and the reason
210// is NOT the one the canonical payment-guard advice gives. That advice is about
211// a realm that ACCEPTS payment, where IsUser is dangerously broad because the
212// MsgRun ephemeral can consume the envelope before forwarding control and the
213// receipt invariant no longer holds. This guard REFUSES, so the broader
214// predicate would refuse MORE, and "more conservative" is the wrong frame.
215//
216// The real reason is that OriginSend is a fact about the TRANSACTION, not about
217// this realm. It is a faithful receipt only on the direct MsgCall path, where
218// the envelope's destination is this realm. On a MsgRun path the envelope can
219// be non-empty while nothing arrives here — coins sent to the ephemeral flow
220// back to the user — so pairing it with IsUser buys no protection and adds a
221// FALSE REFUSAL mode: a panic on a call that brought this realm nothing.
222// IsUserCall is the narrowest predicate covering the only path on which a
223// non-empty envelope means coins actually credited to this address.
224//
225// The residual is stated rather than guarded: coins can still reach this
226// realm's address through an intermediary's explicit banker send, which no
227// predicate here can see. What makes that survivable is not this guard but the
228// absence of any consumption path — nothing in this realm can spend what
229// arrives, so a stranded balance is inert rather than exploitable.
230//
231// An earlier version of this comment said "OriginSend is only meaningful for a
232// direct MsgCall", which is false as written — it is readable on every path and
233// merely stops being a receipt. An independent audit caught it. The predicate
234// was right and the stated reason was not, which is the worse of the two
235// failures: the predicate is re-derivable from the references and a confident
236// wrong sentence is what stops a maintainer re-deriving it. Onyx's VM tightens
237// IsUserCall, so this is exactly the comment that gets consulted after the
238// semantics have moved underneath it.
239func refuseCoins(cur realm) {
240 // A no-op at today's single call site: Publish's first cur is
241 // runtime-current by construction and stays current when forwarded through
242 // a non-crossing call. It is here because this realm is IMMUTABLE after
243 // addpkg, so a future second caller cannot be made safe later — and a
244 // helper that reads Previous() off an unchecked realm parameter is a
245 // designation-forgery shape whether or not it is reachable today.
246 if !cur.IsCurrent() {
247 panic("statements: refuseCoins called with a realm that is not current")
248 }
249 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
250 panic("statements: this realm holds no coins and has no withdrawal path; send none")
251 }
252}
253
254// ---------------------------------------------------------------------------
255// The cross-realm reads
256// ---------------------------------------------------------------------------
257
258// parseKind turns one of [r/books]' five kind words back into a
259// [journal.Kind], and PANICS on anything else.
260//
261// This is the ugly seam and it is named rather than hidden. r/books returns an
262// account's kind as `journal.Kind.String()` — a presentation format that has
263// become an API across a realm boundary — and no Kind parser exists anywhere
264// in this portfolio (measured in DISCOVERY.md § 7, step 2c: zero hits for
265// ParseKind, parseKind and kindFromString; r/books' own kindFromCode maps a
266// code PREFIX to a kind and is unexported).
267//
268// It panics rather than returning a zero Kind, and the reason is specific: the
269// zero Kind is reported CREDIT-NORMAL by [journal.Kind.IsDebitNormal], so a
270// silently-unparsed word would not drop the row — it would file it under
271// claims and produce a statement that balances and misstates. p/statements'
272// Derive would catch it as ErrInvalidKind, and relying on that would make this
273// realm's error message point at the wrong layer.
274//
275// The round trip is pinned in the suite over all five kinds rather than
276// assumed, because the contract is another realm's String method.
277func parseKind(s string) journal.Kind {
278 switch s {
279 case "asset":
280 return journal.Asset
281 case "liability":
282 return journal.Liability
283 case "equity":
284 return journal.Equity
285 case "revenue":
286 return journal.Revenue
287 case "expense":
288 return journal.Expense
289 }
290 panic("statements: unreadable account kind " + strconv.Quote(s) +
291 " from " + BooksPath)
292}
293
294// chartOf reads an owner's whole chart out of [r/books] as journal.Account
295// rows, ready for [stmt.Derive].
296//
297// # One walk, not a paging loop
298//
299// books.Codes takes a `from` and a `limit`, so the obvious shape is a paging
300// loop — and DISCOVERY.md § 6.4 planned one, costing ceil(n/limit) full chart
301// walks because that iterator has no seek and honours `from` by skipping.
302// This asks for the whole chart in ONE call instead, with limit at r/books'
303// own account cap, which is exactly one walk and is strictly cheaper than any
304// paging of it. The cap is read from that realm's exported constant, so the
305// request can never be short of what a book may hold.
306//
307// Dropping the loop also drops its hazard. books.Codes' `from` is INCLUSIVE —
308// paging re-reads the last code received — so every paging caller owes itself
309// a boundary dedup, and a missed one double-counts one account per page
310// boundary. A double-counted balance is the defect this product is least able
311// to detect: the statement would still balance.
312//
313// # The agreement check is not decoration
314//
315// books.Accounts and books.Codes are two reads, taken at one instant but
316// through different code paths. If they disagree this refuses rather than
317// publishing over a truncated chart, because a chart missing rows yields a
318// statement that is wrong in a way no identity here can see.
319//
320// # Taint
321//
322// The []string books.Codes returns was allocated under that realm and reaches
323// here readonly-tainted. Nothing below mutates it or stores it: the codes are
324// read out by value, and the rows are allocated here.
325func chartOf(owner address) []journal.Account {
326 n := books.Accounts(owner) // panics if the owner has no book
327 codes := books.Codes(owner, "", books.MaxAccountsPerBook)
328 if len(codes) != n {
329 panic("statements: " + BooksPath + " reports " + strconv.Itoa(n) +
330 " accounts but enumerated " + strconv.Itoa(len(codes)) +
331 "; refusing to report on a partial chart")
332 }
333
334 rows := make([]journal.Account, 0, n)
335 for _, code := range codes {
336 name, kind, debits, credits, balance := books.AccountInfo(owner, code)
337 a := journal.Account{
338 Code: code,
339 Name: name,
340 Kind: parseKind(kind),
341 Debits: debits,
342 Credits: credits,
343 }
344 // The balance r/books reported, against the one this row computes.
345 //
346 // State its reach exactly, because the flattering reading is
347 // available: BOTH sides are journal.Account.Balance over the same two
348 // columns, so this is a CONSTANT TRUE against any disagreement
349 // between the two realms' arithmetic and must never be quoted as
350 // cross-checking it. What it does catch is a transcription error in
351 // the four assignments above — swapping Debits and Credits, or
352 // reading a column off the wrong code — which is this realm's own
353 // code and the only place in this path where that could happen.
354 if a.Balance() != balance {
355 panic("statements: balance mismatch for account " + code +
356 "; this realm transcribed the chart wrongly")
357 }
358 rows = append(rows, a)
359 }
360 return rows
361}
362
363// deriveLive reads the owner's chart now and classifies it. Every live figure
364// in this realm goes through here, so there is one path from the ledger to a
365// statement and no accessor can derive differently from Publish.
366//
367// MustDerive, not Derive: on the mutating path an error return does not revert
368// a Gno transaction and only a panic does.
369func deriveLive(owner address) stmt.Statement {
370 return stmt.MustDerive(chartOf(owner))
371}
372
373// ---------------------------------------------------------------------------
374// Write
375// ---------------------------------------------------------------------------
376
377// Publish freezes the caller's own statement at the current height and returns
378// its sequence number, which starts at 1 for each publisher.
379//
380// The owner is cur.Previous().Address() and there is no owner parameter, so
381// authorization is structural rather than a check: there is no address for a
382// caller to forge and no way to publish a statement about somebody else's
383// book. A realm that calls in publishes over its own book for free, which
384// arrives as a property of that rule rather than as a feature — the same way
385// it does in r/books.
386//
387// An EMPTY CHART IS REFUSED, and this is a deliberate divergence from
388// p/statements, whose doc calls zero rows "a legitimate statement of nothing".
389// It is legitimate for a pure function and not worth a storage deposit here: a
390// frozen row of zeros at a permanent path reads as a statement rather than as
391// an absence, and the publisher has paid to say nothing.
392//
393// Everything that can refuse does so before this realm writes anything.
394func Publish(cur realm) int64 {
395 refuseCoins(cur)
396 owner := cur.Previous().Address()
397
398 rows := chartOf(owner)
399 if len(rows) == 0 {
400 panic("statements: this book has no accounts; there is nothing to report on")
401 }
402 st := stmt.MustDerive(rows)
403
404 key := owner.String()
405 seq := int64(ownerCount(key)) + 1
406 published.Set(pubKey(key, seq), &record{
407 at: runtime.ChainHeight(),
408 st: st,
409 })
410 counts.Set(key, int(seq))
411 pubCount++
412 return seq
413}
414
415// ---------------------------------------------------------------------------
416// Reads — the frozen statements
417// ---------------------------------------------------------------------------
418
419// Count is how many statements an owner has published. Zero for an address
420// that has never published, which is not an error.
421func Count(owner address) int { return ownerCount(owner.String()) }
422
423// Latest is an owner's highest sequence number, and whether they have
424// published at all. The second result matters: sequence numbers start at 1, so
425// a zero first result means "never published" and never "statement 0".
426func Latest(owner address) (int64, bool) {
427 n := ownerCount(owner.String())
428 if n == 0 {
429 return 0, false
430 }
431 return int64(n), true
432}
433
434// Total is how many statements exist across every publisher. It is a count and
435// nothing more; see the package doc on why there is no enumeration and no
436// cross-owner figure.
437func Total() int { return pubCount }
438
439// Has reports whether a publication exists, so a reader can distinguish it
440// from the panics every accessor below raises for an unknown one.
441func Has(owner address, seq int64) bool {
442 return published.Has(pubKey(owner.String(), seq))
443}
444
445// PublishedAt is the chain height a statement was frozen at.
446func PublishedAt(owner address, seq int64) int64 {
447 return mustRecord(owner, seq).at
448}
449
450// Rows is how many accounts the statement classified.
451func Rows(owner address, seq int64) int {
452 return mustRecord(owner, seq).st.Rows
453}
454
455// BalanceSheet returns a frozen statement's balance sheet.
456//
457// cumulativeNet appears here as an EXPLICIT RECONCILING LINE because nothing
458// in this stack closes a nominal account into equity. claims is
459// liabilities + equity + cumulativeNet, so a reader who checks assets against
460// liabilities + equity alone and finds it short by exactly cumulativeNet is
461// not looking at a bug.
462//
463// assets and claims are both returned and their equality is deliberately not
464// offered as a verdict; see the package doc.
465func BalanceSheet(owner address, seq int64) (assets, liabilities, equity, cumulativeNet, claims int64) {
466 s := mustRecord(owner, seq).st
467 return s.Assets, s.Liabilities, s.Equity, s.CumulativeNet, s.Claims
468}
469
470// IncomeStatement returns a frozen statement's income statement. Every figure
471// is CUMULATIVE SINCE THE BOOK'S FIRST POSTING, not a period figure; see the
472// package doc.
473func IncomeStatement(owner address, seq int64) (cumulativeRevenue, cumulativeExpenses, cumulativeNet int64) {
474 s := mustRecord(owner, seq).st
475 return s.CumulativeRevenue, s.CumulativeExpenses, s.CumulativeNet
476}
477
478// SectionTotal returns the figure presented for one section: "assets",
479// "claims" or "performance". The performance total is the income statement's
480// bottom line, not the sum of its two components.
481func SectionTotal(owner address, seq int64, section string) int64 {
482 v, err := mustRecord(owner, seq).st.SectionTotal(parseSection(section))
483 if err != nil {
484 // Unreachable: parseSection panics on anything outside the three.
485 panic("statements: " + err.Error())
486 }
487 return v
488}
489
490// AbnormalCount is how many accounts in a frozen statement carry a balance
491// running against their kind.
492//
493// # What the census catches, and what it cannot
494//
495// This is the only partial answer in this portfolio to p/journal's consumer
496// obligation 7 — a kind set wrongly when an account was opened is undetectable
497// in that package and undetectable anywhere else in it. The answer is sound and
498// incomplete, and the incompleteness is exact:
499//
500// - It CATCHES a misclassification that crosses the debit/credit-normal
501// boundary and whose account has a non-zero balance. An account named
502// "Accounts payable" but coded 1200 is an asset in r/books' scheme,
503// accumulates credits, and reads negative here.
504// - It CANNOT CATCH a misclassification that stays on the same side:
505// liability to equity to revenue, or asset to expense. Both subtotals
506// move, no balance goes negative, and nothing in this portfolio detects
507// it. Obligation 7 is NARROWED, NOT CLOSED.
508// - It has a FALSE-POSITIVE MODE that is not a defect. A genuine contra
509// account — accumulated depreciation under assets, a sales return under
510// revenue — is legitimately abnormal-signed. r/books' code-prefix scheme
511// has no contra range, so such an account is indistinguishable here from a
512// miscoded one.
513//
514// So a non-zero count is A FINDING TO READ, never a verdict, and a zero count
515// is not a clean bill of health.
516func AbnormalCount(owner address, seq int64) int {
517 return len(mustRecord(owner, seq).st.Abnormal)
518}
519
520// AbnormalIn counts the abnormal accounts presented in one section:
521// "assets", "claims" or "performance".
522func AbnormalIn(owner address, seq int64, section string) int {
523 return mustRecord(owner, seq).st.AbnormalIn(parseSection(section))
524}
525
526// AbnormalAt returns one census row by its zero-based index. Rows are in
527// ascending account-code order, which p/statements enforces as a precondition
528// on its input rather than relying on this realm to sort.
529//
530// section is the row's statement section, derived from its kind through the
531// same mapping the totals were built from.
532func AbnormalAt(owner address, seq int64, i int) (code, name, kind, section string, balance int64) {
533 rows := mustRecord(owner, seq).st.Abnormal
534 if i < 0 || i >= len(rows) {
535 panic("statements: no census row " + strconv.Itoa(i) +
536 "; this statement has " + strconv.Itoa(len(rows)))
537 }
538 a := rows[i]
539 sec, err := stmt.SectionOf(a.Kind)
540 if err != nil {
541 // Unreachable: Derive refuses a row whose kind is outside the five.
542 panic("statements: " + err.Error())
543 }
544 return a.Code, a.Name, a.Kind.String(), sec.String(), a.Balance
545}
546
547// ---------------------------------------------------------------------------
548// Reads — the live chart
549// ---------------------------------------------------------------------------
550
551// THE COST DECISION FOR EVERY ACCESSOR BELOW, RECORDED BECAUSE IT WAS NOT.
552//
553// Each of these takes an `owner address` the CALLER names, and each one walks
554// that stranger's whole chart: one books.Codes call plus up to
555// books.MaxAccountsPerBook separate cross-realm books.AccountInfo calls. None
556// of them is a crossing function, so anyone can trigger that against any
557// address for free. An audit raised it; this block is the answer, and the
558// realm's other cost decisions are documented so this one has to be too.
559//
560// WHAT IS AND IS NOT AT STAKE. The querying caller pays the gas. Nothing is
561// charged to the owner, nothing accumulates in chain state, and no figure here
562// can be moved by the query. The residual is that a vm/qrender or vm/qeval
563// against a chart at the account cap is that many cross-realm calls of
564// NODE-SIDE work — a node-operator consideration, not a hazard to this realm
565// or to the owner. [Verify] being reached from [Render] is the one part that is
566// involuntary for a viewer and repeatable by anyone: a public page pays it per
567// view.
568//
569// THE DECISION IS TO KEEP THE INLINE CALL. The cheap alternative is for
570// renderStatement to print the frozen figures and link to [Verify] instead of
571// calling it. That is refused because a page showing frozen figures with no
572// live comparison is the exact artifact this realm exists to improve on — the
573// whole reason [Verify] and these accessors exist is that a stale statement be
574// visibly stale, and deferring that to a second click makes the common reading
575// the uninformed one. The bound is the realm's own account cap, it is
576// double-clamped by books.Codes, and the cost is flat per view rather than
577// growing.
578//
579// THE AXIS THAT DOES NOT PRICE THIS. Entries, not accounts, are what a book
580// accumulates without bound, so the question that decides the ceiling is
581// whether this walk is priced by entries as well as by accounts. It is not,
582// and the reason is STRUCTURAL rather than measured: books.AccountInfo returns
583// the account's STORED Debits and Credits and a Balance() over them, so it
584// READS NO ENTRIES AT ALL and performs no journal replay. chartOf does one
585// books.Codes walk plus one AccountInfo per account, bounded by the account
586// cap. So the ceiling is the MAX of the account and entry axes and never their
587// product.
588//
589// Say it as "O(1) in entries EXAMINED", never as "O(1) in gas" -- the second is
590// the overclaim available here and the measurement below refutes it. A bigger
591// book is a bigger store and every lookup into it traverses a deeper avl tree,
592// so cost does grow with entries: sub-linearly, in the store's depth rather
593// than in the entry count. The ceiling argument needs only that the entry term
594// is absent from the PRODUCT, which this gives it.
595//
596// A gas measurement checks that and does not establish it, which is the right
597// way round and was not how this comment first read. Measured on the pinned
598// onyx toolchain, two accounts held constant, as (101 reads minus 1 read) / 100
599// so the setup cancels: about 476k gas per read against a 3-entry book and
600// about 563k against one at books.MaxEntriesPerBook -- +18% across the whole
601// entry range, or roughly twenty gas per entry, IN THAT FIXTURE. Read the next
602// paragraph before quoting either figure; the qualifier is not a hedge and the
603// percentage is not a property of this realm. What the figures do establish is
604// the shape: entry count adds a realm-size overhead and not a replay, which is
605// what the structural argument predicts.
606//
607// THOSE TWO FIGURES ARE EXACTLY REPRODUCIBLE AND ARE STILL NOT A PRICE, and
608// the distinction is the whole point of this paragraph. Two separate
609// invocations of the same four-case fixture returned 476,470 and 562,874 gas
610// per read, identical to the unit -- so this is not run-to-run noise and the
611// toolchain is deterministic. Add NINE UNRELATED test cases ahead of the same
612// four and the cap figure becomes 828,602, up 47%, while the 3-entry figure
613// moves 3%. What the number is sensitive to is therefore the total avl state
614// resident when the read happens, not the entry count under test. A figure
615// measured in a fixture is a figure about that fixture; on chain the resident
616// state is the whole realm's history and nothing here bounds it.
617//
618// That also explains the two designs that failed, which are kept because the
619// failure is the evidence. Differencing setup-only against setup-plus-one-read
620// at the cap puts a ~5e5 signal against a ~3.5e9 setup. Differencing by TEN
621// reads returned a NEGATIVE marginal: each case carries its own setup, and the
622// later case's setup runs against more accumulated state than the earlier
623// one's, so the order effect exceeded a 10-read signal and reversed its sign.
624// A hundred reads clears that; ten does not. The confound was found by the sign
625// rather than argued.
626//
627// An earlier version of this comment quoted 1,060,407 and 1,085,495 gas,
628// "+2.4%", "about six gas per entry". Those came from one of the confounded
629// designs and did not reproduce. They are replaced rather than defended. The
630// structural claim above is the one that survives, and it is checkable from
631// source forever while a gas figure is not.
632//
633// WHAT THIS IS NOT. It is NOT the cost decision books.Rederive makes by being
634// a crossing function. That realm's doc says a free read there "would let
635// anyone make an owner's book expensive to query", and the first reading of
636// this finding was that this realm undoes that decision. It does not, and the
637// analogy was withdrawn on evidence: Rederive replays the APPEND-ONLY JOURNAL,
638// bounded by entry count and growing without bound, while chartOf is bounded
639// by ACCOUNTS at a fixed cap. The two differ by orders of magnitude, which is
640// why one is gated and these are not.
641
642// CurrentBalanceSheet derives the owner's balance sheet from their chart NOW,
643// publishing nothing. It exists so that a false [Verify] is diagnosable: a
644// reader can see which figure moved instead of only that one did.
645//
646// Each of the three Current accessors re-reads the chart, so asking for all of
647// them is three walks. [Verify] is the single-call form.
648func CurrentBalanceSheet(owner address) (assets, liabilities, equity, cumulativeNet, claims int64) {
649 s := deriveLive(owner)
650 return s.Assets, s.Liabilities, s.Equity, s.CumulativeNet, s.Claims
651}
652
653// CurrentIncomeStatement derives the owner's income statement from their chart
654// now. Cumulative since inception; see the package doc.
655func CurrentIncomeStatement(owner address) (cumulativeRevenue, cumulativeExpenses, cumulativeNet int64) {
656 s := deriveLive(owner)
657 return s.CumulativeRevenue, s.CumulativeExpenses, s.CumulativeNet
658}
659
660// CurrentAbnormalCount runs the census over the owner's chart now. Its reach
661// is exactly [AbnormalCount]'s.
662func CurrentAbnormalCount(owner address) int {
663 return len(deriveLive(owner).Abnormal)
664}
665
666// CurrentRows is how many accounts the owner's chart holds right now, as this
667// realm reads it. It agrees with books.Accounts by construction — chartOf
668// refuses when they disagree — and is exposed so a reader comparing a frozen
669// statement's Rows against the live chart does not have to call two realms.
670func CurrentRows(owner address) int { return len(chartOf(owner)) }
671
672// Verify re-derives the owner's chart now and reports whether every figure in
673// a frozen statement still holds, including the census rows.
674//
675// # Read the ceiling before quoting this
676//
677// A true result means THE FROZEN FIGURES STILL HOLD. It does NOT mean the
678// chart is untouched, and the gap is demonstrable rather than theoretical: a
679// posting that moves an amount between two asset accounts leaves Assets,
680// Claims, the net, the row count and the census all identical, and this still
681// reads true. The suite pins that case.
682//
683// A false result is a real difference and nothing more: the owner has posted
684// since, which is the normal life of a book. It is not evidence of
685// misconduct, and this realm offers no mechanism that treats it as such.
686//
687// What proves the chart's own history is r/books' append-only journal, where
688// a wrong entry is corrected by a visible reversal rather than an edit.
689func Verify(owner address, seq int64) bool {
690 want := mustRecord(owner, seq).st
691 got := deriveLive(owner)
692
693 if want.Rows != got.Rows ||
694 want.Assets != got.Assets ||
695 want.Liabilities != got.Liabilities ||
696 want.Equity != got.Equity ||
697 want.CumulativeRevenue != got.CumulativeRevenue ||
698 want.CumulativeExpenses != got.CumulativeExpenses ||
699 want.CumulativeNet != got.CumulativeNet ||
700 want.Claims != got.Claims {
701 return false
702 }
703 if len(want.Abnormal) != len(got.Abnormal) {
704 return false
705 }
706 for i, a := range want.Abnormal {
707 b := got.Abnormal[i]
708 if a.Code != b.Code || a.Kind != b.Kind || a.Balance != b.Balance {
709 return false
710 }
711 // Name is deliberately NOT compared. r/books has no rename path, so
712 // it cannot change; comparing it would assert a property of that
713 // realm's API surface from here, where a reader would mistake it for
714 // a check on the chart.
715 }
716 return true
717}
718
719// Height is the chain height this realm stamps publications with. Exposed so a
720// caller can predict what Publish will record.
721func Height() int64 { return runtime.ChainHeight() }
722
723// Ledger is the path of the realm every figure here is derived from. The
724// constant is [BooksPath]; this is the accessor for a caller that cannot read
725// a constant off a package.
726func Ledger() string { return BooksPath }
727
728// ---------------------------------------------------------------------------
729// Internals
730// ---------------------------------------------------------------------------
731
732// pubKey is owner|paddedSeq. The sequence is left-padded to 19 digits — the
733// width of MaxInt64 — so that lexicographic order over an owner's prefix is
734// numeric order over their statements. An unpadded decimal would sort "10"
735// before "9". The convention is r/invoices' invKey, adopted rather than
736// re-derived.
737func pubKey(owner string, seq int64) string {
738 s := strconv.FormatInt(seq, 10)
739 for len(s) < 19 {
740 s = "0" + s
741 }
742 return owner + "|" + s
743}
744
745// ownerCount reads a publisher's count, treating an absent key as zero.
746//
747// avl.Tree.Get returns ONE value and cannot distinguish an absent key from a
748// nil-valued one, so the two-value type assertion carries the existence answer
749// in one traversal. Only this file ever Sets, and it only ever Sets an int.
750func ownerCount(owner string) int {
751 n, ok := counts.Get(owner).(int)
752 if !ok {
753 return 0
754 }
755 return n
756}
757
758func mustRecord(owner address, seq int64) *record {
759 r, ok := published.Get(pubKey(owner.String(), seq)).(*record)
760 if !ok {
761 panic("statements: no statement " + strconv.FormatInt(seq, 10) +
762 " for " + owner.String())
763 }
764 return r
765}
766
767// parseSection turns a section word into a [stmt.Section] and panics on
768// anything else. The three words are that type's own String output, so the
769// round trip is exact; the suite pins it rather than trusting this to stay
770// true of another package's method.
771func parseSection(section string) stmt.Section {
772 switch strings.ToLower(strings.TrimSpace(section)) {
773 case "assets":
774 return stmt.SectionAssets
775 case "claims":
776 return stmt.SectionClaims
777 case "performance":
778 return stmt.SectionPerformance
779 }
780 panic("statements: unknown section " + strconv.Quote(section) +
781 "; want assets, claims or performance")
782}
783
784// ---------------------------------------------------------------------------
785// Render
786// ---------------------------------------------------------------------------
787
788// Render serves the index at "", a publisher's page at "<address>", and one
789// statement at "<address>/<seq>".
790//
791// Every caller-supplied string — an account's code and name, both of them an
792// owner's free text in r/books — goes through [cell]. Free text reaching
793// Render unescaped is the one injection-shaped surface a realm with no custody
794// still has, and here the bytes come from a THIRD realm's state, which does
795// not make them safer.
796func Render(path string) string {
797 p := strings.TrimSpace(strings.Trim(path, "/"))
798 if p == "" {
799 return renderIndex()
800 }
801 if i := strings.Index(p, "/"); i >= 0 {
802 owner := address(strings.TrimSpace(p[:i]))
803 rest := strings.TrimSpace(strings.Trim(p[i+1:], "/"))
804 seq, err := strconv.ParseInt(rest, 10, 64)
805 if err != nil {
806 return "# statements\n\n`" + sanitize.InlineText(rest) +
807 "` is not a statement number.\n"
808 }
809 return renderStatement(owner, seq)
810 }
811 return renderPublisher(address(p))
812}
813
814func renderIndex() string {
815 var sb strings.Builder
816 sb.WriteString("# statements — classified financial statements\n\n")
817 sb.WriteString("A balance sheet, a cumulative income statement and an ")
818 sb.WriteString("abnormality census, **derived** from a chart of accounts ")
819 sb.WriteString("in [`" + BooksPath + "`](" + BooksPath + ") — never ")
820 sb.WriteString("supplied by the publisher.\n\n")
821
822 sb.WriteString("> **A published statement is a claim about a claim.** ")
823 sb.WriteString("The ledger it reads holds no asset, so nothing ties an ")
824 sb.WriteString("account reading `Cash 5000` to 5000 real ugnot anywhere. ")
825 sb.WriteString("A statement derived from it inherits that entirely and ")
826 sb.WriteString("then adds a layer of apparent authority: it looks like an ")
827 sb.WriteString("audited artifact and is a self-report of a self-report. ")
828 sb.WriteString("No audit is performed here and no custody exists.\n\n")
829
830 sb.WriteString("## State\n\n")
831 sb.WriteString("- statements published: **" + strconv.Itoa(pubCount) + "**\n")
832 sb.WriteString("- ledger: `" + BooksPath + "`\n")
833 sb.WriteString("- chain height: **" + strconv.FormatInt(runtime.ChainHeight(), 10) + "**\n\n")
834 sb.WriteString("There is deliberately no way to enumerate publishers or ")
835 sb.WriteString("to aggregate across them: such a total would sum every ")
836 sb.WriteString("stranger's self-reported assertion.\n\n")
837
838 sb.WriteString("## What is frozen, and what is not\n\n")
839 sb.WriteString("`Publish` freezes the **figures**. It does not freeze the ")
840 sb.WriteString("chart — account codes, names and the postings behind them ")
841 sb.WriteString("stay in the ledger, which keeps writing them. So ")
842 sb.WriteString("`Verify` answers *\"do the frozen figures still hold?\"* ")
843 sb.WriteString("and **not** *\"is the chart untouched?\"*: a posting that ")
844 sb.WriteString("moves an amount between two asset accounts leaves every ")
845 sb.WriteString("figure identical and `Verify` still reads true.\n\n")
846
847 sb.WriteString("## The three sections\n\n")
848 sb.WriteString("| section | account kinds | presented as |\n|---|---|---|\n")
849 sb.WriteString("| `assets` | asset | balance sheet, debit side |\n")
850 sb.WriteString("| `claims` | liability, equity | balance sheet, credit side |\n")
851 sb.WriteString("| `performance` | revenue, expense | income statement |\n\n")
852 sb.WriteString("Revenue and expenses are **cumulative since the book's ")
853 sb.WriteString("first posting**, not period figures: nothing in this ")
854 sb.WriteString("stack closes a nominal account into equity, so the net ")
855 sb.WriteString("appears on the balance sheet as an explicit reconciling ")
856 sb.WriteString("line.\n\n")
857
858 sb.WriteString("## Usage\n\n")
859 sb.WriteString("```\nseq := Publish() // over the caller's own book\n")
860 sb.WriteString("BalanceSheet(owner, seq)\nIncomeStatement(owner, seq)\n")
861 sb.WriteString("AbnormalCount(owner, seq) // a finding to read, not a verdict\n")
862 sb.WriteString("Verify(owner, seq) // do the figures still hold?\n```\n\n")
863 sb.WriteString("A publisher's statements are at `" + RealmPath +
864 ":<address>`, and one statement at `" + RealmPath +
865 ":<address>/<seq>`.\n")
866 return sb.String()
867}
868
869// cell prepares caller-supplied text for a GFM table cell, truncating it for
870// display first.
871//
872// It is r/books' helper of the same name, adopted verbatim, because the slot is
873// identical and the text is literally the same bytes one realm further on.
874// TableCell rather than InlineText: these bytes land between two `|`
875// delimiters and InlineText does not escape the pipe — deliberately, per the
876// sanitizer's own doc, so that TableCell can escape it without
877// double-escaping. r/books shipped InlineText in this slot first and an
878// adversarial audit found it.
879//
880// The account CODE needs this as much as the name does. A GFM row is split
881// before inline parsing runs, so a `|` inside a backtick span is not protected
882// by the backticks, and r/books' kindFromCode constrains only the code's first
883// character.
884//
885// TRUNCATE BEFORE ESCAPING, and on a RUNE boundary. Escaping first and cutting
886// afterwards can slice a `\|` in half and leave a cell ending in a bare
887// backslash; cutting on a byte boundary can split a multi-byte rune and emit
888// invalid UTF-8. By the time the sanitizer sees the string the damage is
889// upstream of it.
890func cell(s string) string {
891 const maxRunes = 48
892 if r := []rune(s); len(r) > maxRunes {
893 s = string(r[:maxRunes]) + "…"
894 }
895 return sanitize.TableCell(s)
896}
897
898func renderPublisher(owner address) string {
899 n := ownerCount(owner.String())
900 if n == 0 {
901 return "# statements\n\nNo statement published by `" +
902 sanitize.InlineText(owner.String()) + "`.\n"
903 }
904
905 var sb strings.Builder
906 sb.WriteString("# Statements published by " +
907 sanitize.InlineText(owner.String()) + "\n\n")
908 sb.WriteString("Derived from that address's book in [`" + BooksPath +
909 "`](" + BooksPath + ":" + owner.String() + "). ")
910 sb.WriteString("**A statement is a claim about a claim** — see the ")
911 sb.WriteString("[index](" + RealmPath + ").\n\n")
912
913 shown := n
914 if shown > RenderPublicationLimit {
915 shown = RenderPublicationLimit
916 }
917 sb.WriteString("| seq | height | rows | assets | claims | cumulative net | census |\n")
918 sb.WriteString("|---|---|---|---|---|---|---|\n")
919 for seq := int64(n); seq > int64(n-shown); seq-- {
920 r, ok := published.Get(pubKey(owner.String(), seq)).(*record)
921 if !ok {
922 // Unreachable: counts and published are written together.
923 continue
924 }
925 sb.WriteString("| [" + strconv.FormatInt(seq, 10) + "](" + RealmPath +
926 ":" + owner.String() + "/" + strconv.FormatInt(seq, 10) + ") | " +
927 strconv.FormatInt(r.at, 10) + " | " +
928 strconv.Itoa(r.st.Rows) + " | " +
929 strconv.FormatInt(r.st.Assets, 10) + " | " +
930 strconv.FormatInt(r.st.Claims, 10) + " | " +
931 strconv.FormatInt(r.st.CumulativeNet, 10) + " | " +
932 strconv.Itoa(len(r.st.Abnormal)) + " |\n")
933 }
934 if n > shown {
935 sb.WriteString("\n_Showing the " + strconv.Itoa(shown) +
936 " most recent of " + strconv.Itoa(n) + " statements._\n")
937 }
938 return sb.String()
939}
940
941func renderStatement(owner address, seq int64) string {
942 r, ok := published.Get(pubKey(owner.String(), seq)).(*record)
943 if !ok {
944 return "# statements\n\nNo statement " +
945 strconv.FormatInt(seq, 10) + " for `" +
946 sanitize.InlineText(owner.String()) + "`.\n"
947 }
948 s := r.st
949
950 var sb strings.Builder
951 sb.WriteString("# Statement " + strconv.FormatInt(seq, 10) + " of " +
952 sanitize.InlineText(owner.String()) + "\n\n")
953 sb.WriteString("Frozen at height **" + strconv.FormatInt(r.at, 10) +
954 "** over **" + strconv.Itoa(s.Rows) + "** accounts, derived from [`" +
955 BooksPath + "`](" + BooksPath + ":" + owner.String() + ").\n\n")
956
957 sb.WriteString("> **This is a claim about a claim.** The ledger holds no ")
958 sb.WriteString("asset and no audit was performed. Every figure below is ")
959 sb.WriteString("derived faithfully from what this address wrote about ")
960 sb.WriteString("itself.\n\n")
961
962 sb.WriteString("## Balance sheet\n\n")
963 sb.WriteString("| | |\n|---|---|\n")
964 sb.WriteString("| assets | " + strconv.FormatInt(s.Assets, 10) + " |\n")
965 sb.WriteString("| liabilities | " + strconv.FormatInt(s.Liabilities, 10) + " |\n")
966 sb.WriteString("| equity | " + strconv.FormatInt(s.Equity, 10) + " |\n")
967 sb.WriteString("| cumulative net | " + strconv.FormatInt(s.CumulativeNet, 10) + " |\n")
968 sb.WriteString("| **claims** | **" + strconv.FormatInt(s.Claims, 10) + "** |\n\n")
969 sb.WriteString("`claims` is liabilities + equity + cumulative net. The ")
970 sb.WriteString("net is an **explicit reconciling line**, because nothing ")
971 sb.WriteString("in this stack closes a nominal account into equity — a ")
972 sb.WriteString("reader who checks assets against liabilities + equity ")
973 sb.WriteString("alone and finds it short by exactly the net is not ")
974 sb.WriteString("looking at a bug.\n\n")
975 sb.WriteString("That `assets` and `claims` agree is **not offered as a ")
976 sb.WriteString("check**: over any chart the ledger can produce that ")
977 sb.WriteString("equality is algebraically identical to *debits equal ")
978 sb.WriteString("credits*, which it enforces on every post. It is true of ")
979 sb.WriteString("a chart whose every account is classified backwards.\n\n")
980
981 sb.WriteString("## Income statement — cumulative since inception\n\n")
982 sb.WriteString("| | |\n|---|---|\n")
983 sb.WriteString("| cumulative revenue | " + strconv.FormatInt(s.CumulativeRevenue, 10) + " |\n")
984 sb.WriteString("| cumulative expenses | " + strconv.FormatInt(s.CumulativeExpenses, 10) + " |\n")
985 sb.WriteString("| **cumulative net** | **" + strconv.FormatInt(s.CumulativeNet, 10) + "** |\n\n")
986 sb.WriteString("**Not a period figure.** Revenue and expenses accumulate ")
987 sb.WriteString("from the book's first posting forever: the ledger's close ")
988 sb.WriteString("is a backdating seal and sweeps no nominal account into ")
989 sb.WriteString("equity.\n\n")
990
991 sb.WriteString("## Abnormality census\n\n")
992 if len(s.Abnormal) == 0 {
993 sb.WriteString("_No account carried a balance against its kind._\n\n")
994 } else {
995 sb.WriteString("**" + strconv.Itoa(len(s.Abnormal)) + "** account(s) ")
996 sb.WriteString("carried a balance running against their kind.\n\n")
997 sb.WriteString("| code | name | kind | section | balance |\n")
998 sb.WriteString("|---|---|---|---|---|\n")
999 for i, a := range s.Abnormal {
1000 if i >= RenderAbnormalLimit {
1001 break
1002 }
1003 secName := "invalid"
1004 if sec, err := stmt.SectionOf(a.Kind); err == nil {
1005 secName = sec.String()
1006 }
1007 sb.WriteString("| `" + cell(a.Code) + "` | " + cell(a.Name) +
1008 " | " + a.Kind.String() + " | " + secName + " | " +
1009 strconv.FormatInt(a.Balance, 10) + " |\n")
1010 }
1011 if len(s.Abnormal) > RenderAbnormalLimit {
1012 sb.WriteString("\n_Showing " + strconv.Itoa(RenderAbnormalLimit) +
1013 " of " + strconv.Itoa(len(s.Abnormal)) + " rows._\n")
1014 }
1015 sb.WriteString("\n")
1016 }
1017 sb.WriteString("A census row is **a finding to read, never a verdict**. ")
1018 sb.WriteString("It catches a misclassification that crosses the ")
1019 sb.WriteString("debit/credit-normal boundary; it **cannot** catch one ")
1020 sb.WriteString("that stays on the same side (liability↔equity↔revenue, ")
1021 sb.WriteString("asset↔expense), and a genuine contra account — ")
1022 sb.WriteString("accumulated depreciation, a sales return — is ")
1023 sb.WriteString("legitimately abnormal-signed. An empty census is not a ")
1024 sb.WriteString("clean bill of health.\n\n")
1025
1026 sb.WriteString("## Does it still hold?\n\n")
1027 if !books.HasBook(owner) {
1028 sb.WriteString("_The ledger reports no book for this address._\n")
1029 return sb.String()
1030 }
1031 if Verify(owner, seq) {
1032 sb.WriteString("**Verify: true** — re-deriving the chart now ")
1033 sb.WriteString("reproduces every figure above. This means the ")
1034 sb.WriteString("**figures** still hold; it does **not** mean the ")
1035 sb.WriteString("chart is untouched. A posting that moves an amount ")
1036 sb.WriteString("between two asset accounts leaves every figure here ")
1037 sb.WriteString("identical.\n")
1038 } else {
1039 sb.WriteString("**Verify: false** — the chart has moved since this ")
1040 sb.WriteString("statement was frozen. That is the normal life of a ")
1041 sb.WriteString("book and is not evidence of anything else. The ")
1042 sb.WriteString("current figures are derivable with ")
1043 sb.WriteString("`CurrentBalanceSheet`.\n")
1044 }
1045 return sb.String()
1046}