Ledger
A high-performance, double-entry ledger system for Rust with two-phase execution, value object fragmentation, and a fluent payment-splitting API.
Designed to be used standalone or embedded in Ousia via the ledger feature.
Table of Contents
- Architecture
- Installation
- Setup
- Assets
- Minting and Burning
- Transfers
- The Slice API
- Payment Splits
- Reserve (Escrow)
- Balances
- Transactions
- Value Objects and Fragmentation
- Error Handling
- Implementing a Production Adapter
- Running Tests
Architecture
Two-Phase Execution
Every money operation goes through two phases:
Phase 1 — Planning (pure memory, no DB locks)
├── Build ExecutionPlan (list of Operations)
├── Validate all slices are consumed
└── Calculate required locks
Phase 2 — Execution (single DB transaction, microsecond locks)
├── BEGIN TRANSACTION
├── SELECT FOR UPDATE on required value objects
├── Execute all operations
├── Burn locked value objects
├── Mint change (over-selection handled automatically)
└── COMMIT (or ROLLBACK on any error)
Key properties:
- No early locking — DB locks are held only during execution, not during business logic
- Double-spend protection — atomic execution with SELECT FOR UPDATE
- Automatic change — if you select $100 worth of value objects but only spend $60, $40 is minted back as change
- Rollback on any failure — a planning error or a partial
Errin the closure leaves balances unchanged
Value Objects
Money exists as immutable ValueObject fragments. Each fragment has an amount, an owner, an asset, and a state (Alive, Reserved, or Burned).
State transitions:
Alive ──→ Reserved ──→ Alive
Alive ──→ Burned
Reserved ──→ Burned
Burned ──→ (terminal — no transitions)
Fragmentation keeps individual value objects at or below the asset's configured unit size, preventing unbounded accumulation.
Installation
= "1.0"
** If using ledger as standalone, the MemoryAdapter is for testing and shouldn't be used in Production as it doesn't persist data. You should implement XLedgerAdapter where X corresponds to the database you're using.
Setup
use ;
use Arc;
// 1. Create a system with an adapter
let adapter = Boxnew;
let system = new;
let ctx = new;
// 2. Register assets before any money operations
let usd = new; // unit = $100, 2 decimal places
system.adapter.create_asset.await?;
In production you implement LedgerAdapter for your database (see Implementing a Production Adapter).
Assets
Assets describe a currency or token and control fragmentation and display precision.
// Fiat — unit sized to practical transaction amounts
let usd = new; // unit = $100.00, display as X.XX
let ngn = new; // unit = ₦5,000.00
// Crypto — unit sized to typical on-chain amounts
let eth = new; // unit = 0.01 ETH
let btc = new; // unit = 0.1 BTC
unit — maximum amount per ValueObject. Large balances are split into multiple fragments, each at most unit in size. Smaller units mean more fragments; larger units mean fewer, bigger objects.
decimals — display conversion only, does not affect internal amounts.
let usd = new;
// Display conversion helpers
let internal = usd.to_internal; // → 10050 (internal units)
let display = usd.to_display; // → 100.50 (f64)
All internal amounts are u64. The convention in tests and examples is to write them with underscores for readability: 100_00 means 100 dollars (10000 cents).
Minting and Burning
Use Money::atomic for all state-changing operations. The closure receives a TransactionContext and returns Result<(), MoneyError>. The plan is only executed if the closure returns Ok(()) and all slices are consumed.
// Mint (create money from nothing — e.g., a deposit webhook)
atomic.await?;
// Burn via a slice (destroy money — e.g., a fee deduction)
atomic.await?;
// Direct burn (without the slice API)
atomic.await?;
Transfers
atomic.await?;
tx.money(asset, owner, amount) checks the current balance in the planning phase and returns InsufficientFunds early if the sender can't cover amount. The actual value objects are only locked and burned during execution.
The Slice API
A Money represents funds you intend to spend from a particular owner. A MoneySlice is a portion of that money earmarked for a specific operation.
Rules enforced at commit time:
- Every
Moneycreated viatx.money()must have its full amount sliced out — leaving money unsliced returnsStorage("never sliced"). - Every
MoneySlicemust be consumed (viatransfer_toorburn) — an unconsumed slice returnsUnconsumedSlice. - You cannot slice more than the remaining amount on a
Money— returnsInvalidAmount.
let money = tx.money.await?;
// Slice from Money
let slice_a = money.slice?; // 60 taken, 40 remaining
let slice_b = money.slice?; // 40 taken, 0 remaining ✓
// Sub-slice from an existing slice
let mut big_slice = money.slice?;
let part1 = big_slice.slice?; // big_slice now holds 30
let part2 = big_slice.slice?; // big_slice now holds 0
// Consume each slice
part1.transfer_to.await?;
part2.transfer_to.await?;
big_slice.burn.await?; // consume leftover (0 is valid)
Payment Splits
The slice API makes multi-party payment splits ergonomic and correct:
atomic.await?;
All three transfers are executed in a single atomic database transaction. Either all succeed or none do.
Reserve (Escrow)
Reserving moves funds into a Reserved state under a designated authority. The funds are locked against the original owner (reducing their available balance) but credited as reserved to the authority.
// Reserve $200 from buyer, held by marketplace escrow
atomic.await?;
// Balance after:
// buyer.available = original - 200_00
// marketplace.reserved = 200_00
Releasing a reservation (e.g., after order completion) is handled by your application — burn the reserved amount or transfer it onward.
Balances
use Balance;
let balance = get.await?;
println!; // spendable
println!; // locked in escrow
println!; // available + reserved
You can also query balance inside a transaction:
atomic.await?;
Transactions
Every operation records a Transaction with sender, receiver, burned_amount, minted_amount, and metadata.
let tx = system.adapter.get_transaction.await?;
println!;
println!;
println!;
println!;
println!;
println!;
Value Objects and Fragmentation
Behind the scenes, a balance of $500 with a unit of $100 is stored as five ValueObject rows, each with amount = 100_00. When you spend $200, the system selects two (or more) value objects that cover the amount, burns them, executes the transfers, and mints the change back.
This fragmentation prevents any single value object from growing unboundedly and keeps lock contention low — you're locking small, discrete fragments rather than a single mutable balance row.
// Asset with unit = 100_00 ($100)
// Mint $250 → creates three fragments: $100 + $100 + $50
tx.mint.await?;
You never interact with ValueObject directly in normal usage. The adapter handles selection, locking, and change minting transparently.
Error Handling
use MoneyError;
match result
The Storage("Money created but never sliced") variant fires when tx.money() is called but .slice() is never invoked before the closure returns. Always slice (and consume) every Money you create.
Implementing a Production Adapter
The MemoryAdapter is provided for testing. For production, implement LedgerAdapter for your database:
use ;
use async_trait;
use Uuid;
The critical contract is execute_plan: it receives the complete ExecutionPlan and the lock requirements. Lock, execute, handle change, commit — all in one DB transaction.
Running Tests
# Unit + integration tests (in-memory adapter)
# With verbose output
Test Coverage
Core operations: mint, transfer, transfer with change, multiple slices, sub-slices, burn, reserve
Error paths: insufficient funds, unconsumed slice, money never sliced, over-slice, double-spend
Advanced: asset decimal conversion, fragmentation, multi-recipient payment splits, rollback on error, multiple assets, concurrent transfers
License
MIT