ENGINEERING NOTES / FINTECH ENGINEERING

Double-Entry Ledger Design for Wallets: Seven Invariants and How We Verify Them

The seven invariants a wallet ledger should check, what the write path must enforce first, and the test and load-audit evidence that they hold.

Double-Entry Ledger Design for Wallets: Seven Invariants and How We Verify Them

A double-entry ledger for a wallet is correct when a small set of invariants holds at every moment: every posting sums to zero per currency, every stored balance equals the sum of its entries, and no account sits below the floor its rules allow. Design the write path so it cannot break those rules, then run an independent verifier that re-derives them from the raw entries and fails loudly when they drift.

This is how the ledger behind WalletD does it. We make WalletD, so read this as one team’s design with the evidence attached, not a neutral survey. I read everything below from the ledgerd source at commit acf0cba and checked it on 8 November 2026.

The short answer

  • Store money as integers in minor units, one signed convention for every account, and refuse any posting whose legs do not sum to zero per commodity.
  • Keep entries append-only. Balances may be cached on the account row, but entries are the truth and a verifier must be able to rebuild every balance from them.
  • Check seven invariants in one consistent snapshot: zero-sum entries, balance equals entry sum, the float equation, floors (counting holds), an empty overflow partition, closed accounts at zero, and checkpoints that still match history.
  • Run the verifier on a schedule and as a command that exits non-zero, so a deploy gate or an alert can act on it.
  • Prove it under concurrency, not only in unit tests: retry storms, duplicate keys and a load test with an audit that does not trust the application.

What makes a ledger “double-entry”?

A double-entry ledger records every movement as two or more legs that cancel out. An operation never creates or destroys money. It moves money between accounts, so the books as a whole always net to zero.

Textbooks split accounts into debit-normal and credit-normal, which forces every balance read to know the account type. Our ledger uses one signed convention instead, recorded in ADR-0007: credit is +1, debit is -1, and an account’s balance is the sum of direction × amount over its entries. Liability accounts such as a user’s cash run positive. System accounts such as treasury, which mirrors real money that entered, run negative. Whether a floor applies is a per-account flag, enforce_floor, not a property of the account type.

That makes the float equation a one-line check: for each commodity, every account balance in a ledger sums to zero.

Three more rules sit underneath all of this:

  1. Integer minor units. $25.00 is 2500. No floats anywhere in the API or the database.
  2. A ceiling on every leg. MaxAmount is 10^14 minor units, because fee maths multiplies amounts by basis points and an unbounded int64 could overflow before validation catches it.
  3. Commodities do not mix in one balance check. Points and cash are separate commodities. A conversion between them is an exchange group: linked postings, each zero-sum in its own commodity, with the rate stored on the transaction.

Which invariants should a wallet ledger check?

Check the ones that, if broken, mean money was created, lost or hidden, and the ones that quietly corrupt reports. Our verifier, CheckInvariants in internal/ledger/invariants.go, runs seven. The table below comes straight from that file:

# Violation kind What it asserts What a failure means
1 entries_not_zero_sum Per commodity, SUM(direction × amount) over all entries is 0 Money was created or destroyed
2 balance_mismatch Every account’s stored balance equals the sum of its own entries The cached balance drifted from history
3 float_equation Per commodity, all account balances sum to 0 The books do not tie out as a whole
4 floor_breach For floor-enforced accounts, balance − active holds ≥ −credit_limit Someone spent money they did not have, or that was already reserved
5 partition_overflow The DEFAULT (overflow) partition of entries is empty Monthly partition creation fell behind; nothing is lost, but it must be fixed
6 closed_nonzero Every closed account holds exactly 0 Value moved around the account lifecycle rules
7 checkpoint_drift Every period-close checkpoint equals the recomputed sum of entries effective at or before the period end Statements, balance-as-of and trial balances built on that checkpoint are wrong

Two of these need a closer look.

The floor check counts holds. An earlier version inside the walletd monolith ignored active reservations. That made the verifier weaker than the write path it was meant to check, because a balance drained under an active hold looked healthy. The current check subtracts the sum of pending reservations, as the posting path does. TestFloorBreachCountsPendings reserves 800 of a 1,000 balance, forces the balance to 500 behind the ledger’s back, and expects a floor_breach.

Checkpoint drift is easy to miss. Period close writes a balance per account into an append-only checkpoints table, and statements, balance-as-of and trial balances all start from those checkpoints. If one disagreed with the entries beneath it, every report built on it would stay wrong and no other check would fire. The verifier recomputes each checkpoint from entries by effective date and compares.

What must the write path enforce before the verifier ever runs?

The verifier is the second line of defence. The first is a posting path that refuses bad data, so the invariants hold by construction:

  • Validation before any lock. A posting needs at least two legs, every amount positive and at most 10^14, every direction credit or debit, and the legs must sum to zero per commodity. A failure returns ErrUnbalanced with the commodity and the amount it is off by.
  • Floors under the row lock. Accounts are locked in a deterministic order, then each floor-enforced account is checked against its balance, its active holds and its credit line.
  • Append-only by trigger. entries, transactions, ledger_events, exchanges, periods and checkpoints carry a trigger that raises ledger is append-only on any UPDATE or DELETE, whoever holds the connection. A reversal is a new mirror posting linked by reverses_txn_id, never an edit.
  • Closed periods refuse back-dated postings. Each transaction carries a caller-supplied effective_at next to an immutable created_at; a database trigger refuses any posting dated inside a closed period.
  • Idempotency in the same transaction as the posting. The key row and the entries commit together, so a retry cannot double-post. That mechanism gets its own post in this series.

The verifier has no per-transaction balance check. The write path enforces that on every posting, and the entries table cannot be edited afterwards, so a per-commodity global sum catches anything that slips through. The independent audit described below does check per-transaction balance, from a different codebase.

How do you run the checks without false alarms?

Run all seven inside one read-only transaction at REPEATABLE READ. PostgreSQL’s documentation says a repeatable read transaction sees “a snapshot as of the start of the first non-transaction-control statement in the transaction” (PostgreSQL 18 docs, transaction isolation). Without it, a posting that commits between check 2 and check 3 can make a clean ledger look broken for a moment, and an alert that cries wolf gets muted.

The verifier runs in two places:

  1. On a schedule. When ledgerd serve has a database, a background worker verifies every ledger at start and then every five minutes, logs LEDGER INVARIANT VIOLATED with the kind and detail, and sets a Prometheus gauge, ledgerd_invariant_violations{kind=...}. Any non-zero value is meant to page someone. (Whether a given deployment scrapes and alerts on that gauge is an operations decision, not something the code guarantees.)
  2. As a command. ledgerd verify (or ledgerd verify --ledger <id>) prints one line per ledger and exits non-zero on any violation, so a deploy pipeline or cron job can gate on it.

The command prints what it checked, because a check that says “ok” over an empty or wrong database is worse than no check. From internal/cli/verify.go, a clean line has this shape:

ledger <id> (<name>): ok (<n> accounts, <n> transactions, <n> entries, <n> pendings, <n> checkpoints)

A ledger with no entries prints ok (EMPTY: ...), and a database with no ledgers prints no ledgers found: nothing was verified instead of a silent pass. That shape is from the source. I did not run the command against a live database for this post.

What evidence shows the invariants hold?

There are three layers, each approaching the books from a different direction.

Tests that corrupt the books on purpose. We ran the relevant ledgerd suites on 8 November 2026 (commit acf0cba, Go 1.27.3, PostgreSQL 18 in a test container): 27 tests passed, including these:

--- PASS: TestChaosGate (5.52s)
--- PASS: TestInvariantsDetectCorruption (0.02s)
--- PASS: TestFloorBreachCountsPendings (0.02s)
--- PASS: TestPartitionOverflowIsFlagged (0.02s)
--- PASS: TestIdempotentConcurrentDuplicatesRunOnce (0.03s)
--- PASS: TestClosePeriodCheckpointsAndRefusesBackdating (0.03s)
ok  	github.com/alpha/ledgerd/internal/ledger	6.768s

TestInvariantsDetectCorruption adds 1 to a balance with a raw UPDATE and expects a violation. The chaos gate fires 1,000 concurrent mixed operations (top-ups, three-leg transfers with a fee, holds that are posted or voided) and, in the last 200, four concurrent callers per idempotency key across 50 keys. It then requires exactly one transaction per storm key, byte-identical replays, and clean invariants.

A load test with an audit that does not trust the application. On 26 August 2026 we drove authorize-and-capture payments through the API gateway on staging, on synthetic data, ramping from 400 to 1,600 payments per second, with 100% completion at every step from 600 upward. Afterwards a separate SQL audit, written outside the ledger code, recomputed the books: 0 conservation violations, 0 unbalanced transactions, 0 stored-versus-recomputed mismatches and 0 floor violations across 1,550,594 transactions, 3,101,188 entries and 81,184 accounts. The data, charts and hashes are public in walletd-io/ledger-proof, and the write-up is on the WalletD blog: we proved our ledger stays correct at 1,600 payments a second. That is a synthetic benchmark, not production traffic: WalletD is in early access and has not carried real customer money.

Two checks that agree. The load generator also watched the double-entry sum and floors live during the final ramp and recorded 0 violations and 0 double-spends over 623,564 money operations.

Design choices, compared

Choice Common alternative What this ledger does The trade-off
Amount type Decimal or float int64 minor units, max 10^14 per leg Display formatting moves to the edge
Sign convention Debit-normal and credit-normal accounts One signed convention plus an enforce_floor flag Readers must learn that treasury runs negative
Balance storage Compute from entries on every read Cached on the account row, verified against entries Needs invariant 2, and a verifier to run it
History Mutable rows, soft deletes Append-only triggers, reversals as linked mirror postings Corrections are always visible
Time One timestamp effective_at plus immutable created_at Period close and statements key on effective time
Tamper evidence Hash chain per entry Rejected in ADR-0021: append-only triggers and role separation instead Prevents edits rather than detecting them; revisit if a regulator requires detectability
Holds Status flag on a balance Two-phase pendings: reserve, then post or void, with a TTL One more object to sweep and monitor

The pending model is close to TigerBeetle’s two-phase transfers, which reserve funds and then post, void or expire them. TigerBeetle is a dedicated database; ours runs on PostgreSQL, so the books can be audited with plain SQL.

A checklist if you are building your own

  1. Pick the sign convention and the minor-unit rule before the first migration. Both are painful to change later.
  2. Make the posting function the only writer of entries, and put append-only triggers on the tables anyway.
  3. Write the verifier on day one, with a test that corrupts a balance and expects it to fire.
  4. Count holds in the floor check, or the verifier will be weaker than your write path.
  5. Run the checks in one repeatable-read snapshot, on a schedule, and print what was checked, not just “ok”.
  6. Before you quote a throughput number, audit the books after the run from raw entries. A fast ledger that loses a cent has produced a bug report, not a benchmark.

This is engineering guidance, not legal or compliance advice. Rules on holding customer funds, authorization windows and record keeping differ by country and licence, so check them with your counsel and regulator.

FAQ

How many invariants does a wallet ledger need? Our verifier checks seven. The minimum that catches created or lost money is the first three: zero-sum entries, balance equals entry sum, and the per-commodity float equation. Floors, overflow partition, closed accounts and checkpoints cover the rest.

Should balances be stored or computed? Either works. If you cache the balance on the account row, you need invariant 2 and a verifier that rebuilds it from entries.

Why integer minor units? $25.00 is 2500, so there is no rounding in the API or database. A per-leg cap (10^14 here) keeps fee maths from overflowing int64.

Can a load test prove correctness? Only if the books are audited afterwards from raw entries. Ours was a synthetic staging run, not production traffic.

Where WalletD fits

WalletD ships this ledger, ledgerd, as a separate service with its own database, plus the wallet money paths that post to it: top-ups, transfers, payments with holds, rewards, subscriptions, marketplace commission and payouts. It is in early access with design partners, publishes no pricing, and deployment (your infrastructure or an agreed cloud) is scoped with you.

If you are designing a wallet ledger, see the WalletD product page, or email hello@cloudguys.io with your invariants list and we will compare it with ours. The ledger and each money path are also described on walletd.io.

BUILT AND OPERATED BY CLOUDGUYS

WalletD: wallet infrastructure
Wallet infrastructure for business, on a ledger you can audit.

Merchant collections, balances, payments, rewards and payouts behind one API, with every movement posted to a separate double-entry ledger that reconciles by construction. Run it on your own infrastructure or in the cloud.

Visit WalletD ↗ About WalletD ↗
Prefer a text version?Read this page as Markdown · All pages