Skip to main content
Architecture · the core Everything else in Arc is an interface onto this. Transfers, fees, FX, reversals, reconciliation and reporting are all ways of asking the ledger a question or telling it something happened. The ledger has one job: record what happened to money, exactly, and never lose a unit of it.

Three ideas carry the whole design

A customer's balance is a liability, not an asset

When someone funds a virtual account, Arc gains an asset, float sitting at a bank or on-chain, and simultaneously owes that person the same amount. Those are the two sides of one journal.Keeping them paired is what makes “do we actually hold what we owe?” an answerable question rather than a hope. A system that stores a customer balance as a number has no way to ask it.
A balance is a fold over entries. Replaying the entry log from the beginning must reproduce the same figures.If it does not, the entries are right and the cached number is wrong. That ordering is not arbitrary: it is the reason the log is the record and the balance is a projection.
A correction is a new, opposing journal. Never an edit, never a delete.This keeps the record of what happened separate from the record of what was meant to happen, which is the property an auditor actually cares about, and the property that makes an incident reconstructable.

The model

Account codes are structured strings, not opaque ids: asset.float.bank.EUR, liability.customer.va_1.KES. A journal is therefore legible in a log or a psql session without joining anything, which matters at 3am far more than it matters at design time.

Account types and direction

Debits increase assets and expenses. Credits increase liabilities, equity and revenue. The same entry moves two accounts in opposite senses: a €10 debit raises a bank-float asset and lowers a customer liability. That mapping lives in exactly one place, a NORMAL_BALANCE table, and a single function, entrySign(), is the only thing that reads it. Duplicating it would guarantee that the two copies eventually disagree and the ledger silently mis-signs balances. It returns bigint rather than number so it can be multiplied directly by a minor-unit amount without a cast.

Two system accounts worth knowing

liability.in_transitFunds committed to a live transfer but not yet paid out. Its overdraft floor is zero: driving it negative would mean paying out money nobody put in, and the posting engine rejects it.
equity.fx_positionThe bridge between the currency halves of an FX journal. The pair of position accounts is where an unhedged exposure becomes visible: sold EUR for USDC and not covered, and it shows as offsetting balances here.

The central invariant

In every currency it touches, a journal’s debits equal its credits. Exactly. Not within a tolerance.
This is the reason money is an integer count of minor units. With floats an epsilon would be unavoidable here, and a ledger with an epsilon is not a ledger: “how far off is acceptable?” has no defensible answer, and the drift grows with volume. Per currency, independently. A journal converting EUR to USDC has two halves and each must close on its own. Offsetting a EUR debit against a USDC credit would be adding quantities of different things: numerically possible, economically meaningless. The FX position accounts are what close each half. assertBalanced checks three things in order:
1

At least two entries

One entry can never balance.
2

Every amount is positive and non-zero

amount is always positive; direction carries the sign. Allowing negative amounts would give two representations of one fact, a −€10 debit and a +€10 credit, and two representations is how ledgers drift.
3

Every currency balances exactly

Currencies are returned sorted, so error messages and test assertions are deterministic.

Worked example

€1,000.00 from Germany to a Kenyan mobile-money wallet. Three journals; every one balances in every currency it touches.
kind: transferThe customer’s liability falls by €1,000: Arc owes them less. €990 moves into in-transit; €10 becomes revenue, split into two named fee accounts rather than one blended line.
Afterwards: the sender’s balance is zero, the recipient holds KES 138,401.00, Arc kept €10.00 in revenue, and the trial balance is zero in every currency. That exact sequence is asserted by a test.

Rounding residuals

A 1.5% fee on €33.33 is €0.49995, not representable in cents. Round it to €0.49 and €0.00995 has to go somewhere, or the journal will not balance.
The residual becomes a number someone can look at, rather than drift nobody can explain. divResidual() returns the exact leftover from any rounded division precisely so it can be posted like this.
The cent that vanished →

Balance-or-reject

PostingEngine.post() validates in full before writing anything. A rejection leaves no trace: there is no path that writes some entries and then discovers a problem. The order is deliberate: cheapest and most fundamental first. Balance is pure and needs no I/O, so a malformed journal never reaches the database at all. Account resolution is one round trip. Overdraft checking needs current balances, so it is last.

Available versus posted

A customer with €1,000 posted and a €250 active hold can spend €750. Showing them posted would let them spend money already committed elsewhere. A quote reserves; execution captures; expiry releases.
Holds are modelled, not yet wired. The table and the projection exist; the saga currently debits directly rather than reserving at quote time. Wiring them belongs with the transfer API.There was a race here, closed in Phase 6.5: between the balance read and the append, a concurrent journal could spend the same funds. The database’s balance trigger does not catch it, because it enforces that a journal balances, not that an account stayed above its floor. LedgerStore now exposes withAccountLocks, and PrismaLedgerStore implements it as a transaction opening with SELECT … FOR UPDATE on the touched rows. Account codes are sorted by the posting engine so concurrent posts acquire in the same order and cannot deadlock. Two integration tests pin it: two concurrent €80 spends from €100 leave exactly one winner, and eight concurrent €30 spends leave exactly three.

Enforced twice, on purpose

The posting engine refuses to write an unbalanced journal. The database refuses too, independently. This is deliberate duplication. An invariant that depends on every future developer remembering to go through the right class is not an invariant: it is a convention, and conventions erode. DEFERRABLE INITIALLY DEFERRED is what makes the balance check workable: it runs at COMMIT, not per row, so a journal can be inserted one entry at a time and is judged only once complete. Each of these was attempted through raw SQL against a live database and rejected:
A transaction that leaves any journal unbalanced in any currency cannot commit: not from the application, not from a migration script, not from a psql session during an incident.

Reversal

Arc undoes things by posting the opposite journal, never by deleting. Two properties make this load-bearing:
  • If a journal balances, its reversal balances. Flipping every direction preserves the equality, so unwinding a failed transfer is safe by construction rather than by careful coding. That matters because the unwind path runs when something has already gone wrong.
  • It is an involution. Reversing twice returns the original.
Both are asserted by the property suite. What the balance check cannot catch is compensating in the wrong order, and that is the most instructive scenario on this site.

What the tests actually prove

These were mutation-checked. Three deliberate defects were introduced to confirm the suite is load-bearing rather than decorative:
A suite that passes when the code is broken proves nothing. These do not.
What the tests prove →

What is not here yet

  • Holds at quote time, as described above.
  • Balance snapshots. Every balance is a full fold over entries. Correct, and fine at this scale; real volume wants periodic snapshots to fold forward from.
  • The int64 ceiling. Amounts are Postgres BIGINT, so the limit is 9.2e18 minor units — ample for 6-decimal assets, but an 18-decimal balance above ~9.2 tokens would exceed it and needs NUMERIC(78,0).

Next: the chain layer

Five chains with genuinely different physics, behind one interface.

Walk a transfer

The same €1,000, followed step by step through every context.