chemaclass / unspent
A PHP library for UTXO-like bookkeeping using unspent entries.
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 10
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/chemaclass/unspent
Requires
- php: >=8.4
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.92
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.5
- rector/rector: ^2.3
- symfony/console: ^8.0
- symfony/var-dumper: ^8.0
This package is auto-updated.
Last update: 2026-01-11 17:02:56 UTC
README
Track value like physical cash in your PHP apps. Every unit has an origin, can only be spent once, and leaves a complete audit trail.
// Start with 1000 units $ledger = InMemoryLedger::withGenesis(Output::open(1000, 'funds')); // Spend 600, keep 400 as change $ledger = $ledger->apply(Tx::create( spendIds: ['funds'], outputs: [Output::open(600), Output::open(400)], )); // The original 1000 is gone forever - can't be double-spent.
Why?
Traditional balance tracking (balance: 500) is just a number you mutate. There's no history, no proof of where it came from, and race conditions can corrupt it.
Unspent tracks value like physical cash. You can't photocopy a $20 bill - you spend it and get change back. This gives you:
- Double-spend prevention - A unit can only be spent once, ever
- Complete audit trail - Trace any value back to its origin
- Immutable history - State changes are additive, never mutated
- Zero external dependencies - Pure PHP 8.4+
Inspired by Bitcoin's UTXO model, decoupled as a standalone library.
When is UTXO right for you?
| Need | Traditional Balance | Unspent |
|---|---|---|
| Simple spending | ✅ Easy | Overkill |
| "Who authorized this?" | Requires extra logging | ✅ Built-in |
| "Trace this value's origin" | Requires event sourcing | ✅ Built-in |
| Concurrent spending safety | Race conditions | ✅ Atomic |
| Conditional spending rules | Custom logic needed | ✅ Lock system |
| Regulatory audit trail | Reconstruct from logs | ✅ Native |
Use Unspent when:
- Value moves between parties (not just a single user's balance)
- You need to prove who authorized what
- Audit trail is a requirement, not a nice-to-have
Skip it when:
- You just need a simple counter or balance
- Single-user scenarios with no authorization needs
- No audit requirements
Install
composer require chemaclass/unspent
Quick Start
Create and transfer value
// Initial value $ledger = InMemoryLedger::withGenesis(Output::open(1000, 'funds')); // Transfer: spend existing outputs, create new ones $ledger = $ledger->apply(Tx::create( spendIds: ['funds'], outputs: [ Output::open(600, 'payment'), Output::open(400, 'change'), ], )); // Query state $ledger->totalUnspentAmount(); // 1000 $ledger->unspent()->count(); // 2 outputs
Add authorization
When you need to control who can spend:
// Server-side ownership (sessions, JWT, etc.) $ledger = InMemoryLedger::withGenesis( Output::ownedBy('alice', 1000, 'alice-funds'), ); $ledger = $ledger->apply(Tx::create( spendIds: ['alice-funds'], outputs: [ Output::ownedBy('bob', 600), Output::ownedBy('alice', 400), ], signedBy: 'alice', // Must match the owner ));
Output types
| Method | Use case |
|---|---|
Output::open(100) |
No lock - pure bookkeeping |
Output::ownedBy('alice', 100) |
Server-side auth (sessions, JWT) |
Output::signedBy($pubKey, 100) |
Ed25519 crypto (trustless) |
Output::lockedWith($lock, 100) |
Custom locks (multisig, timelock) |
Use Cases
| What you're building | Topics |
|---|---|
| In-game currency | Ownership, double-spend prevention, implicit fees |
| Loyalty points | Minting new value, redemption, audit trails |
| Internal accounting | Multi-party authorization, reconciliation |
| Crypto wallet | Ed25519 signatures, trustless verification |
| Event sourcing | State machines, immutable history tracing |
| Bitcoin simulation | Coinbase mining, fees, UTXO consolidation |
| Custom locks | Timelocks, custom lock types, serialization |
| SQLite persistence | Database storage, querying, ScalableLedger |
php example/run game # Run any example (loyalty, wallet, btc, etc.) composer init-db # Initialize database for persistence examples
See example/README.md for details.
Documentation
| Topic | What you'll learn |
|---|---|
| Core Concepts | How outputs, transactions, and the ledger work |
| Ownership | Locks, authorization, custom lock types |
| History | Tracing value through transactions |
| Fees & Minting | Implicit fees, coinbase transactions |
| Persistence | JSON, SQLite, custom storage |
| Scalability | InMemoryLedger vs ScalableLedger for large datasets |
| API Reference | Complete method reference |
FAQ
Can two outputs have the same ID?
No. Output IDs must be unique across the ledger. If you omit the ID parameter, a unique one is auto-generated using 128-bit random entropy. If you provide a custom ID that already exists, the library throws DuplicateOutputIdException.
// Auto-generated IDs (recommended) - always unique Output::ownedBy('bob', 100); // ID: auto-generated Output::ownedBy('bob', 200); // ID: different auto-generated // Custom IDs - validated for uniqueness Output::ownedBy('bob', 100, 'payment-1'); // OK Output::ownedBy('bob', 200, 'payment-1'); // Throws DuplicateOutputIdException
This mirrors Bitcoin's UTXO model where each output has a unique txid:vout identifier, even when sending to the same address multiple times.
When should I use InMemoryLedger vs ScalableLedger?
| Scenario | Recommendation |
|---|---|
| < 100k total outputs | InMemoryLedger |
| > 100k total outputs | ScalableLedger |
| Need full history in memory | InMemoryLedger |
| Memory-constrained environment | ScalableLedger |
ScalableLedger keeps only unspent outputs in memory and delegates history to a HistoryStore. See Scalability docs.
How are fees calculated?
Fees are implicit, like in Bitcoin. The difference between inputs and outputs is the fee:
$ledger->apply(Tx::create( spendIds: ['input-100'], // Spending 100 outputs: [Output::open(95)], // Creating 95 )); // Fee = 100 - 95 = 5 (implicit)
See Fees & Minting docs.
Development
composer install # Installs dependencies + pre-commit hook composer test # Runs cs-fixer, rector, phpstan, phpunit