journal
gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/journal on gno.land mainnetgno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal on onyx-1 testnet
pure packageprimitiveaccountinglive on mainnetlive on onyx-1
Keep double-entry books that always balance.
Every entry is checked before it is recorded, so the ledger cannot drift out of balance one posting at a time.
In detail curated
gno.land mainnet
Double-entry journal primitive: balanced entries enforced at Post (sum(debits)==sum(credits)), immutable once posted, reversal by compensating entry, balances derived not stored. The accounting-suite floor -- eight prepared mainnet applications import it. Byte-exact twin of the live onyx copy (zero substitutions); audit clean, 99/0 sabotage coverage run live by register gate SEC8.
onyx-1 testnet
Double-entry bookkeeping as a pure primitive. It stores no balance -- it stores ENTRIES and derives balances from them, which is what separates it from every one-sided financial primitive in the searched ecosystem (a treasury records what moved, a pull-payment ledger records who is owed what, a GRC20 ledger records who holds what; each stores a balance and mutates it). An entry that does not balance is REFUSED and the refusal names the short column, so sum(debits) == sum(credits) is the precondition for the state changing at all rather than a property the consumer must remember to maintain: a Book cannot be put into an unbalanced state by any sequence of calls to this package. Post is the only way in -- there is no Edit and no Delete -- and a mistake is corrected the way real books correct one, with a REVERSING ENTRY that mirrors every posting and leaves both visible; Reverse refuses a second reversal of the same entry, so a correction cannot be looped. Close(through) seals a period, only ever moves forward, and afterwards both Post and Reverse refuse anything dated into the sealed range. Amounts are always POSITIVE with direction carried by Side rather than a sign, which is what makes 'which column is short' answerable. Three consistency checks of DELIBERATELY DIFFERENT STRENGTH, each documented at its real strength: IsBalanced compares two independently-maintained accumulators (cheap, catches a drift between code paths, does not re-read the journal); RederiveFromJournal replays every posting ever and is the strong claim at O(all postings); AccountingEquation is WEAKER THAN IT LOOKS and says so -- its boolean proves exactly one of IsBalanced's three conjuncts and is INVARIANT under every possible Kind misclassification, so its value is the two subtotals, not the flag. Holds no coins, performs no effects, imports no chain APIs, owns no package-level state; the realm owns a *Book and supplies the clock.
One description per chain, because this application is deployed on more than one chain and each account was written for its own deployment. The programs differ only where the chain's namespace appears in the source — proved, not asserted, under Deployments below — so where the accounts differ they differ in emphasis and in which chain's history they describe, not in what the code does.
Use it
A pure package has no state and no transactions: you use it by importing it.
Import it
on gno.land mainnet
import "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/journal"on onyx-1 testnet
import "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal"The path is the address: there is no package manager, nothing to add to a manifest and no version to pin, so the package you compile against is the one the chain runs. It differs per chain because the namespace in it is the deploying account's.
Called from real code
Not an example written for this page. These lines are quoted from assets, a deployed package in this portfolio that imports this one as journal, out of the committed file whose sha256 onyx-1 testnet attested. They compile because they are compiled.
b := journal.MustNew(2, MaxEntriesPerRegister)Deployments
Deployed on 2 chains from the same source. The bytes are identical — one digest, cca6944e1dcbddd2…, on both — so the API below is the API on either. Everything that is not the bytes differs per chain and is stated per chain: the import path, the namespace, the transaction, the height, the cost, and which files the chain holds.
gno.land mainnet gnoland-1
chain-attested| Import path | gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/journal |
|---|---|
| Namespace | g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39 |
| Deployed at height | 1,316,628 |
| Deploy transaction | f43ed0546985bcd744af240ad3c4f196defbb7bc1138b9ec6a6f55380083fda1 look it up on the RPC |
| Deployer | g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39 |
| Gas used | 104,756,192 |
| Storage | not attested per package on this chain — see the limitations below |
| Files on chain | gnomod.toml journal.gno journal_test.gno |
| Deployed bytes | journal.gno — 35,285 bytes |
| sha256 | cca6944e1dcbddd212c18e7b2531bca6065a28f7c80b541b4281f034ff505a1a |
| Realm address | none — A /p/ package is not a realm: no state, no custody, never a transaction sender. |
| Used by | no consumer on this chain |
|---|
Do not take the hash above on trust. $download returns the bytes gnoland-1 is actually running; this command fetches them and prints their digest, which should equal the one in the table:
curl -sS 'https://gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/journal$download&file=journal.gno' | shasum -a 256Expected: cca6944e1dcbddd212c18e7b2531bca6065a28f7c80b541b4281f034ff505a1a — 35,285 bytes. Use curl: the gno.land edges answer Python's default user-agent with HTTP 403.
onyx-1 testnet onyx-1
chain-attested| Import path | gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal |
|---|---|
| Namespace | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Deployed at height | 327,179 |
| Deploy transaction | e405a57357ab24aa6d56001996f167edb7075c010e2dcc2574c29ea343747f76 look it up on the RPC |
| Deployer | g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3 |
| Gas used | 47,593,781 |
| Storage | not attested per package on this chain — see the limitations below |
| Files on chain | gnomod.toml journal.gno |
| Deployed bytes | journal.gno — 35,285 bytes |
| sha256 | cca6944e1dcbddd212c18e7b2531bca6065a28f7c80b541b4281f034ff505a1a |
| Realm address | none — A /p/ package is not a realm: it holds no state, custodies no coins and is never a transaction sender. The pkgPath derivation would still produce a value; recording one would name nothing. |
| Used by | p/cashflow, p/periods, p/statements, r/assets, r/books, r/cashflow, r/inventory, r/statements |
|---|
Do not take the hash above on trust. $download returns the bytes onyx-1 is actually running; this command fetches them and prints their digest, which should equal the one in the table:
curl -sS 'https://onyx.testnets.gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal$download&file=journal.gno' | shasum -a 256Expected: cca6944e1dcbddd212c18e7b2531bca6065a28f7c80b541b4281f034ff505a1a — 35,285 bytes. Use curl: the gno.land edges answer Python's default user-agent with HTTP 403.
API
chain-derived 2 exported functions, 6 types, 36 methods.
Read from vm/qdoc on gnoland-1. It describes the onyx-1 testnet deployment as well: the deployed bytes are identical on every chain here — one digest, cca6944e1dcbddd2… — and this site refuses to build a shared page for a package whose bytes differ across chains, so the same API is the API on each. The API is the only thing that carries across; every provenance fact above is per-chain.
This is a /p/ package: you import it, you do not call it in a transaction. gnoweb's $help shows only exported top-level functions, so the types and methods below do not appear there at all — which is why this reference exists.
Overview
Package journal is a pure double-entry accounting primitive for realms that need books which provably balance. It is to *bookkeeping* what feeledger is to *money* and duebook is to *time*: it holds no coins, performs no effects, imports no chain APIs, and owns no package-level state. The importing realm owns a *Book, supplies the clock, and performs its own effects.
The one idea
Every financial primitive in the searched Gno ecosystem is ONE-SIDED. A treasury records what moved. A pull-payment ledger records who is owed what. A GRC20 ledger records who holds what. Each stores a balance and mutates it.
This package stores no balance. It stores ENTRIES, and derives balances from them:
book.MustOpen("1000", "Cash", Asset) book.MustOpen("4000", "Revenue", Revenue) book.MustPost(now, "invoice 17 paid", []Posting{ {Account: "1000", Side: Debit, Amount: 5000}, {Account: "4000", Side: Credit, Amount: 5000}, })
An entry that does not balance is refused, and the refusal says which side is short. So `sum(debits) == sum(credits)` is not a property the consumer must remember to maintain — it is the precondition for the state changing at all. A Book cannot be put into an unbalanced state by any sequence of calls to this package.
No amend, no void
Post is the only way in, and there is no Edit and no Delete. A mistake is corrected the way real books correct one: with a REVERSING ENTRY that mirrors every posting of the original, leaving both visible.
book.MustReverse(entryID, now, "wrong account")
This is why history is trustworthy here and would not be if entries were mutable. An auditor reading the journal sees the error AND the correction, which is the entire point of keeping a journal rather than a balance. Reverse refuses a second reversal of the same entry, so a correction cannot be looped.
Periods
Close(through) seals everything dated at or before `through`. It only ever moves forward, and after it Post refuses any entry dated into the sealed range — the backdating that makes a closed period meaningless. Reverse is refused there too: correcting a sealed period means opening a new entry in the current one, which is the consumer's call to make explicitly.
What "balanced" is actually worth
Read this before quoting IsBalanced as a guarantee.
IsBalanced compares TWO INDEPENDENTLY MAINTAINED ACCUMULATORS: the per-account debit/credit totals, summed across the chart, against the Book's own running totals. They are written by different code paths, so a drift between them is a real defect and IsBalanced finds it. What it does NOT do is re-read the journal, so a defect that corrupted both accumulators identically would survive it.
RederiveFromJournal is the strong check: it replays every posting of every entry and compares the result against both accumulators. It costs O(all postings ever) and exists to be run deliberately, not on every read.
AccountingEquation is WEAKER THAN IT LOOKS and is documented as such rather than left for a reader to assume otherwise: under this package's sign conventions it is implied by IsBalanced, and its boolean proves EXACTLY ONE of IsBalanced's three conjuncts. It is NOT an independent accounting fact. Three checks that are really two, presented as three, would be the defect this package exists to avoid.
This doc said "it is a cross-check of this package's own normal-balance handling" until 2026-10-07, and that sentence was FALSE — the one place the careful disclaimer around it still overclaimed. The boolean is the sum of every account's RawDelta, bucketed by Kind and then added back together, so the bucketing cannot change it: offsets is INVARIANT under every possible Kind misclassification, which is the one thing "normal-balance handling" means. See obligation 7 below, and AccountingEquation's own doc for what it does prove.
And the ceiling on all three: a Book can be internally perfect and describe nothing. See obligation 1 below.
State growth
Entries are never removed, because an append-only journal whose entries could vanish is not a journal. Storage therefore grows with every Post and is bounded only by maxEntries, set at construction. A Book that reaches that cap refuses further entries rather than silently dropping history — a long-lived consumer must plan for this, and the plan is a new Book with opening balances carried forward, not a pruned one.
Consumer contract (the parts the package cannot enforce)
- THE BOOKS ARE CLAIMS BY WHOEVER POSTED THEM. This package proves arithmetic, never truth. With no custody there is no solvency invariant tying "Cash 5000" to 5000 real ugnot anywhere. A realm that presents a journal balance as a holding is asserting something this package did not check. This is the single most important obligation and the one most commonly got wrong.
- SUPPLY A REAL CLOCK. journal cannot verify that `at` came from runtime.ChainHeight() or time.Now().Unix(). A realm that lets a caller choose `at` has no period discipline at all: the caller dates entries wherever they are convenient. Pass the chain's clock, never a transaction parameter.
- USE ONE CLOCK CONSISTENTLY. Heights and seconds must not be mixed within a Book; `at` and `through` are both in the caller's unit.
- DO NOT EXPORT THE BOOK. A *Book is a mutable handle. Returning one across a realm boundary hands out the right to open accounts, post and reverse. Expose your own crossing functions instead; this package returns Account and Entry VALUES, never pointers into its state.
- AUTHORIZE THE POSTER. journal authenticates nobody. Who may open an account, who may post, and who may close a period are the realm's policy — derive the caller from cur.Previous().Address(), not from an argument.
- CHOOSE THE UNIT ONCE. Amounts are int64 in whatever minor unit the consumer picks. journal does not know about denominations and will happily balance an entry that mixes two of them.
- GET THE KIND RIGHT AT OPEN TIME. NO CHECK IN THIS PACKAGE VALIDATES AN ACCOUNT'S KIND. A Kind is accepted if it is one of the five and is never revisited, so an account opened Asset that should have been Liability reports a sign-flipped Balance() forever — and IsBalanced, RederiveFromJournal and AccountingEquation all three stay healthy, because every one of them is built on Debits and Credits, which the Kind does not touch. This is the invariant tiers' blind spot, it is structural rather than an omission to be fixed later, and a realm that lets a caller choose the Kind has delegated a decision no later check can audit.
All failures are returned as errors and leave the Book COMPLETELY UNCHANGED. Must* wrappers are the only functions here that panic.
The Book is address-agnostic: account codes are non-empty strings. Realms normally use a chart-of-accounts code, not an address.
Imports
errorsgno.land/p/nt/avl/v0strconv
Constants and variables
MaxAccountsLimit is the largest maxAccounts a Book may be constructed with. It bounds the cost of IsBalanced and AccountingEquation, which scan the chart of accounts.
const MaxAccountsLimit = 10000
MaxCodeLen bounds an account code.
const MaxCodeLen = 64
MaxEntriesLimit is the largest maxEntries a Book may be constructed with.
const MaxEntriesLimit = 1000000
MaxMemoLen bounds an entry memo. The memo is opaque to journal — it exists so a realm can record why an entry was made without keeping a parallel table.
const MaxMemoLen = 1024
MaxNameLen bounds an account's human-readable name.
const MaxNameLen = 256
MaxPostingsPerEntry bounds a single entry's posting count. Two is the minimum a balanced entry can have; the ceiling keeps one Post bounded.
const MaxPostingsPerEntry = 64
Errors returned by Book operations.
var (
ErrEmptyCode = errors.New("journal: empty account code")
ErrCodeTooLong = errors.New("journal: account code exceeds MaxCodeLen")
ErrNameTooLong = errors.New("journal: account name exceeds MaxNameLen")
ErrMemoTooLong = errors.New("journal: memo exceeds MaxMemoLen")
ErrInvalidKind = errors.New("journal: unknown account kind")
ErrInvalidSide = errors.New("journal: posting side must be Debit or Credit")
ErrInvalidAmount = errors.New("journal: posting amount must be positive")
ErrInvalidAt = errors.New("journal: at must be non-negative")
ErrInvalidConfig = errors.New("journal: invalid book configuration")
ErrAccountExists = errors.New("journal: account code already open")
ErrUnknownAccount = errors.New("journal: no such account")
ErrChartFull = errors.New("journal: account cap reached")
ErrBookFull = errors.New("journal: entry cap reached")
ErrTooFewPostings = errors.New("journal: an entry needs at least two postings")
ErrTooManyPostings = errors.New("journal: entry exceeds MaxPostingsPerEntry")
ErrNoDebit = errors.New("journal: entry has no debit posting")
ErrNoCredit = errors.New("journal: entry has no credit posting")
ErrDebitShort = errors.New("journal: entry does not balance, debits are short")
ErrCreditShort = errors.New("journal: entry does not balance, credits are short")
ErrEntryNotFound = errors.New("journal: no such entry")
ErrAlreadyReversed = errors.New("journal: entry has already been reversed")
ErrPeriodClosed = errors.New("journal: at falls in a closed period")
ErrCloseNotForward = errors.New("journal: close must move forward")
ErrOverflow = errors.New("journal: int64 overflow")
ErrIDExhausted = errors.New("journal: identifier space exhausted")
)
The five account kinds. Asset and Expense are debit-normal; Liability, Equity and Revenue are credit-normal.
const (
Asset Kind = 1
Liability Kind = 2
Equity Kind = 3
Revenue Kind = 4
Expense Kind = 5
)
The two posting sides.
const (
Debit Side = 1
Credit Side = 2
)
Types
type Account
type Account struct {
Code string
Name string
Kind Kind
Debits int64
Credits int64
}
Account is one line of the chart of accounts, carrying its cumulative debit and credit totals. It is returned BY VALUE: holders cannot reach into a Book through it.
Account.Balance
func (a Account) Balance() int64
Balance returns the account's balance in its NORMAL direction: for a debit-normal account Debits-Credits, for a credit-normal account Credits-Debits. A normal account therefore has a non-negative balance, and a negative result is a real signal (a contra position) rather than a sign convention.
Account.RawDelta
func (a Account) RawDelta() int64
RawDelta returns Debits-Credits regardless of kind. Summed over every account in a balanced book it is exactly zero, which is what makes it the useful quantity for a consistency check and the wrong one for a report.
type Book
type Book struct {
maxAccounts int
maxEntries int
accounts *avl.Tree // code -> Account
entries *avl.Tree // padded id -> Entry
// reversed stores `true`, not a struct{} sentinel, and deliberately:
// avl.Tree.Get cannot tell a nil-valued key from an absent one, so a
// nil sentinel invites a future reader to switch from Has to Get and
// get a silently wrong answer. Membership is the whole value here —
// both readers use Has and the stored value is never read.
reversed *avl.Tree // padded id -> true, for entries already reversed
nextID uint64 // never decreases; entry IDs are never reused
closedThrough int64 // entries dated at or before this are refused
hasClosed bool // distinguishes "closed through 0" from "never closed"
totalDebits int64 // running total, maintained independently of accounts
totalCredits int64
}
Book is the chart of accounts and the journal of one consuming realm. The zero value is not usable; construct with New.
Book.Account
func (b *Book) Account(code string) (Account, bool)
Account returns an account by code. The second result is false if no such account is open.
Book.AccountCount
func (b *Book) AccountCount() int
AccountCount returns how many accounts are open.
Book.AccountingEquation
func (b *Book) AccountingEquation() (debitNormal, creditNormal int64, offsets bool)
AccountingEquation returns the two halves of assets+expenses versus liabilities+equity+revenue, in RAW debit-minus-credit terms, and whether they offset.
IT IS WEAKER THAN IT LOOKS, and weaker than this doc used to claim.
What offsets ACTUALLY proves: sum(RawDelta) == 0 over the whole chart, which is the same fact as IsBalanced's first conjunct (d == c) and nothing more. IsBalanced requires three. So this is implied by IsBalanced, is NOT an independent accounting fact, and citing it as a second guarantee alongside IsBalanced is counting one check twice.
What offsets DOES NOT prove, despite the Kind partition sitting right there in the code: anything whatsoever about normal-balance handling. dn and cn are a partition of the same addends, so dn+cn is the total regardless of which bucket each account landed in. Reclassify every account in the chart and BOTH subtotals change while offsets stays true. A Kind set wrongly at Open time is undetectable here and undetectable anywhere else in this package — obligation 7.
It is exported because the two SUBTOTALS are correct and a consumer presenting a balance sheet wants them anyway. They are the product; offsets is a duplicate of a check you already have.
Book.Balance
func (b *Book) Balance(code string) (int64, bool)
Balance returns an account's balance in its normal direction, and whether the account exists. A missing account returns (0, false) rather than a zero balance, because "no such account" and "balance zero" are different answers and a consumer branching on the number alone would conflate them.
Book.Close
func (b *Book) Close(through int64) error
Close seals the period ending at `through`, after which Post and Reverse refuse any entry dated at or before it.
It only moves forward. A first close may name any non-negative instant; a later one must name a strictly greater instant, so a seal can never be loosened and backdating can never be re-enabled.
Fails with ErrInvalidAt (through < 0) or ErrCloseNotForward. On error nothing is modified.
Book.ClosedThrough
func (b *Book) ClosedThrough() (int64, bool)
ClosedThrough returns the period seal: entries dated at or before it are refused. The second result is false if the Book has never been closed, in which case the first is meaningless — a Book closed through 0 and a Book never closed differ, and collapsing them would silently reject entries dated at 0.
Book.Entries
func (b *Book) Entries(from uint64, limit int) []Entry
Entries returns up to limit entries with ID >= from, in ascending ID order, each with a defensive copy of its postings. A limit <= 0 returns nothing. It is the paging read for a consumer that cannot hold the whole journal in one call.
Book.Entry
func (b *Book) Entry(id uint64) (Entry, bool)
Entry returns an entry by ID, with a defensive copy of its postings. The second result is false if no such entry exists — which, because entries are never removed, means it was never created.
Book.EntryCount
func (b *Book) EntryCount() int
EntryCount returns how many entries the journal holds. It never decreases.
Book.IsBalanced
func (b *Book) IsBalanced() bool
IsBalanced reports whether the Book's two independently-maintained accumulators agree with each other and with themselves.
It scans the chart, sums every account's debit and credit totals, and requires that the two sums are equal AND that each matches the Book's own running total. Those totals are written by different code in Post, so a disagreement is a defect in this package and IsBalanced is how a consumer detects one.
What it does not do is re-read the journal. Read the package doc's "What balanced is actually worth" before quoting it, and use RederiveFromJournal for the strong claim.
An empty Book is balanced.
Book.IsReversed
func (b *Book) IsReversed(id uint64) bool
IsReversed reports whether an entry has already been reversed. It returns false for an entry that does not exist, so pair it with Entry when the distinction matters.
Book.IterateAccounts
func (b *Book) IterateAccounts(fn func(Account) bool)
IterateAccounts calls fn for every account in ascending code order. Iteration stops early when fn returns true.
fn receives a COPY: Account is passed by value and holds only scalars and strings, so fn gets no pointer into the Book.
Two rules for fn, and the second is the one that is easy to miss.
- Do not Open, Post, Reverse or Close from inside fn: mutating a tree while iterating it is undefined. Collect what you need first, then act after IterateAccounts returns.
- fn runs under the CALLING REALM'S STORAGE AUTHORITY. This method's receiver is stamped with the importing realm's PkgID, so the borrow rules leave the realm context set to that realm for the whole callback, and a top-level fn has no receiver and no declaring realm to anchor it elsewhere. A callback that re-enters the calling realm's own mutators therefore does so with that realm's authority. Never pass a caller-supplied function here from inside a permission-gated path; pass only a closure this package's consumer wrote itself.
Book.IterateEntries
func (b *Book) IterateEntries(fn func(Entry) bool)
IterateEntries calls fn for every entry in ascending ID order, which for an append-only journal is also chronological by insertion. Iteration stops early when fn returns true.
fn receives an Entry whose Postings slice is a DEFENSIVE COPY, so a callback cannot write through it into Book state. The two rules on IterateAccounts's fn apply here unchanged, including the storage-authority one.
Book.MaxAccounts
func (b *Book) MaxAccounts() int
MaxAccounts returns the Book's cap on chart size.
Book.MaxEntries
func (b *Book) MaxEntries() int
MaxEntries returns the Book's lifetime cap on entries.
Book.MustClose
func (b *Book) MustClose(through int64)
MustClose is Close but panics on error.
Book.MustOpen
func (b *Book) MustOpen(code, name string, kind Kind)
MustOpen is Open but panics on error.
Book.MustPost
func (b *Book) MustPost(at int64, memo string, postings []Posting) uint64
MustPost is Post but panics on error.
Book.MustReverse
func (b *Book) MustReverse(id uint64, at int64, memo string) uint64
MustReverse is Reverse but panics on error.
Book.NextID
func (b *Book) NextID() uint64
NextID returns the identifier the next entry will take. It only ever increases, so an entry ID identifies one entry for the life of the Book.
Book.Open
func (b *Book) Open(code, name string, kind Kind) error
Open adds an account to the chart. Codes are unique and immutable: there is no Rename and no Close, because an account's cumulative totals are part of the derivation of every balance that cites it, and removing one would make the journal unreplayable.
Fails with ErrEmptyCode, ErrCodeTooLong, ErrNameTooLong, ErrInvalidKind, ErrAccountExists, or ErrChartFull. On error nothing is modified.
Book.Post
func (b *Book) Post(at int64, memo string, postings []Posting) (uint64, error)
Post appends a balanced entry and returns its ID.
The postings must number at least two and at most MaxPostingsPerEntry, each naming an open account, a valid Side, and a positive Amount. The entry must contain at least one debit and at least one credit, and the two column totals must be equal — an inequality is reported as ErrDebitShort or ErrCreditShort, naming the column that needs more.
Fails with ErrInvalidAt, ErrMemoTooLong, ErrPeriodClosed, ErrBookFull, ErrIDExhausted, ErrTooFewPostings, ErrTooManyPostings, ErrInvalidSide, ErrInvalidAmount, ErrUnknownAccount, ErrNoDebit, ErrNoCredit, ErrDebitShort, ErrCreditShort, or ErrOverflow. On error nothing is modified — every posting is validated and every total computed before the first write.
Book.RederiveFromJournal
func (b *Book) RederiveFromJournal() (debits, credits int64, agrees bool)
RederiveFromJournal replays every posting of every entry and reports the debit and credit totals it finds, together with whether they agree with both the per-account accumulators and the Book's running totals.
This is the strong check and the expensive one: its cost is proportional to every posting ever made, so it is meant to be called deliberately — by an auditor, or by a consumer's own invariant view — and not from a hot path. `agrees` false on a Book this package built is a defect in this package.
Book.Reverse
func (b *Book) Reverse(id uint64, at int64, memo string) (uint64, error)
Reverse appends the mirror of an existing entry — every posting repeated on the opposite side, for the same amount — and returns the new entry's ID. The original is left exactly as it was.
An entry may be reversed at most once. Reversing a reversal IS permitted (it re-applies the original's effect) and is itself subject to the same once-only rule, so a correction cannot be looped.
`at` dates the correction and must be in the open period: a sealed period cannot be corrected in place, by construction. Correcting one means posting into the current period, which is the consumer's decision to make explicitly rather than this package's to make silently.
Fails with ErrInvalidAt, ErrMemoTooLong, ErrPeriodClosed, ErrEntryNotFound, ErrAlreadyReversed, ErrBookFull, ErrIDExhausted, or ErrOverflow. On error nothing is modified.
Book.TotalCredits
func (b *Book) TotalCredits() int64
TotalCredits returns the Book's running credit total across every entry.
Book.TotalDebits
func (b *Book) TotalDebits() int64
TotalDebits returns the Book's running debit total across every entry.
type Entry
type Entry struct {
ID uint64
At int64
Memo string
Postings []Posting
ReversalOf uint64
}
Entry is one journal entry: a dated, memo'd, balanced set of postings. It is returned BY VALUE, and its Postings slice is a DEFENSIVE COPY — a holder can mutate the returned slice without touching the Book. Entry is the one type here that contains a slice, so it is the one type where returning the stored value directly would have handed out a writable window into Book state.
ReversalOf is 0 unless this entry was produced by Reverse, in which case it is the ID of the entry it mirrors.
Entry.IsReversal
func (e Entry) IsReversal() bool
IsReversal reports whether this entry was produced by Reverse.
Entry.Total
func (e Entry) Total() int64
Total returns the entry's debit total, which for any entry a Book accepted equals its credit total.
type Kind
type Kind uint8
Kind is an account's classification, which fixes its normal balance. The zero value is deliberately invalid so a forgotten field is an error rather than an Asset.
Kind.IsDebitNormal
func (k Kind) IsDebitNormal() bool
IsDebitNormal reports whether an increase in k is recorded as a debit. Asset and Expense are debit-normal; the other three are credit-normal. An invalid Kind reports false, which is why callers validate first.
Kind.String
func (k Kind) String() string
String returns the kind's name, or "invalid" for the zero value and any unrecognised code.
Kind.Valid
func (k Kind) Valid() bool
Valid reports whether k is one of the five kinds.
type Posting
type Posting struct {
Account string
Side Side
Amount int64
}
Posting is one leg of an entry: an amount in one column of one account. Amount is always POSITIVE — direction is carried by Side, not by a sign.
That is a deliberate choice over signed amounts. With a sign, "which side is short" is unanswerable without knowing each account's normal balance, so an unbalanced entry can only be reported as a number that does not add up. With an explicit Side the refusal names the short column, which is what a bookkeeper needs in order to fix it.
type Side
type Side uint8
Side is which column a posting lands in. The zero value is deliberately invalid: a posting with a forgotten Side must be refused, not silently treated as a debit.
Side.Opposite
func (s Side) Opposite() Side
Opposite returns the other side. An invalid Side returns itself, so a caller that skipped validation gets an invalid posting rather than a plausible one.
Side.String
func (s Side) String() string
String returns "debit", "credit", or "invalid".
Side.Valid
func (s Side) Valid() bool
Valid reports whether s is Debit or Credit.
Functions
MustNew
func MustNew(maxAccounts, maxEntries int) *Book
MustNew is New but panics on error.
New
func New(maxAccounts, maxEntries int) (*Book, error)
New returns an empty Book.
maxAccounts caps the chart and must be in [1, MaxAccountsLimit]. maxEntries caps the journal and must be in [1, MaxEntriesLimit]; because entries are never removed, it is a lifetime cap rather than a concurrent one, and a Book that reaches it is finished.
Doc text is reproduced as vm/qdoc returns it. The node markdown-escapes doc comments, so a bracket or angle bracket may carry a backslash the committed source does not have. The source itself is at source and in this repository.
Dependencies
A first-party dependency is imported from the SAME chain's namespace, so each deployment above depends on that chain's copy — the paths differ even where the code does not.
chain-attested| Imports | errors, gno.land/p/nt/avl/v0, strconv |
|---|---|
| First-party dependencies | none |
Known limitations
Recorded by the people who built and deployed it. This list is deliberately not empty where honesty costs something.
curatedgno.land mainnet
- No transaction enters a /p/: the gnoland-1 commit-proof arrives with the first consumer (the accounting-suite realms are staged candidates, each its own section-8 selection). The onyx twin's coverage transfers at byte-exact strength (zero substitutions).
- J1 (audit 2026-11-04): Post/Reverse/Close take a caller-supplied clock -- a realm forwarding caller input as `at` guts the period discipline (RED in any realm that does; none does, verified across all five live accounting realms). Documented consumer obligation 2; recorded here because the live bytes cannot change.
onyx-1 testnet
- NOTHING CONSUMES THIS PACKAGE AND NO PROBE CAN CHANGE THAT. consumed_by is empty and that is the honest state. A /p/ package declares no crossing functions, so MsgCall has nothing to dispatch to and there is no transaction and no vm/qeval that executes this code without a consuming realm. onyx/r/books is written, audited and sabotage-covered against these exact bytes but is NOT deployed, so every behavioural claim here rests on the 56 unit tests (82 including subtests) and the 99 sabotage cases run locally under the pinned onyx toolchain, with the vm/qfile byte-match transferring that local evidence to this chain. Group 8's spendbook record and its 2026-10-05 amendment are the precedent for how this changes once a consumer lands -- and for the fact that a limitation written as permanent can expire in a day. Group 6's and Group 7's live-lifecycle evidence does NOT read across; those are different packages. CORRECTION, 2026-10-11, ONE DAY LATER AND KEPT VERBATIM ABOVE RATHER THAN REPLACED: the first sentence is now FALSE. onyx/r/books is live at height 328091 and consumed_by reads ['books'], written by tools/sync_consumed_by.py from the CHAIN's import graph and not by hand. The second clause -- 'and no probe can change that' -- was the part that was wrong, and it was wrong for a precise reason worth keeping: no probe OF THIS PACKAGE can change it, which is true and is not what the sentence said. What changed it was deploying a consumer, and the consumer's KindOf('1000') probe returns the lowercase 'asset' that only journal.Kind.String() produces, so this package's bytes are now PROVEN to execute on onyx-1 rather than merely proven to be stored there. The behavioural claims that still rest on the 56 unit tests and 99 sabotage cases are the ones r/books does not reach: nothing has posted an entry on chain, because no book has been opened. This bullet predicted its own expiry 'within a day' and that is exactly how long it took -- the lesson is not that the prediction was good but that a limitation phrased as structural ('no probe can') when it is actually contingent ('nothing consumes it yet') outlives its truth and gets quoted after it stops being true.
- NO CHECK IN THIS PACKAGE VALIDATES AN ACCOUNT'S KIND, and this is structural rather than an omission to be fixed later. A Kind is accepted if it is one of the five and is never revisited, so an account opened Asset that should have been Liability reports a sign-flipped Balance() forever -- while IsBalanced, RederiveFromJournal and AccountingEquation all three stay healthy, because every one of them is built on Debits and Credits, which the Kind does not touch. It is the invariant tiers' blind spot, stated in-source as consumer-contract item 7, and a realm that lets a caller choose the Kind has delegated a decision no later check can audit. TestAccountingEquationIsBlindToAKindMisclassification pins the blindness as observed behaviour rather than leaving it as a claim.
- AccountingEquation IS WEAKER THAN IT LOOKS and its own doc said something FALSE until 2026-10-07. What `offsets` proves is sum(RawDelta) == 0 over the chart, which is IsBalanced's first conjunct and nothing more -- so it is implied by IsBalanced, is NOT an independent accounting fact, and citing it as a second guarantee alongside IsBalanced is counting one check twice. It proves nothing whatsoever about normal-balance handling despite the Kind partition sitting in the code: dn and cn are a partition of the same addends, so reclassifying every account changes both subtotals while offsets stays true. The two SUBTOTALS are the product; the flag is a duplicate. Likewise IsBalanced does not re-read the journal, so a defect corrupting both accumulators identically would survive it -- RederiveFromJournal is the strong check and costs O(all postings ever).
- THE BOOKS ARE CLAIMS BY WHOEVER POSTED THEM; this package proves arithmetic, never truth. With no custody there is no solvency invariant tying 'Cash 5000' to 5000 real ugnot anywhere, so a realm that presents a journal balance as a holding is asserting something this package did not check. Stated in-source as consumer-contract item 1 and flagged there as the single most important obligation and the one most commonly got wrong.
- THE CLOCK IS CALLER-SUPPLIED AND UNVERIFIABLE HERE. journal cannot tell whether `at` came from runtime.ChainHeight() or time.Now().Unix(), so a realm that lets a caller choose `at` has no period discipline at all -- the caller dates entries wherever convenient, which empties Close() of meaning. Heights and seconds must also not be mixed within one Book. Consumer-contract items 2 and 3. Authorization is likewise absent: journal authenticates nobody, and who may open, post or close is the realm's policy, derived from cur.Previous().Address() and never from an argument (item 5).
- A *Book IS A MUTABLE HANDLE and must not cross a realm boundary. Returning one hands out the right to open accounts, post and reverse. The package itself returns Account and Entry VALUES and never pointers into its state, and Entry's Postings slice is a defensive copy on every exported path -- but it cannot stop a consumer from exporting the handle. Consumer-contract item 4.
- THE TWO ITERATORS RUN fn UNDER THE CALLING REALM'S STORAGE AUTHORITY. The receiver is stamped with the importing realm's PkgID, so borrow rule #2 leaves the realm context set to that realm for the whole callback, and a top-level fn has no receiver to anchor it elsewhere. A callback that re-enters the calling realm's own mutators does so with that realm's authority, so a caller-supplied function must never be passed here from inside a permission-gated path. Documented on IterateAccounts in-source, together with the separate rule that fn must not mutate the tree it is iterating.
- STORAGE GROWS MONOTONICALLY AND maxEntries IS A LIFETIME CAP, NOT A CONCURRENT ONE. Entries are never removed, because an append-only journal whose entries could vanish is not a journal, so a Book that reaches its cap is finished -- it refuses further entries rather than dropping history, and the plan a long-lived consumer needs is a new Book with opening balances carried forward, not a pruned one. Amounts are int64 in whatever minor unit the consumer picks; journal does not know about denominations and will happily balance an entry that mixes two of them (item 6).
- THE CLOSURE VERDICT IS THIN AND THE TOOLCHAIN IS SEALED, NOT ATTESTED. Gates A and B are both EQUIVALENT over exactly ONE compared chain dependency, gno.land/p/nt/avl/v0, digest efa7dbbf...c02391b5 on both sides. That is not zero -- a zero-dep EQUIVALENT means 'no chain dependencies at all' and this is not that -- but it is one package and nothing first-party. The residual differs between the gates and the verdict does not: 17 stdlib packages under --include-tests against 6 without. Stdlibs execute from the node binary and are not comparable, so equivalence for them rests on the sealed onyx GNOROOT matching the node's build, which the node does not expose. Per CLAUDE.md 7a the pin proves only that nothing has changed since it was written, not that it was right when written.
- simulate=true ON onyx-1 DOES NOT TYPE-CHECK (the chain's inert code-submission policy), so the dry run before this broadcast was a gas/fee/authorization probe only. The correctness substitute is `gno lint .` against the onyx release, rc=0 on exactly these bytes -- but it was run AFTER the broadcast, not before it, and the pre-broadcast gate set did not include it. The lint result is therefore a property of the deployed bytes rather than a gate the deploy cleared.
- THE STORAGE DEPOSIT IS BALANCE-DERIVED, NOT CHAIN-ATTESTED. onyx-1 emits no StorageDepositEvent (storage_deposit_events: false in this stanza; re-confirmed on this tx, whose DeliverTx log reads events:[]), so storage_bytes and storage_deposit are null here as on every onyx-1 entry. A pre-broadcast and a post-broadcast gno_account read bracket the cost -- 22,165,152 then 18,453,370 ugnot, both chain_id_bound: true -- which with the exactly-reproduced fee leaves a 3,569,000 ugnot residual. That residual is an inference from two balances, not a figure the chain attested, and it is recorded in [an unpublished record] Group 10 as such.
- THE 7 ECOSYSTEM-ABSENCE PHRASING IS USED, AND IT IS NOT A UNIQUENESS CLAIM. [an unpublished record] records 'No relevant existing implementation was found in the searched sources' and says explicitly that the sources are finite so no uniqueness claim is made. It also records the honest counter-argument against the package's own central framing rather than leaving it for a reviewer.
Source
| Source file (gnoland-1) | mainnet/p/journal/journal.gno at commit c2e10d17ffbf in the project repository (not public — the digest command above is the check that needs no repository) |
|---|---|
| Matches the deployed bytes (gnoland-1) | yes — byte-identical |
| Source file (onyx-1) | onyx/p/journal/journal.gno at commit c2e10d17ffbf in the project repository (not public — the digest command above is the check that needs no repository) |
| Matches the deployed bytes (onyx-1) | yes — byte-identical |