Zerobook

A tamper-evident, double-entry ledger for Elixir. Pure Elixir, zero dependencies.

Every transaction must balance to zero in every currency, and transactions are chained with SHA-256 links — so any edit to a past transaction, or any reordering or deletion of a block, is detected when you verify the chain.

Features

Installation

Add zerobook to your dependencies in mix.exs:

def deps do
[
{:zerobook, "~> 0.1.0"}
]
end

Usage

alias Zerobook.{Ledger, Transaction, Entry}
ledger =
Ledger.new()
|> Ledger.append!(Transaction.new("txn_1", [
Entry.new("1000.cash", 10_000, "BRL", "deposit"),
Entry.new("2000.revenue", -10_000, "BRL")
]))
|> Ledger.append!(Transaction.new("txn_2", [
Entry.new("3000.expense", 2_500, "BRL", "hosting"),
Entry.new("1000.cash", -2_500, "BRL")
]))
Ledger.verify!(ledger) # => :ok
Ledger.balance_of(ledger, "1000.cash", "BRL") # => 7500

Sign convention: a positive amount debits an account (money flows in), a negative amount credits it (money flows out). Keep it consistent and use the smallest unit of your currency (cents for BRL/USD, attos for crypto, plain integers for credits/points).

Error handling

Ledger.append/2 returns tagged tuples instead of raising:

case Ledger.append(ledger, tx) do
{:ok, ledger} -> ledger
{:error, {:unbalanced, currency, total}} -> # transaction did not sum to zero
{:error, {:duplicate_transaction, id}} -> # id already in the chain
end

Verifying the chain

Ledger.verify/1 returns :ok or an error pinpointing the first broken block (0-based position in the chain):

{:error, {:bad_hash, 3}} # block 3 was edited after being written
{:error, {:broken_link, 1}} # block 1 does not point to block 0's hash
{:error, {:unbalanced_tx, 2}} # a transaction no longer balances

Queries

Ledger.balance_of(ledger, "1000.cash", "BRL") # running balance
Ledger.statements(ledger, "1000.cash") # [{txn, entry}, ...]
Ledger.blocks(ledger) # full chain, oldest first
Ledger.last_block(ledger) # latest block or nil

Persistence

Ledger and block structs are plain serializable data:

block = Ledger.last_block(ledger)
%Zerobook.Block{
index: 1,
timestamp: ~U[2026-09-03 12:00:00Z],
transaction: %Zerobook.Transaction{...},
prev_hash: <<...>>, # SHA-256 of the previous block
hash: <<...>> # SHA-256 of this block's content
}

Store the blocks (e.g. as JSON in Postgres or rows in SQLite) and replay them into a fresh ledger on startup.

Design notes

License

MIT — see LICENSE.