Skip to Content
EngineeringLedger and balances

Ledger and balances

Two questions, two sources of truth

QuestionSource
“What is actually at the exchange”Derive, private/get_subaccount
“How much of their own money does the user have, and why did the balance change”the journal → the ledger_entries projection

Balances are a projection, not an independent truth. The portfolio never asks the exchange at all. When the two disagree, the exchange wins and the journal is pulled toward it.

Everything Derive physically cannot see — gas, on-chain transfers, platform overhead — is exclusively ledger truth. There is no other source for those numbers.

Reconciliation reads amount from the exchange, not mark_value. Derive returns quantities as exact decimal strings but computes the dollar valuation in float and prints the entire mantissa (19.859056985262139249925894546322524547576904296875). Reconciling against that number would catch representation artefacts instead of accounting errors.

Chart of accounts

Account names are built only in ledger/accounts.ts. A literal elsewhere is a future typo that no type catches, and the projection drifts silently.

AccountMeaning
user:{u}:sub:{subaccountId}:{cur}one currency’s balance in one subaccount
user:{u}:transitfunds in flight; the single basis for free balance
user:{u}:externalmirror of the system boundary
user:{u}:income:{premium|interest|funding}
user:{u}:expense:{derive_fee|settlement|swap|borrow}
platform:gas:{chain} / platform:hot:{chain}
platform:expense:gas:{chain}gas attributed to a user but not charged to them
platform:expense:overhead / platform:fee_revenue
platform:expense:reconciliationunexplained journal-versus-exchange difference

A deposit increases transit and decreases external; a withdrawal does the reverse. A user’s own equity is the sum of the internal accounts, and external shows how much they brought in minus how much they took out.

Reconciliation gets its own account rather than being folded into overhead, because the cause of a divergence is by definition unknown — if it were known, the event would have a different name. The spec requires the running total of adjustments to be treated as an accounting-quality metric.

The subaccount account is keyed by subaccountId, not asset (migration 1787900000002).

Sign, not debit and credit

pair(plusAccount, minusAccount, …) names the arguments after the sign, not the role: in some calls the asset account comes first, in others the expense account does, and both are correct as long as plus and minus are not swapped.

Negation is multiplication by '-1', never string concatenation: a - in front of an already negative value would give --0.11 and Decimal would throw on parse. Negative amounts are real — USDC interest can be paid.

Who bears a cost is determined by the account prefix: platform: means the platform, anything else means the user. Gas sits on a platform account even though it is attributed to a user — that is the cost-allocation rule, stated once.

Posting rules

EventPostings
deposit_detected+transit / −external
withdrawal_sent+external / −transit (the mirror)
subaccount_funded+sub(cur) / −transit
subaccount_released+transit / −sub(cur)
option_sold+gross sub:USDC / −gross income:premium and +fee expense:derive_fee / −fee sub:USDC
option_settledITM only: +debit expense:settlement / −debit sub:USDC
interest_accrued+sub:USDC / −income:interest (signed amount)
gas_spent+platform:expense:gas:{chain} / −platform:gas:{chain} — both platform
swap_executedthree legs, see below
reconciliation_adjustment+{payload.account} / −platform:expense:reconciliation
deposit_swept, swap_reverted, trade_rejected, OTM option_settlednone

subaccount_funded separating transit from sub is what holds the boundary with the withdrawal subsystem: free balance is computed from transit, so money that has gone to the exchange drops out of it by itself, with no separate rule that someone could forget to apply.

swap_executed has three legs because the third is computed as the remainder of the first two rather than from a formula — so the event closes to zero by construction, not by a coincidence of rounding. The third leg is in USD on expense:swap, where dollars are appropriate, because that account does not enter the free-balance computation.

A subaccount account is built only when the subaccount is known. Absence is not “zero” and not “a default” — it is data corruption.

moneyField() requires a payload money field to already be a string on the way in: String(4.7) would silently pass a value that had already lost precision inside JSON.parse.

Money field types

  • unsignedMoney where a minus is impossible. fee: '-0.78' would quietly turn a fee charge into a credit to the user, and the zero-sum invariant would not catch it — both postings of the pair simply flip sign together.
  • signedMoney only where a minus is real.
  • fxRate is stored in the event, not looked up at projection time. Otherwise re-projecting the journal a month later would produce different numbers.

The invariant

assertBalanced: the postings of one event sum to zero in USD. “The invariant that separates a journal from a log.”

Storage

ledger_events(id, idempotency_key UNIQUE, type, user_id, subaccount_id, cycle_id, payload, occurred_at, recorded_at) ledger_entries(event_id, account, amount_native numeric(38,18), currency, amount_usd, fx_rate, bearer CHECK IN ('user','platform'))

Indexes on (user_id, occurred_at) and (type): reports read the journal in the chronology of what happened, not of what was recorded.

Append-only is enforced in the database: a function plus a ROW trigger on UPDATE/DELETE, plus a separate STATEMENT trigger on TRUNCATE — Postgres does not fire ROW triggers on TRUNCATE.

An honest boundary: this protects against accident and against a late-night psql, not against intent. ALTER TABLE … DISABLE TRIGGER and superuser access go straight through it.

Idempotency

See Reference for the full table of key shapes.

append is ON CONFLICT DO NOTHING, not check-then-insert

A second process fits between the check and the insert, and the race would produce two rows.

The id is returned always, even for a duplicate, via a separate SELECT. Without that, a restart after a crash between append and project could never project the event: the identifier would exist nowhere, and there is no “which events are unprojected” query.

Every caller uses this uniformly:

if (!appended.duplicate) await this.projector.projectEvent(appended.id)

A duplicate has already been projected; re-projecting changes nothing but takes FOR UPDATE on the event row.

parsePayload is called for its exception, not for its result. zod’s z.object silently drops keys absent from the schema. If the parse result were written to the column, any extra field — the exchange’s order id, its timestamps, the raw fill — would be destroyed at write time, and the journal is append-only. The original input.payload is what goes into the database.

Projection

projectEvent: SELECT … FOR UPDATEpostingsForassertBalancedDELETEINSERT, all in one transaction.

Replacement, not appending: otherwise a second call after a failure would double the balance — precisely what the projection exists to prevent.

FOR UPDATE is not decoration. Two concurrent projectEvent calls on the same eventId are a normal mode: reconciliation runs after every money operation and hourly, so a rebuild can catch a trade that was just written. Under READ COMMITTED both see the same rows until the other commits, each deletes what its own snapshot shows, and each re-inserts — the postings double.

connect() and startTransaction() go inside the try: if BEGIN fails after the connection is taken, release() still runs in finally. Rollback happens only when a transaction is actually active — rolling back before a successful BEGIN throws a second exception and masks the first.

postingsFor is a pure function: no database, no network, no clock. That is what makes the projection reproducible from the journal at any time, to the same digits.

Reading

nativeBalances filters by account prefix, not by ledger_events.subaccount_id: the account is where the posting landed, and one event can touch two accounts, so filtering by the event would pull in the other side of the pair.

starts_with, not LIKE: _ is a wildcard in LIKE, and on the day a subaccount identifier stops being a uuid, one subaccount would quietly absorb another’s balance.

Grouping by (account, currency) is mandatory — ETH and USDC must never be added together on one account.

A known irrecoverability. The projection cannot be rebuilt from the journal after the account-key migration if the journal holds even one pre-migration reconciliation_adjustment: it carries the account name directly in its payload, the rule copies it verbatim into the posting, and assertKnownAccount rejects the old shape. Such an event is permanently unprojectable, and rebuildForUser aborts on that user.

This is why assertKnownAccount was added to the event schema before the write. The real check used to live only in the projection, so an event with a bad account reached the append-only table before anything rejected it.

Two books: native and USD

Every posting carries both. One formula, in one place: amountUsd = amountNative × fxRate.

The rate is Derive’s index price from the showcase snapshot — the same one options are priced against. USDC → '1' by short circuit: taking its “market” price would inject fourth-decimal noise into the journal for nothing.

One source for all consumers, deliberately. A second would create a second version of the truth: the same event recorded by two subsystems would get different rates, and reconciliation would see a divergence that does not exist.

Until the showcase is warm, SnapshotNotReadyError — and that is correct. Better than recording a deposit at an invented rate in an append-only journal.

What is forbidden

  1. Floats on money. Money.of accepts only a string.
  2. Cross-currency arithmeticassertSameCurrency.
  3. Adding native amounts of different currencies on one account.
  4. Inventing a fake currency to do quantity arithmeticcompareBalances computes directly through Decimal, because inventing a currency for a quantity would be cheating the Money ban.
  5. Writing a synthetic 'USD' or an ERC20 ticker onto :transit — the journal’s currency vocabulary is asset-level, not token-level (finding C-1, Ruling 10).
  6. Taking Decimal’s global precision. Six files independently clone it with precision: 40, ROUND_DOWN, toExpNeg: -30, toExpPos: 40common/money.ts, ledger/event.types.ts, reconciliation/balance-comparison.ts, strategies/candidate-builder.ts, wheel/candidate.selector.ts, wheel/wheel.sizer.ts.

Why precision 40: numeric(38,18) yields a balance with 21 significant digits, while the global Decimal default is 20. The global one would round a recomputed delta differently from its producer and reject a legitimate adjustment — in the middle of a reconciliation write cycle, where earlier divergences are already recorded and cannot be rolled back. The rejected divergence would then stay unrecorded forever: a retry recomputes the same delta and rejects it again.

The wide toExpNeg/toExpPos are a consumer requirement too: a default Decimal switches to "-1.2e-7" at divergences below 1e-7 — routine at crypto precision — and then parsePayload would throw on exactly the divergence reconciliation is obliged to record.

Portfolio

GET /portfolio/summarybalances[] (free), onExchange[], totalUsd.

free = the :transit projection − withdrawal reserve (conversion_id IS NULL) − conversion reserve (C8)

Reserves are subtracted here, not in the projection: a request is not yet a movement of money and nothing is written to the journal until a transaction is sent. But showing a balance that has already been requested for withdrawal, or that is being swapped on Base right now, would let the same money be spent twice.

Two twin methods: projectedFree (no reserve — for request intake, which computes the reserve itself inside its transaction; subtracting twice would forbid withdrawing half of one’s own money) and freeBalance (with reserve — for the screen).

Exchange money is a separate pass, not a branch inside: applying the reserve subtraction to money that cannot be withdrawn yet would count the reserve twice.

totalUsd is computed from the free balance only — it answers “how much can I withdraw”. balances and onExchange are not summed: a displayed total of the two would raise the question “why can I withdraw less than it says”, which has no good answer.

Sorting by localeCompare is not cosmetic — without it the on-screen list jumps between requests.

Debt is a negative balance

There is no separate field. In summary it arrives as a negative amount in onExchange. In the release endpoint it is broken out as debt along with the way to clear it — deposit USDC (P11).

valuate() zeroes totalUsd entirely. On the first unknown currency the catch returns null for the whole total, not for the offending currency. This is exactly the hole the C-1 comments warn about.

Valuation uses the current index price, not the event rates: the rate in the journal is fixed at the moment of the event and must stay that way, but the screen answers a different question. An unavailable price yields null — not an exception and not zero: “the quantity is the substance, the price is decoration”.

The statement

Membership is defined by a rule, not a list of types: a row is visible if the event produced postings on that user’s :transit. From which it follows on its own that deposit_swept is invisible (no postings) and gas_spent is invisible (platform postings).

The valuation in a row is historical, at the event’s fx_rate.

Pagination is a keyset on (occurred_at, id), not OFFSET: the journal is append-only and grows, so with OFFSET a new event would shift the window. Two fields because a deposit and its sweep arrive in the same second. The cursor is opaque so its shape does not become part of the public contract; a corrupted cursor means “start over”, not a 500.

Amounts pass through Money: numeric(38,18) returns 0.000100000000000000 while the summary shows 0.0001, and one number in two shapes on two screens reads as a data discrepancy.

After-the-fact events

occurred_at is separate from recorded_at, every read of the journal is in the chronology of what happened, and the index exists for exactly that.

Interest

Accrual happens without our participation: the exchange computes borrow on a negative balance and supply on a positive one, and the only way to learn of it is to read it.

This is a silence condition, not a convenience. Without the collector the journal drifts from the exchange by the accrued amount, reconciliation starts writing adjustments every hour forever, and a real divergence drowns in that noise.

Measured 2026-08-20: USDC borrow_apy 4.696%, supply_apy 3.491%.

  • occurredAt is the end of the bucket, not “now” — otherwise the statement would show a week of borrow as a single line at the moment the collector finally got to it.
  • The sign is preserved: after an ITM call the balance is negative and accrues borrow; an absolute value would turn an expense into income.
  • The cursor moves after the write: the reverse order would, on a mid-way failure, skip accruals forever — an append-only journal is not backfilled.
  • Every funded subaccount is walked, not only those with an open position: borrow accrues on a closed cycle too, until the debt is cleared.

The shape of an interest-history item has never been observed — there has never been a negative balance on the account. The item is parsed with passthrough(), the amount field is guessed by trying names, and on failure the error lists the fields that actually arrived.

getInterestSince also does not pass the window to the exchange — the filtering is client-side.

Funding is not collected at all

The type funding_paid is declared and the account income:funding exists, but there is no payload schema, no posting rule, nobody writes it, and the gateway has no method to read funding either. The same holds for bridged, signal_received and stop_cleared.

Boundaries of honesty

WhatState
ReconciliationWired, but observe by default — divergences go to the log only
Platform feeApplied nowhere
funding_paid, bridged, signal_received, stop_clearedDeclared, never written
platform:gas:{chain} only ever decreasesAll three gas sources are projected (sweeps, the withdrawal relay, swaps), but topping the gas wallet up is not recorded as an event — it is manual. As a running expense the account is correct; as a wallet balance it is not, and it is named as the second
Platform accounts appear in a user-scoped querybalances(userId) and nativeBalances(userId) filter by ledger_events.user_id, not by account prefix, and gas_spent carries the user_id of whoever’s sweep caused it. The portfolio’s consumers filter by account shape themselves (:transit, isSub) and are therefore fine; the first naive call to balances() will get platform gas inside a user’s balance
rebuildForUserUnrecoverable after the account-key migration if a pre-migration adjustment exists
Append-onlyTrigger-protected, but not against a superuser
valuate()Zeroes totalUsd entirely on the first unknown currency
Clearing a stopManual only, and there is no endpoint
decimalsThe same { USDC: 6, ETH: 18, BTC: 8 } is declared in three files: subaccounts/release.service.ts, subaccounts/subaccount-provisioner.ts, conversions/stages/release.stage.ts
The shape of interest / settlement / trade historyNever observed
withdraw behaviour with a negative USDC balanceClosed by observation 2026-08-31 (CQ2, opened as M6): the request is trimmed to the margin limit rather than refused outright, so the circle takes what it is given. Observed on Derive’s own UI — not measured by our code