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 runningbalancecolumn and atransactionId. The unique-by-transactionId check is the only idempotency guard inside the service. - Five public operations:
getBalances,credit,debit,lock,unlock, plussettleTrade(called via thetrades.executedevent, not RPC). - 🟢 Deposit-sync wiring landed 2026-05-27 —
custody.deposit.confirmed→ matching-engineDeposit→ledger.creditis verified end-to-end withtxHashas the idempotency key. - 🔴 Known gap:
settleTradehas no idempotency short-circuit in the source despite a test that asserts one. See §4.2. - 🔴 Known gap:
credit/debitare not actually opened withSerializableisolation 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¶
- 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).
- 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).
- Postgres is read-replica-grade for balances. UIs that need an authoritative current balance ask the matching engine via the
ledger.balances.getRPC, 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):
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 available → locked. 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-core2has no stored "locked" — it's derived from open orders, and cancel auto-releases via ReduceEvent). Thelock/unlockRPCs exist to mirror that into Postgres for off-engine queries but are not invoked on the live order-placement path today. The order-router'srisk-checkerreads 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:
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
@uniquetoLedgerEntry.transactionIdin the Prisma schema and convert thefindFirst+createpair to anupsert. 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— theSubjects.CUSTODY.DEPOSIT_CONFIRMEDhandler. - Engine call:
matchingEngine.deposit(...)atorder-router/src/main.ts:732-736(gRPC client wrapper). - Ledger call:
publisher.request<...>('ledger.credit', {...})atorder-router/src/main.ts:742-761. - Ledger handler:
subscriber.subscribeService(...)registration inservices/ledger-service/src/main.ts:72-106. - Ledger work:
LedgerService.creditatservices/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).
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
creditcalls on the same account (e.g. two different deposit subscribers in a horizontally-scaled ledger-service) can both read the sameavailable, both compute the samenewBalance, and both write — the secondupdatewins and a credit is lost. ThefindFirstidempotency guard ontransactionIdonly saves us if the two calls share the sametransactionId. - Settlement under READ COMMITTED:
settleTradere-reads accounts viafindFirstOrThrowafter 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-servicepast 1 replica without also addingSerializableand retry-on-serialisation-error handling.
Recommended fix¶
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 isuserId_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: newBalancewherenewBalance = add(account.available, amount). The next entry on the same account will readaccount.available(now equal tonewBalance) and add to that. ✅ Sequential entries on a single account form a verifiable running tape.debit(ledger.ts:201): same shape,balance: newBalanceafter the subtraction. ✅
Where it's misleading¶
settleTradetrade rows (ledger.ts:475, 485, 495, 505): writesbalance: <account>.availablewhere<account>.availableis the opening balance read ingetOrCreateAccountbefore any update. This is not the post-trade resting balance. 🔴settleTradefee 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.
10. Related reading¶
- 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.