An accounting ledger for digital assets sits between two sources of truth that disagree constantly: the blockchain, where a deposit is “detected” long before it is final, and the compliance function, which can quarantine funds the chain considers settled. Most ledgers model neither well — and the ones built on floating-point arithmetic quietly corrupt the rest. This system is a double-entry ledger designed for that reality, with a design twist I care about: it runs as a dual-run pair — the Kotlin/Spring Boot platform ledger shadowed by a byte-compatible Rust reimplementation — so the books are checked by two independent engines that must agree.
| Role | Ledger design and implementation of both engines |
| Stack | Kotlin + Spring Boot (platform), Rust — axum 0.7, tokio, sqlx (dual-run verifier), PostgreSQL |
| Model | 6-state lifecycle, 10 account pools on two parallel tracks, append-only journal |
| API | 8 REST routes, scoped bearer auth, RFC-7807 problem+json errors, Prometheus metrics |
| Status | Beta on testnet ledgers (Stellar, Bitcoin; XLM/USDC/BTC); 47 tests |
The problem
Three failure modes dominate digital-asset accounting:
- Double-posting. Networks fail and clients retry — that’s not an edge case, it’s the steady state. Without deep idempotency, every retry risks a duplicate entry and a phantom balance that someone eventually withdraws.
- Limbo funds. A single status column cannot express “on-chain but unconfirmed,” “confirmed but compliance-held,” or “cleared but reserved for an outgoing broadcast” — yet that is where customer money lives most of the time. Squeezing those states into one field produces the special-case code that eats ledgers alive.
- Unprovable history. Ledgers that permit
UPDATEin place can’t demonstrate to an auditor that history wasn’t rewritten; the absence of evidence is the finding.
And underneath all three: IEEE floats round. Across millions of postings, sub-cent errors compound into balances nobody can reconcile — the original sin of amateur ledgers.
Design decisions
A status machine that drives pool-to-pool movements
The ledger models each transaction’s life as a six-state machine — Reserved, Detected, Quarantine, Compliance, Confirmed, Finalized — and each state transition expands into a fixed set of movements between ten account pools:
┌────────────────┐ ┌────────────────────┐
shared pools: │ SharedLedger │ │ SharedCompliance │
└───────┬────────┘ └─────────┬──────────┘
status: DETECTED ═══ opens BOTH tracks at once ═════════╪══════════
│ │
BLOCK TRACK ▼ ▼ COMPLIANCE TRACK
(mirrors chain reality) ┌────────────────┐ ┌───────────────────┐ (mirrors institutional
│ BlockDetected │ │ComplianceDetected │ reality)
└───────┬────────┘ └────────┬──────────┘
confirmations ▼ ▼ AML / sanctions hold
┌────────────────┐ ┌───────────────────┐
│ BlockConfirmed │ │ Compliance │
└───────┬────────┘ │ Quarantined │
finality ▼ └────────┬──────────┘
┌────────────────┐ ▼ cleared by review
│ BlockFinalized │ ┌───────────────────┐
└────────────────┘ │ ComplianceCleared │
└────────┬──────────┘
▼ settled
┌───────────────────┐ ┌───────────────────┐
│ ComplianceSettled │─────►│ BroadcastReserved │
└───────────────────┘ RESERVED (outgoing txs)
The key insight is the dual track: a single Detected event initializes both tracks simultaneously (“dual track initialization” is the literal rule name in the engine). Funds can be “confirmed on-chain” and “held in compliance” at the same time without any special-case code, because those are different pools on different tracks. Balance queries read pools directly — ComplianceSettled feeds total, BroadcastReserved feeds reserved, ComplianceQuarantined feeds quarantined — so the customer-facing numbers are pool sums, not derived flags.
Balanced by construction, guarded by invariant
Every movement becomes a debit/credit pair, and assert_balanced() rejects any posting whose signed amounts don’t sum to zero per (transaction, ledger, asset, account) — with a typed UnbalancedTransaction error rather than a generic failure, alongside InsufficientPoolBalance checks before any transfer. Transition guards make illegal states unrepresentable: nothing leaves Finalized, nothing regresses to Detected. Corrections never edit rows; a reversal posts offsetting entries with negated amounts and status REVERSED_<reason>, linked to the original via reversal_of_entry_id, and the net-zero outcome after reversal is proven by test.
Three independent layers of idempotency
request ──► LAYER 1: row_idempotency_key UNIQUE + ON CONFLICT DO NOTHING
│ stops raw duplicates at the database
▼
LAYER 2: business key ledger:batch:tx:index:asset:debitor:creditor:status
│ replayed request returns the SAME entry IDs as the original
▼
LAYER 3: reversal key reverse:<entry-id>:<reason>
"undo" is exactly-once too
The second layer is the one that changes client behavior: a retried posting doesn’t just not duplicate — it converges, returning identical entry IDs to the original call, which is proven by tests asserting first == duplicate across direct, status, and reversal flows. Retries stop being an error condition and become the expected mode of operation.
No floats, no ceilings
Amounts are arbitrary-precision unsigned integers carried as decimal strings, with digit-by-digit addition, subtraction, and comparison implemented by hand — a 2^127 value round-trips in the test suite — and stored as PostgreSQL NUMERIC. Zero floats anywhere in the codebase. Balances are never cached: a balance query is a SUM over pools at read time, grouped by account and asset, which makes every balance an audit rather than a lookup.
Dual-run verification
The Rust service implements the same posting engine, against the same append-only schema, as the Kotlin platform ledger. Running both against the same movement stream turns implementation divergence into an immediately visible bug rather than a reconciliation mystery six months later — the same oracle discipline I use in my Tetra blockchain’s dual-node design, applied to money. The Rust side is deliberately small and legible: axum + sqlx, one entry table with five indexes, schema migrations applied idempotently at startup, /metrics for Prometheus.
API surface
| Route | Purpose | Scope |
|---|---|---|
POST /accounting/v1/entry-direct |
Post a movement directly | accounting:write |
POST /accounting/v1/entries/status |
Drive a status transition (expands to movements) | accounting:write |
POST /accounting/v1/entry-reverse |
Reverse by entry, transaction, or batch | accounting:reverse |
POST /accounting/v1/balance/query |
Balances by account/asset (SUM by pool) | accounting:read |
GET /accounting/v1/balance/by-accounting-ids |
Balances for specific entries | accounting:read |
GET /accounting/v1/health · GET /metrics |
Liveness · Prometheus | none |
Errors are RFC-7807 application/problem+json with typed, machine-readable categories. A typed client crate ships builder helpers (credit_detected, debit_reserved, …) and assertion decoders, so integration tests read like accounting statements rather than HTTP plumbing. The status vocabulary accepts domain aliases — CREDIT_DETECTED, DEBIT_RESERVED, CREDIT_SETTLED — mapped onto the six core states, so callers speak their own dialect while the engine stays canonical.
Status and limits
The posting engine, lifecycle machine, idempotency layers, and reversal flow are live in beta on testnet ledgers (Stellar and Bitcoin testnets; XLM, USDC, BTC), with 47 tests spanning the domain core (30), embedded service tests (11), and integration tests through a shared test harness (6). Honestly scoped: a YAML-configurable rules engine — transition rules, ledger-entry rules, per-ledger validation config — exists in the domain layer but is not yet wired to the HTTP surface; snapshots, aging reports, and deterministic period close are designed but not built; and append-only is currently enforced by code path, with database-level triggers on the hardening roadmap.
What this demonstrates
This project is about turning accounting promises into enforced invariants: balance as a typed assertion rather than a hope, retry-safety as three deliberate layers rather than a client guideline, precision as hand-rolled arithmetic with no ceiling, limbo as first-class pools on parallel tracks, and correctness as two engines that must agree. If your platform moves money and your ledger is a table you UPDATE, let’s talk.