We replaced our ledger with two functions

leishman3 pts0 comments

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’...

ledger event bitcoin balances functions flow

Related Articles