doubleentry 0.8.0

An immutable, tamper-evident double-entry bookkeeping engine: one pure rule core, exact integer money, identity derived from the idempotency key, and one append-only RFC 6962 Merkle log with inclusion, consistency and sealed-balance proofs and C2SP checkpoints, in memory (sync) or over SQLite and PostgreSQL (async).
Documentation

πŸ“’ doubleentry

Crates.io Docs.rs License MSRV

An immutable, tamper-evident double-entry bookkeeping engine.

A ledger is one entity's books, kept as one append-only RFC 6962 SHA-256 Merkle log of typed records: genesis, currencies, accounts, policies, periods, entries, clearings and seals. One pure function, the rule core, decides every write: it returns the records to append or one Rejection with a stable code. Two faΓ§ades call it with the same methods: Journal (sync, in memory) and Ledger<S> (async, over SQLite, PostgreSQL or your own Store).

Why: a ledger should not leave its invariants to the application, and it should be able to answer an auditor's question β€” how do I know this record was not changed afterwards? β€” with a proof instead of a promise.

πŸ“– Documentation Β· πŸ¦€ API reference Β· πŸ“‹ Changelog

πŸš€ Quick start

use doubleentry::prelude::*;

let mut books = Journal::new(LedgerId::new("example.com/acme")?);
books.declare_currency(EUR, 2, date!("2026-01-01"))?;
books.define_months(2026, 1..=12)?;
books.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01"))?;
books.open("Income:Sales", AccountKind::Income, date!("2026-01-01"))?;

let rec = books.record(
    Entry::new("invoice-2026-0001", date!("2026-03-15"))
        .debit("Assets:Cash", amount!("1190.00"), EUR)
        .credit("Income:Sales", amount!("1190.00"), EUR),
)?;
let inclusion = books.prove_entry(rec.id)?; // an RFC 6962 inclusion proof
assert_eq!((inclusion.index, inclusion.size), (rec.position, books.head().size));

// Seal March (and the months before it), then prove one closing balance.
let archived = books.head();
books.seal_through("2026-03")?;
let proof = books.prove_balance("Assets:Cash", EUR, Layer::Settled, "2026-03")?;
let proven = proof.verify(&books.head())?;
assert_eq!(proven.balance.net(), amount!("1190.00"));
books.prove_consistency(archived.size, books.head().size)?.verify(&archived, &books.head())?;
# Ok::<(), Box<dyn std::error::Error>>(())

The idempotency key ("invoice-2026-0001") is the entry's identity: recording the same draft again writes nothing and returns the original receipt with replayed == true.

Over SQLite (feature sqlite) the same calls are async:

# #[cfg(feature = "sqlite")]
# fn main() -> Result<(), Box<dyn std::error::Error>> {
# tokio::runtime::Builder::new_current_thread().enable_all().build()?.block_on(async {
use doubleentry::prelude::*;
use doubleentry::store::SqlStore;

let store = SqlStore::sqlite("sqlite::memory:").await?; // or "sqlite://books.db?mode=rwc"
let ledger = Ledger::new(store, LedgerId::new("example.com/acme")?).await?; // creates or opens
ledger.declare_currency(EUR, 2, date!("2026-01-01")).await?;
ledger.define_months(2026, 1..=12).await?;
ledger.open("Assets:Cash", AccountKind::Asset, date!("2026-01-01")).await?;
ledger.open("Income:Sales", AccountKind::Income, date!("2026-01-01")).await?;
ledger
    .record(
        Entry::new("invoice-2026-0001", date!("2026-03-15"))
            .debit("Assets:Cash", amount!("1190.00"), EUR)
            .credit("Income:Sales", amount!("1190.00"), EUR),
    )
    .await?;
ledger.audit().await?; // replays the log and compares every stored table
# Ok::<(), Box<dyn std::error::Error>>(())
# })
# }
# #[cfg(not(feature = "sqlite"))]
# fn main() {}

SqlStore::postgres(url) is the same for PostgreSQL. More in Getting started and the programs in examples/.

πŸ”’ Guarantees

Guarantee How
Every entry balances per (currency, layer) The rule core refuses an unbalanced draft inside record, in the store's write transaction; there is no other way to write an entry.
Exact money Amount<P> is an i128 scaled by 10^P. Checked arithmetic returning Result, no floats, explicit Rounding; excess decimals are refused.
Deterministic identity EntryId is SHA-256 over the ledger id and the idempotency key, so a retry replays instead of booking twice. The rule core reads no clock and no randomness.
Tamper evidence Every record is a leaf of one RFC 6962 tree. Inclusion and consistency proofs verify against a TreeHead; records are re-hashed on read (Error::Integrity).
Provable balances A seal commits, per (currency, layer), to a tree of account balances; prove_balance proves one, named, against a head alone.
Witnessable checkpoint signs a C2SP tlog-checkpoint; WitnessSession speaks tlog-witness to stock witnesses; Policy and Verifier check cosignatures. Tested against the Go reference implementations.
Full audit audit() replays every record through the rule core and compares every table the store keeps, row by row.
One behaviour Journal, MemoryStore, SQLite and PostgreSQL return the same Rejection for the same input, checked by a public conformance suite.

It does not prevent a database superuser from editing tables β€” it detects it: on read, in audit(), and, for a history rewritten from scratch, against a head someone else kept (an auditor's archive or a witness cosignature). There is no FX conversion, no chart of accounts, no HTTP client, no clock and no migrations. See Design.

πŸ“¦ Features

All off by default.

Feature Enables
serde Serialize / Deserialize on public types (transport only; hashing never uses serde)
sqlite SqlStore<Sqlite> (sqlx, bundled SQLite)
postgres SqlStore<Postgres> (sqlx, no TLS stack)
witness Ed25519 signing and verification for transparency
testing The store conformance suite and, with witness, TestWitness
tracing tracing spans; never an amount, memo or account path

For PostgreSQL over TLS, enable a TLS feature on your own sqlx dependency, e.g. sqlx = { version = "0.9", features = ["tls-rustls-aws-lc-rs"] }.

πŸ¦€ MSRV

Rust 1.94 (required by sqlx 0.9), declared as rust-version and checked in CI. It is never raised in a patch release, and only to a stable release at least six months old.

πŸ“„ License

Licensed under either of Apache-2.0 or MIT at your option.