Skip to content

03 — Ledger & Accounting

As of: 2026-05-28 Service: ledger-service (TypeScript, NATS-fronted, Prisma over Postgres) Source of truth: services/ledger-service/src/ledger.ts (541 LOC)

This document is the canonical reference for how the QuantaTrade ledger works, what guarantees it makes, and where the rough edges are. It is read alongside 01-architecture.md (service topology) and 07-data-model.md (schema).


TL;DR

  • The matching engine is the runtime source of truth for balances. The ledger is an auditable mirror in Postgres. A failed ledger write does NOT roll back an engine state change — it is logged and alertable.
  • Every money movement writes paired entries to ledger_entries, with a running balance column and a transactionId. The unique-by-transactionId check is the only idempotency guard inside the service.
  • Five public operations: getBalances, credit, debit, lock, unlock, plus settleTrade (called via the trades.executed event, not RPC).
  • 🟢 Deposit-sync wiring landed 2026-05-27custody.deposit.confirmed → matching-engine Depositledger.credit is verified end-to-end with txHash as the idempotency key.
  • 🔴 Known gap: settleTrade has no idempotency short-circuit in the source despite a test that asserts one. See §4.2.
  • 🔴 Known gap: credit/debit are not actually opened with Serializable isolation despite tests asserting they are. See §6.

1. The "engine is truth, ledger is mirror" rule

Rule: The Java matching engine (exchange-core2, in-memory LMAX disruptor) is the runtime source of truth for user balances and order state. The Postgres ledger is an auditable mirror, eventually consistent via NATS.

Stated up front in docs/milestone-1-status.md:93:

"Ledger failure is logged and alertable but does not roll back the matching-engine deposit (the engine is the runtime source of truth; the ledger is the auditable mirror)."

The deposit-sync handler in order-router/src/main.ts:767-775 codifies the rule:

} catch (ledgerError) {
  // Log but do not re-throw — matching engine already has the funds; ledger sync
  // failure must be alertable but must not roll back the deposit in the engine.
  logger.logError('Failed to credit ledger for deposit', ...);
}

Consequences

  1. A ledger-service outage does not freeze trading. Orders continue to match in-memory. The ledger catches up when NATS reconnects (subjects are JetStream-backed for the events in question — see 08-event-bus.md).
  2. Reconciliation is required. The ledger and engine can drift if a ledger write fails silently. Engineering must monitor ledger.credit error logs and have a reconciliation tool ready. This is a known operational gap (see §9).
  3. Postgres is read-replica-grade for balances. UIs that need an authoritative current balance ask the matching engine via the ledger.balances.get RPC, which today actually reads from Postgres (ledger.ts:55-69). The engine-vs-Postgres reconcile-on-read pattern is not yet implemented.

2. Double-entry semantics as implemented

Every money movement creates paired entries — user account ↔ user account (trade) or user ↔ omnibus (deposit/withdrawal). The mirror entry is what makes the book balance.

ledger_entries row shape

From packages/db/prisma/schema.prisma:121-138:

Column Type Purpose
id cuid PK
transactionId string Idempotency key — not unique in the schema today; uniqueness is enforced only by the application-level findFirst check in credit/debit. See §4.4
accountId FK → accounts Which account is moving
amount Decimal(36,18) Signed: positive for credits, negative for debits
balance Decimal(36,18) Running balance after this entry
entryType enum LedgerEntryType Why the movement (see below)
referenceType string What kind of business event ('deposit', 'trade', 'withdrawal', …)
referenceId string The business event ID (txHash, tradeId, …)
createdAt timestamp Default now()

Indexes (schema:134-136):

@@index([transactionId])
@@index([accountId, createdAt])
@@index([referenceType, referenceId])

Entry types (the 13 enum values)

From schema.prisma:140-154:

deposit | withdrawal | trade | fee | transfer
margin_borrow | margin_repay | margin_interest | liquidation
staking_lock | staking_unlock | staking_reward
adjustment

Mirrors the TypeScript union in ledger.ts:24-37. Today, only deposit, trade, and fee are written by production code. The rest are scaffold for M5 (staking), M8 (margin) and ops use (adjustment).


3. The five ledger operations

All five live in services/ledger-service/src/ledger.ts. They are exposed over NATS by services/ledger-service/src/main.ts (subject → handler):

NATS subject Handler File:line
ledger.balances.get getBalances main.ts:59-69
ledger.credit credit main.ts:71-106
ledger.debit debit main.ts:108-143
ledger.lock lock main.ts:145-155
ledger.unlock unlock main.ts:157-167
trades.executed (event) settleTrade main.ts:169-181

Note the asymmetry: settleTrade is not an RPC. It is a subscription on the trades.executed event that the order-router emits when the matching engine produces a trade. There is no return value to the caller — the event is fire-and-forget.

3.1 credit

ledger.ts:71-150. Adds funds to a user's spot account.

Signature:

credit(transactionId, userId, asset, amount, entryType, referenceType, referenceId)
   { success, newBalance?, error? }

Flow:

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
sequenceDiagram
  participant Caller as Caller<br/>(order-router, etc.)
  participant LS as LedgerService
  participant DB as Postgres
  Caller->>LS: credit(txId, userId, asset, amount, ...)
  LS->>LS: isPositive(amount)? else error
  LS->>DB: ledgerEntry.findFirst({transactionId})
  alt entry already exists
    DB-->>LS: { balance }
    LS-->>Caller: { success: true, newBalance: balance }
  else new entry
    LS->>DB: $transaction begin
    DB->>DB: account.findFirst({userId, asset, type:'spot'})
    alt account missing
      DB->>DB: account.create({available:0, locked:0})
    end
    DB->>DB: account.update({ available: add(available, amount) })
    DB->>DB: ledgerEntry.create({ txId, accountId, amount, balance: newBalance, entryType, ... })
    DB-->>LS: newBalance
    LS-->>Caller: { success: true, newBalance }
  end

Idempotency (ledger.ts:84-90): findFirst({where: {transactionId}}) short-circuits before the transaction body opens. If the entry exists, the cached balance from that row is returned — note this is the balance at the time of the original credit, not the current balance. Callers that need a fresh read should use ledger.balances.get.

Account auto-creation (ledger.ts:99-109): if the user has no spot account for that asset, one is created with zero balances inside the same Prisma transaction. See §7 for the concurrency caveat.

3.2 debit

ledger.ts:152-227. Removes funds from a user's spot account.

Identical structure to credit but: - Throws 'Account not found' if no account exists (no auto-create). - Throws 'Insufficient balance' if account.available < amount (ledger.ts:184-186). - Writes the entry with a negative amount (ledger.ts:200).

Used today by: nothing in production (yet). The withdrawal path is custody-side and not wired through ledger.debit — see §9.

3.3 lock

ledger.ts:229-276. Moves balance from availablelocked. Used when an order is placed.

No ledger entry is written. Locking only mutates accounts.available and accounts.locked; the ledger_entries table is for money movement, and lock is internal book-keeping. Audit trail for locks comes from the Order table (see 07-data-model.md §3).

No idempotency guard. A double-publish of ledger.lock will double-lock and fail with insufficient-balance on the second call (or succeed if the user had enough). Callers should not retry blindly.

3.4 unlock

ledger.ts:278-325. Reverse of lock. Used when an order is cancelled or fully filled outside the engine.

No ledger entry written, no idempotency guard. Same caveats as lock.

In practice, the matching engine already handles available/locked internally (exchange-core2 has no stored "locked" — it's derived from open orders, and cancel auto-releases via ReduceEvent). The lock/unlock RPCs exist to mirror that into Postgres for off-engine queries but are not invoked on the live order-placement path today. The order-router's risk-checker reads available-balance directly from the matching engine via gRPC.

3.5 settleTrade

ledger.ts:327-433. The heart of the trade-side ledger. Called when the matching engine publishes a trades.executed event.

Signature:

settleTrade({ id, buyerId, sellerId, symbol, price, quantity, buyerFee, sellerFee })  void

Flow (ledger.ts:341-411):

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
sequenceDiagram
  participant ME as Matching Engine
  participant OR as order-router
  participant NATS
  participant LS as LedgerService
  participant DB as Postgres
  ME-->>OR: TradeEvent (gRPC stream)
  OR->>NATS: publish trades.executed
  NATS->>LS: deliver trades.executed
  LS->>DB: $transaction begin
  Note over LS,DB: getOrCreateAccount × 4<br/>(buyerBase, buyerQuote, sellerBase, sellerQuote)
  LS->>DB: buyerQuote.locked -= quoteAmount
  LS->>DB: buyerBase.available += quantity
  alt buyerFee > 0
    LS->>DB: re-read buyerQuote.available
    LS->>DB: buyerQuote.available -= buyerFee
  end
  LS->>DB: sellerBase.locked -= quantity
  LS->>DB: sellerQuote.available += quoteAmount
  alt sellerFee > 0
    LS->>DB: re-read sellerBase.available
    LS->>DB: sellerBase.available -= sellerFee
  end
  LS->>DB: ledgerEntry.createMany([4 trade rows + up to 2 fee rows])
  DB-->>LS: commit
  LS->>LS: logger.logTrade({ ..., status: 'settled' })

Per-side math (ledger.ts:337-339): - [baseAsset, quoteAsset] = symbol.split('-') — e.g. BTC-USD → base=BTC, quote=USD. - quoteAmount = quantity × price. - txId = generateTransactionId() — generated once per settlement, reused as the prefix for each ledger entry's transactionId (see createTradeEntries below).

Fee model: - BUY side pays fee in quote currency (ledger.ts:368-378). - SELL side pays fee in base currency (ledger.ts:397-407). - This matches Binance/major-CEX convention.

The 4 base + 2 fee ledger_entries (ledger.ts:459-540, createTradeEntries):

transactionId accountId amount entryType
{txId}-buyer-base buyer base +quantity trade
{txId}-buyer-quote buyer quote -quoteAmount trade
{txId}-seller-base seller base -quantity trade
{txId}-seller-quote seller quote +quoteAmount trade
{txId}-buyer-fee buyer quote -buyerFee fee (if buyerFee > 0)
{txId}-seller-fee seller base -sellerFee fee (if sellerFee > 0)

The 4-base-plus-up-to-2-fee shape is locked in by ledger.test.ts:977-1095 (PR#4 added the explicit shape assertions when the jest config started actually running tests).

balance column at trade time — note ledger.ts:475, 485, 495, 505 write balance: <account>.available before any update was applied. This is the pre-update available. After-update balance is harder to compute because each side has two account updates (asset + fee). Today the balance column on trade rows reflects the opening balance of that account in the transaction, not the post-trade resting balance. This is a defect: the column name implies "running balance after this entry" (as it does for credit/debit) but for trades, it isn't. Document this honestly to whoever audits.


4. Idempotency mechanisms

This is the subtle part. There are three different idempotency mechanisms in play, and they don't all agree.

4.1 credit and debit — application-level transactionId guard

ledger.ts:84-90 (credit) and ledger.ts:165-171 (debit):

const existing = await db.ledgerEntry.findFirst({ where: { transactionId } });
if (existing) {
  return { success: true, newBalance: existing.balance.toString() };
}

This is the only idempotency guard. It runs outside the $transaction callback, so two concurrent calls with the same transactionId could both pass the check and both enter the transaction body. The second would then try to insert a row with the same transactionId, but since the schema does not declare transactionId as @unique, both inserts succeed and the user gets double-credited.

In practice, the NATS queue group ledger-service (main.ts:69, 105, 142, 154, 166) serialises all RPCs of the same subject to a single subscriber instance, which means within one Node process the in-flight call collapses the race window. But if you scale ledger-service horizontally past 1 replica, this guard is no longer sufficient.

Recommendation (operational, not yet implemented): add @unique to LedgerEntry.transactionId in the Prisma schema and convert the findFirst + create pair to an upsert. This shifts the guard from advisory (single-replica) to authoritative (multi-replica).

4.2 settleTrade has NO idempotency guard — known gap

🔴 Bug. The test at ledger.test.ts:848-878 asserts:

should be idempotent - skip if trade already settled

And the workflow doc-comment at services/ledger-service/src/workflows/trade-settlement.ts:40:

// Step 1: Settle the trade in ledger
// This is idempotent - the ledger checks for duplicate settlements using trade.id
await settleTradeActivity(input);

But ledger.ts:327-433 has no findFirst check anywhere in settleTrade. The $transaction callback opens immediately and the four getOrCreateAccount calls run. A duplicate trades.executed delivery would replay the entire balance update — buyer base credited twice, seller balance debited twice, etc.

The test passes only because it mocks ledgerEntry.findFirst → { referenceId, referenceType } and the assertion is expect(mockTx.account.update).not.toHaveBeenCalled() — but settleTrade never even calls findFirst on the entries table, so the test is asserting against unreached code. Until PR#4 fixed the jest config, this test had been silently green for weeks.

Recommended fix (not yet shipped):

await db.$transaction(async (tx) => {
  // Idempotency: skip if any ledger entry for this trade already exists
  const existing = await tx.ledgerEntry.findFirst({
    where: { referenceType: 'trade', referenceId: trade.id },
  });
  if (existing) return;

  // ... rest of settleTrade body
});

Use referenceId = trade.id because each trade-settlement writes 4-6 rows with derived per-row transactionIds (${txId}-buyer-base, etc.), so transactionId alone is not enough. The composite (referenceType, referenceId) is what disambiguates "trade trade-123 already settled".

Why the gap matters: matching-engine trade events flow through NATS without JetStream acking today, and the engine itself can re-emit on reconnect. A retransmit = double-settlement of every affected trade.

4.3 Deposit-sync uses txHash as idempotency key — verified

🟢 Working. services/order-router/src/main.ts:753-761:

await publisher.request('ledger.credit', {
  transactionId: `deposit:${event.txHash}`,
  userId: event.userId,
  asset: event.asset,
  amount: event.amount,
  entryType: 'deposit',
  referenceType: 'deposit',
  referenceId: event.txHash,
});

The chain-side txHash is the natural idempotency key — a confirmed deposit at a given hash is, by definition, unique. The credit guard's findFirst({transactionId: 'deposit:0x...'}) short-circuits any retry of the same NATS event.

This was verified end-to-end on 2026-05-27 — see docs/milestone-1-status.md:99-107:

publish custody.deposit.confirmed { txHash:0xledgersync20260527c, userId:cmol48kdq..., asset:USDT, amount:500 }
→ matching-engine: Deposit for user 1013: currency=103, amount=500000000, result=SUCCESS
→ order-router:   "Ledger credited for deposit", newBalance:"500"
→ postgres accounts:        available=500.000000000000000000, locked=0
→ postgres ledger_entries:  amount=500, balance=500, entry_type=deposit
replay same txHash → accounts unchanged, ledger_entries count = 1 (idempotent)

4.4 The schema does NOT enforce unique transactionId

Read schema.prisma:121-138 carefully — transactionId has an @@index, not @@unique. The uniqueness contract lives only in application code. See the recommendation in §4.1.

The IdempotencyKey table (schema.prisma:683-693) exists but is unrelated — it's for HTTP-layer idempotency in api-gateway (see services/api-gateway/src/guards/idempotency.guard.ts), not for ledger entries.


5. The deposit-sync wiring (landed 2026-05-27)

The full deposit flow is the most-recently-verified end-to-end ledger path. Worth documenting in detail because it's the template for every future "external event → engine → ledger" wiring (withdrawals next).

5.1 Sequence

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
sequenceDiagram
  participant BitGo as BitGo<br/>(planned)
  participant CS as custody-service<br/>(planned)
  participant NATS
  participant OR as order-router
  participant ME as matching-engine<br/>(gRPC)
  participant LS as ledger-service
  participant PG as Postgres

  BitGo-->>CS: webhook: deposit confirmed
  CS->>NATS: publish custody.deposit.confirmed<br/>{ txHash, userId, asset, amount }
  NATS->>OR: deliver custody.deposit.confirmed
  OR->>ME: Deposit (gRPC)<br/>{ userId, currency, amount }
  ME-->>OR: result=SUCCESS

  Note over OR,LS: Engine has the funds now.<br/>Ledger sync follows but is not<br/>roll-back-able.

  OR->>NATS: request ledger.credit<br/>{ transactionId: "deposit:${txHash}", ... }
  NATS->>LS: deliver ledger.credit
  LS->>PG: findFirst({transactionId})
  alt not found
    LS->>PG: $transaction:<br/>account upsert + ledger_entries.create
    PG-->>LS: newBalance
    LS->>NATS: publish balances.updated.${userId}
  else found (replay)
    LS-->>OR: { success: true, newBalance: cached }
  end
  LS-->>OR: { success: true, newBalance }

5.2 Wiring file:line

  • Publisher (planned): services/custody-service/ — not implemented yet. Today the event is published by test scripts on EC2. M2 deliverable.
  • Subscriber: services/order-router/src/main.ts:725-782 — the Subjects.CUSTODY.DEPOSIT_CONFIRMED handler.
  • Engine call: matchingEngine.deposit(...) at order-router/src/main.ts:732-736 (gRPC client wrapper).
  • Ledger call: publisher.request<...>('ledger.credit', {...}) at order-router/src/main.ts:742-761.
  • Ledger handler: subscriber.subscribeService(...) registration in services/ledger-service/src/main.ts:72-106.
  • Ledger work: LedgerService.credit at services/ledger-service/src/ledger.ts:71-150.

5.3 Failure modes

Failure point Behaviour Notes
matching-engine Deposit throws Outer try/catch (or-main.ts:776-781) logs and stops. Ledger is not called. User's chain deposit is stranded; needs manual replay.
ledger.credit fails (NATS down) Inner try/catch (or-main.ts:767-775) logs Failed to credit ledger for deposit and continues. Engine has funds; ledger is behind. Alertable. Manual replay possible by re-publishing custody.deposit.confirmed.
ledger.credit returns success:false Inner branch (or-main.ts:762-764) logs ledger.credit rejected deposit. Validation failure (e.g. non-positive amount) — should be impossible in practice.
Duplicate custody.deposit.confirmed matching-engine Deposit is not idempotent at the engine level today — replays would double-credit the engine. ledger.credit is idempotent via transactionId. 🔴 This means a NATS redelivery could desync engine and ledger. Mitigation: dedupe at the publisher (custody-service) using txHash. M2 work.

5.4 Verification log

The 2026-05-27 EC2 verification is the canonical pass:

ssh ubuntu@34.199.105.99 (us-east-1, t3.2xlarge)
docker compose exec -T platform-nats nats pub custody.deposit.confirmed '{...}'

→ matching-engine: "Deposit for user 1013: currency=103, amount=500000000, result=SUCCESS"
→ order-router:   "Synced deposit to matching engine"
→ order-router:   "Ledger credited for deposit" newBalance:"500"
→ ledger-service: writes 1 row to ledger_entries
→ postgres:        accounts.available = 500.000000000000000000, locked = 0
                  ledger_entries.balance = 500, amount = 500, entry_type = deposit
→ replay same txHash:
  accounts unchanged, ledger_entries.count unchanged (still 1).

Cited in docs/milestone-1-status.md:99-107.


6. Concurrency posture

🔴 Known gap between the test contract and the source code.

ledger.test.ts:1098-1133 asserts that credit is opened with Serializable isolation level:

(mockDb.$transaction as jest.Mock).mockImplementation(async (callback, options) => {
  expect(options.isolationLevel).toBe('Serializable');
  return callback(mockTx);
});

But the actual db.$transaction(async (tx) => {...}) calls in ledger.ts (lines 93, 174, 240, 289, 342) pass no options at all — Prisma defaults to the underlying Postgres default (READ COMMITTED).

// ledger.ts:93
const result = await db.$transaction(async (tx) => {
  // ... no options object
});

Same story for the timeout / maxWait assertion at ledger.test.ts:1135-1167. The tests assert defensive defaults that the production code does not yet pass.

Implications

  • Under READ COMMITTED, two concurrent credit calls on the same account (e.g. two different deposit subscribers in a horizontally-scaled ledger-service) can both read the same available, both compute the same newBalance, and both write — the second update wins and a credit is lost. The findFirst idempotency guard on transactionId only saves us if the two calls share the same transactionId.
  • Settlement under READ COMMITTED: settleTrade re-reads accounts via findFirstOrThrow after the locked-balance update (ledger.ts:370, 399) specifically to get the post-update available — this works under READ COMMITTED because the re-read is inside the same transaction (sees its own writes). But two concurrent trade settlements affecting the same account can still interleave their reads if they aren't serialised at the queue.
  • The single-replica NATS queue group is the de-facto serialiser. Don't scale ledger-service past 1 replica without also adding Serializable and retry-on-serialisation-error handling.

Pass options to every $transaction call:

await db.$transaction(
  async (tx) => { /* body */ },
  { isolationLevel: 'Serializable', timeout: 10000, maxWait: 5000 }
);

And handle Prisma's P2034 serialisation-failure error code with a bounded retry. This is small but real M5/M6 hardening work.


7. getOrCreateAccount and the race window

ledger.ts:435-457:

private async getOrCreateAccount(tx, userId, asset) {
  let account = await tx.account.findFirst({
    where: { userId, asset, type: 'spot' },
  });
  if (!account) {
    account = await tx.account.create({
      data: { userId, asset, type: 'spot', available: 0, locked: 0 },
    });
  }
  return account;
}

Question: if two concurrent transactions both call this for the same (userId, asset) and both miss findFirst, do they both try create?

Answer from the schema: schema.prisma:110 declares @@unique([userId, asset, type]) on accounts. The second create will fail with Prisma's P2002 unique-constraint-violation error.

Today's behaviour on that failure: the second transaction will throw, bubble out of db.$transaction, get caught by the outer try/catch, return { success: false, error: 'Unique constraint failed' }. The caller (deposit-sync) logs and moves on — but the user's funds are stuck on the engine side, ledger side missing.

Practical mitigation: the NATS queue group serialises calls, so two-concurrent-create is a single-replica non-issue today. But this is the second place (after §6) where the multi-replica story breaks.

Recommended fix (small): replace findFirst → create with tx.account.upsert({ where: { userId_asset_type: ... }, create: {...}, update: {} }). The unique constraint already exists; Prisma will handle the race cleanly.

Caveat I can't verify from code alone: whether the Prisma client correctly maps the (userId, asset, type) unique to the upsert lookup key without a custom name on @@unique. The named-compound-unique form in Prisma is userId_asset_type. Quick smoke test on staging would resolve this in five minutes.


8. The balance column on ledger_entries

The balance column is the running balance after the entry is applied — for the entry types where this is meaningfully computed.

Where it's correct

  • credit (ledger.ts:124): balance: newBalance where newBalance = add(account.available, amount). The next entry on the same account will read account.available (now equal to newBalance) and add to that. ✅ Sequential entries on a single account form a verifiable running tape.
  • debit (ledger.ts:201): same shape, balance: newBalance after the subtraction. ✅

Where it's misleading

  • settleTrade trade rows (ledger.ts:475, 485, 495, 505): writes balance: <account>.available where <account>.available is the opening balance read in getOrCreateAccount before any update. This is not the post-trade resting balance. 🔴
  • settleTrade fee rows (ledger.ts:519, 532): same problem — opening balance, not post-fee balance.

Audit guarantees

What the column does give you: - For deposit/withdrawal flows on a single account: a verifiable running tape. SELECT amount, balance FROM ledger_entries WHERE accountId=X ORDER BY createdAt should show balance[i] = balance[i-1] + amount[i]. - A point-in-time snapshot for proof-of-reserve generation (see ProofOfReserve / UserBalanceSnapshot in 07-data-model.md).

What it does not give: - A truthful current balance for trade-impacted accounts. Use accounts.available + accounts.locked for that, not the latest ledger_entries.balance. - Ordering guarantee. The schema has no explicit serial column — createdAt has timestamp resolution, and two entries written in the same microsecond have undefined order. The application writes them in array order via createMany (ledger.ts:539), but createMany doesn't guarantee storage order.

Recommended fix for proper audit: add a monotonic seq BIGSERIAL column to ledger_entries and order by (accountId, seq). Small migration; substantial audit clarity.


9. What's NOT in the ledger today

Domain Status Where it lives Milestone
Treasury / revenue-router flows 🔴 Spec only: docs/m5-token-utility-plan.md M5
Staking rewards 🔴 Schema has StakingPosition model but no ledger code writes staking_reward entries M5
Staking lock/unlock 🔴 enum values exist (staking_lock, staking_unlock) but no code path writes them M5
Margin interest accrual 🔴 enum margin_interest exists; no code path M8
Margin borrow/repay 🔴 enums exist; MarginLoan table exists; no code M8
Liquidations 🔴 enum liquidation exists; InsuranceFund tables exist; no code M8
Withdrawals through the ledger 🔴 Custody side has CustodyTransaction records; ledger.debit is not called from any production path today M2 (custody wiring)
Internal transfers between sub-accounts 🔴 enum transfer exists; User.parentUserId exists for corporate sub-accounts; no code TBD
adjustment entries (ops manual correction) 🔴 enum exists; no admin tool yet Ops backlog

This is the gap surface for engineering planning. Every red here is a future wiring against an already-modelled enum/table — the schema work is done.


  • 01-architecture.md — where ledger-service sits in the service map.
  • 07-data-model.md — full schema reference for Account, LedgerEntry, related tables.
  • 08-event-bus.md — NATS subject conventions, idempotency on the bus side.
  • 04-risk-controls.md — the risk-checker that runs before ledger.lock, and why locks are managed in the engine rather than via this service today.
  • docs/milestone-1-status.md — the 2026-05-27 deposit-sync verification log.

Last updated 2026-05-28. Update on every meaningful change to ledger.ts, services/ledger-service/src/main.ts, or packages/db/prisma/schema.prisma's ledger-related models.