Skip to content
JuicePay
All posts

Finance

Reading the ledger: a practical guide to onchain reconciliation

Priya Raghunathan · · 6 min read

Reconciling onchain activity against your own books has a reputation for being hard because of cryptography. In our experience the hard part is entirely mundane: deciding what a row means.

We rewrote our reconciliation model three times. The first two versions were mathematically sound and operationally useless, and both failed for the same reason — they tried to represent chain state directly instead of representing events about value.

The mistake: modelling balances#

The first model stored balances. For each account and asset, keep a running total, and update it whenever chain state changed.

This breaks the moment anything is ambiguous. A deposit seen onchain but not yet final: is it in the balance or not? A transaction that gets reorganised away: do you decrement, or never increment? A conversion that routes through three venues: one entry or four?

Every one of those questions turned into a boolean flag on the balance row, and the flags interacted. We had a pending_reorg_check column. It was not a good column.

The correction: model entries#

The second rewrite threw out balances entirely and stored only entries. A balance is a SUM over entries, computed on read or materialised as a cache.

json
{
  "id": "le_7hQ2mN",
  "direction": "credit",
  "asset": "USDC",
  "amount": "4250.000000",
  "chain": "base",
  "source": "payment_intent:pi_9fK2xQ",
  "counter_entry": "le_7hQ2mP",
  "created": "2026-08-19T09:14:02Z"
}

Four properties make this work:

  • Entries are immutable. A correction is a new entry, never an edit.
  • Fees are their own entries, not netted into the amount. A $4,250 payment with a $0.41 network fee produces two entries, and neither number is a lie.
  • Every entry names its source, so any figure can be traced back to the resource that produced it.
  • Double entry, always. A movement between two sub-accounts writes a debit and a credit. A movement out of the system writes a debit and a fee credit.

The reorg problem disappears rather than getting solved: a reorg produces a reversal entry that references the original. pending_reorg_check became unnecessary because the ledger records what happened rather than trying to predict whether it will keep having happened.

The third rewrite: separation of concerns#

The second model was correct and slow. Recomputing balances on read across a few hundred million entries does not work, and materialising them on every write created lock contention during batch runs.

The third version keeps the entry model and adds an explicit staging area:

LayerContentsMutability
ObservationsRaw chain facts: seen, included, final, revertedAppend-only
EntriesValue movements derived from observationsAppend-only
BalancesMaterialised aggregatesDerived, rebuildable

Observations are the layer we had been missing. A chain fact is not a value movement — an included-but-not-final deposit produces an observation with no corresponding entry. When finality arrives, the entry is written. When a reorg occurs, the observation is superseded and, if an entry exists, a reversal is written.

The payoff is that the materialised balance layer is disposable. If the aggregate is ever wrong, delete it and rebuild from entries. We have done this twice in production, both times during an incident, both times in under four minutes.

Amounts are not numbers#

The other lesson that took a rewrite to learn: amounts must be decimal strings end to end, never floats.

json
{ "amount": "4250.000000" }

Ethereum's smallest units are 10^-18 and a double has 15–17 significant decimal digits. Anything that sums a few thousand such amounts in floating point will drift, and the drift will not be evenly distributed — it will be concentrated in exactly the accounts doing the most volume, which are the ones you least want to explain.

We store amounts as scaled integers internally with a per-asset precision, and serialise as strings. Every layer that touches money either carries the string through untouched or converts to scaled integer for arithmetic. There is no third option, and there is no debugging session that makes floats acceptable.

What a daily close looks like#

With observations, entries, and rebuildable balances, reconciliation becomes a sequence of checks rather than an investigation:

  1. Sum entries per asset. Compare with the materialised balance. Any difference is a bug in the aggregation, not a mystery.
  2. Sum entries by source. Compare against the count of terminal resources. A payment intent that succeeded without a credit entry is a broken invariant.
  3. Compare entry totals against the chain state we hold. Differences here are real and need investigation — everything above is internal consistency.
  4. Compare your own ledger against our entries, joined on your reference, not our IDs.

That last point deserves emphasis. Join on the reference you attached. If you ever recreate a resource for the same business event — a cancelled intent replaced by a new one — our IDs differ but your reference is stable. The reference is the only key that means the same thing on both sides of the join.

The general principle#

Reconciliation is a schema problem. If matching the chain to your books requires a human to reason about what a row means, the schema is asking the human to be the orchestrator, and humans are not durable aggregations.

Record what happened. Derive everything else. Make the derivations cheap enough to throw away.

Want to try this against a real API?

Sandbox keys are issued instantly and settle against deterministic fixtures, so nothing in this post requires production funds to reproduce.