We replaced our ledger with two functions - River
Skip to content
Menu
Announcements<br>Research<br>Newsletter<br>Media<br>Engineering
We replaced our ledger with two functions
We rebuilt the system that tracks every dollar and bitcoin at River and swapped it in live with zero downtime.
Vivian Mathews
in
Engineering
on
19 August 2026
ledger.ex
copy<br># read the truth<br>def get_balances(account)<br>:: {:ok, Balances.t()} | {:error, term()}
# change the truth by recording what happened<br>def record_event(event, balances)<br>:: {:ok, Balances.t()} | {:error, term()}
Every buy, sell, deposit, withdrawal, transfer, payout, reclaim, etc. goes through record_event
This post covers why we decided to redesign our ledger, what’s interesting about the new design, and how we leveraged it to perform a zero-downtime migration through our highest-volume trading days.
tl;dr
Double-Entry Event Sourcing: Records assets and liabilities as immutable events in an append-only system.
Narrow-Waist API: Reduces complexity by using only two functions supported by declarative, versioned rules.
Structural Correctness: Enforces accounting invariants at the schema layer plus a single Postgres CHECK constraint.
Shadow Mode Rollout: Dual-writing with automated parity checks ensured error-free account-by-account migration.
Reverse Migration: Launched the live system first and backfilled historical data afterwards to decouple failure modes.
Leveraged AI Responsibly: We used agents to build and improve our correctness and operational tooling. Shipped net fewer lines of code.
In theory
the ledger we had
River is a Bitcoin financial institution providing people and businesses with a safe, easy way to buy, sell, secure, and use bitcoin. We offer everyday banking services for bitcoin and dollars in one app.
When we designed our previous ledger, the company was operating at a much smaller scale. We offered basic Bitcoin brokerage features (buy, sell, send, receive) but did not have the full suite of banking services we offer today (bitcoin interest on cash, direct deposit, bill pay, etc.).
As our product suite grew, the ledger became increasingly complex, revealing several shortcomings:
An imperative API that bloated to ~40 functions.
Coupling of ledger accounting with business logic and fraud risk semantics (e.g. is your bitcoin withdrawable yet).
Insufficient granularity in tracking intermediate dollar states (e.g. is the cash in transit from your bank to ours).
Query inefficiencies requiring joins across dozens of tables for chronological transaction history.
It was time to "day-zero" the system.
day zero<br>A core engineering principle at River: if we were starting today, knowing everything we know now and owing nothing to sunk cost, what would we build?
narrow waist
The new design simplifies the API to two primary functions: fetching current balances and recording money movement events (e.g., buy_completed). Each event type implements BalanceRules, a pure, versioned function that transforms inputs and balances into ledger entries using declarative rules.
balance_rules.exsimplified
copy<br>defprotocol BalanceRules do<br># pure & deterministic: facts + current balances in, ledger entries out.<br>def apply(inputs, current_balances)<br>end
# a simplified buy completion: decrease USD, increase BTC<br># returns {debit, credit, amount} tuples<br>def apply(%BuyCompleted.Inputs{} = i, balances) do<br>{:usd_liability, :fbo, i.amount_usd},<br>{:received_btc, :btc_liability, i.amount_btc}<br>end
Declarative accounting rules for each event
This design naturally makes every event testable, composable, and auditable. Versioning pins historical rules to their inputs while managing compatibility as we iterate.
Events are grouped into flows that represent the activity (e.g. wire_transfer, buy_order, ach_withdrawal) and these flows can reference each other to signify some semantic relation. This gives us an efficient way to query transaction history, forming a tree-like data structure e.g. a return that points at the deposit it reverses or a deposit that’s linked to the chain of recurring orders that it is a part of.
fund_flow_timelineinteractive · simplified
// one account’s timeline. click a flow to unfold it.
▸flow a1ach_deposit$5,000.00jan 03
event · ach_initiated<br>{client_receivable, usd_liability, $5,000.00}
event · ach_received<br>{fbo, client_receivable, $5,000.00}
▸flow a2buy_order$2,500.00jan 05
event · buy_placed<br>{usd_liability, usd_reserved, $2,500.00}
event · buy_completed<br>{usd_reserved, fbo, $2,500.00}
{received_btc, btc_liability, ₿0.02310000}
▸flow a3ach_return−$5,000.00↳ reverses a1jan 09
event · ach_returned<br>{usd_liability, fbo, $5,000.00}
▸flow a4ach_retry$5,000.00↳ retries a3jan 12
event · ach_received<br>{fbo, usd_liability, $5,000.00}
An account’s chronological timeline, but also semantically linked. Each flow unfolds into events; each event into ledger entries.
follow the money
The previous data model was framed around the funds’...