A zero-dependency PHP library for value tracking, where double-spend prevention and a complete audit trail are baked into the data model — not bolted on afterwards.
A transaction consumes existing unspent outputs and produces new ones. Spent outputs can never be spent again — that invariant is the ledger.
transaction — alice pays bob 30 spends creates ┌─────────────────┐ ┌─────────────────┐ │ alice · 100 │ ──── spend ───▶│ bob · 30 │ └─────────────────┘ ├─────────────────┤ ✕ consumed │ alice · 70 │ change └─────────────────┘
Bitcoin has no account balances — only unspent outputs. Your balance is the set you can unlock; spending consumes whole outputs and creates new ones. This library ports that model to general bookkeeping, keeping its one-spend invariant.
An output is spendable exactly once. Invalid transactions are rejected before they ever mutate the ledger — validate first with canApply().
Domain objects are readonly. Every output records the transaction that created it and the one that spent it.
In-memory for tests, SQLite for production, or implement the repository port for any backend. History and unspent set are stored independently for scale.
Owner, Ed25519 public key, M-of-N multisig, time locks, hash locks — composed and extensible through the OutputLock port.
PSR-14 event dispatching and PSR-3 logging wrap any ledger without touching the core, following the same hexagonal boundaries.
FIFO, largest- and smallest-first selection strategies, plus UTXO analytics for dust, consolidation and per-owner summaries.
use Chemaclass\Unspent\Ledger; $ledger = Ledger::inMemory(); $ledger->credit('alice', 100) // mint 100 to alice ->transfer('alice', 'bob', 30); // alice → bob $ledger->totalUnspentByOwner('bob'); // 30 $ledger->totalUnspentByOwner('alice'); // 70 (change output)
Correctness comes free from the model; the engineering keeps the everyday operations linear and cache-friendly — proven by a phpbench suite, not asserted.
History mutates in place — N transactions cost O(N), not O(N²).
Transfers and per-owner balances are O(outputs-owned), independent of total ledger size.
Mutations happen in place; reads return a copy-on-write snapshot that never poisons the next write.
The SQLite adapter batches writes behind covering indexes and keeps memory bounded to the unspent set — scaling past RAM.