chemaclass/unspent

A PHP library for UTXO-like bookkeeping using unspent entries.

Installs: 0

Dependents: 0

Suggesters: 0

Security: 0

Stars: 14

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/chemaclass/unspent

dev-main 2026-01-18 17:43 UTC

This package is auto-updated.

Last update: 2026-01-18 17:43:25 UTC


README

PHP 8.4+ MIT License Scrutinizer Code Quality Code Coverage Mutation testing badge

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.

// 3 lines to get started
$ledger = Ledger::inMemory();
$ledger->credit('alice', 100)->transfer('alice', 'bob', 25);
echo $ledger->totalUnspentByOwner('bob'); // 25
Full example with all operations
$ledger = Ledger::inMemory();
$ledger->credit('alice', 1000)          // Mint 1000 for Alice
    ->transfer('alice', 'bob', 300)     // Alice sends 300 to Bob
    ->debit('bob', 50);                 // Bob redeems 50

$ledger->totalUnspentByOwner('alice');  // 700
$ledger->totalUnspentByOwner('bob');    // 250

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

When NOT to use this library

Be aware of these limitations before choosing Unspent:

Limitation Details
Integer bounds Amounts are bounded by PHP_INT_MAX (~9.2 quintillion). Use a wrapper for arbitrary precision.
Single-node model Designed for single-node operation. For distributed consensus, add infrastructure (Raft, blockchain).
No built-in rate limiting Your application must implement rate limiting to prevent abuse.
Memory for large datasets In-memory mode uses ~1MB per 1,000 outputs. Use store-backed mode for >100k outputs.
Not for sub-second precision Timestamps are not enforced; this is not a real-time trading engine.

If you need distributed consensus, high-frequency trading, or arbitrary precision arithmetic, consider specialized solutions.

Install

composer require chemaclass/unspent

Quick Start

Simple API (In-Memory)

For quick prototyping and testing:

// Create a ledger and mint initial balances
$ledger = Ledger::inMemory();
$ledger->credit('alice', 1000)         // Mint 1000 to alice
    ->credit('bob', 500);              // Mint 500 to bob

// Transfer between users (auto handles change)
$ledger->transfer('alice', 'bob', 200);

// Transfer with fee (5 units burned)
$ledger->transfer('alice', 'bob', 100, fee: 5);

// Debit/burn value (redemption, purchase, etc.)
$ledger->debit('bob', 50);

// Check balances
$ledger->totalUnspentByOwner('alice');  // 695
$ledger->totalUnspentByOwner('bob');    // 650

With SQLite Persistence

For production use with data persistence:

use Chemaclass\Unspent\Persistence\Sqlite\SqliteRepositoryFactory;

// Create repository from file (auto-creates schema)
$repository = SqliteRepositoryFactory::createFromFile('ledger.db');

// Load existing ledger or create new one
$ledger = $repository->find('my-ledger')
    ?? Ledger::withGenesis(Output::ownedBy('alice', 1000));

// Make changes
$ledger->transfer('alice', 'bob', 200);

// Save to database
$repository->save('my-ledger', $ledger);
Method Description
credit($owner, $amount) Mint new value to owner
transfer($from, $to, $amount, $fee) Move value between owners
debit($owner, $amount, $fee) Burn value from owner

Coin Control API

For full control over inputs and outputs, use apply():

// Start with specific outputs
$ledger = Ledger::withGenesis(
    Output::ownedBy('alice', 500, 'alice-savings'),
    Output::ownedBy('alice', 300, 'alice-checking'),
);

// Explicitly choose which outputs to spend
$ledger->apply(Tx::create(
    spendIds: ['alice-checking'],  // Only spend from checking
    outputs: [
        Output::ownedBy('bob', 200),
        Output::ownedBy('alice', 100, 'alice-change'),
    ],
    signedBy: 'alice',
));

// alice-savings (500) is untouched
// alice-change (100) is the new output

Use coin control when you need:

  • Specific output selection (spend oldest first, consolidate dust, etc.)
  • Custom output IDs for tracking
  • Multiple recipients in one transaction
  • Complex fee structures

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

In-game currency — Ownership, double-spend prevention, implicit fees

📄 Source code

Virtual Currency - In-Game Economy (Flagship Demo)
==================================================

 [Created new ledger with 3 genesis outputs]

 alice bought item for 300g (tax: 50g)

Balances
--------

   alice: 650g
   bob: 500g
   shop: 5300g

 Total fees collected: 50g

Database Stats
--------------

 * Database: example/data/sample:virtual-currency.db
 * Ledger: sample:virtual-currency
 * Outputs: 5
 * Transactions: 1

 Run again to continue. Delete the DB file to reset.
Loyalty points — Minting new value, redemption, audit trails

📄 Source code

Loyalty Points - Customer Rewards Program
=========================================

 [Created new empty ledger]

 Customer bought $75 -> earned 75 pts
 Total points minted: 75

 Customer balance: 75 pts

Points Breakdown
----------------

   earn-1: 75 pts (customer)

Database Stats
--------------

 * Database: example/data/sample:loyalty-points.db
 * Outputs: 1
 * Transactions: 1

 Run again to continue. Delete the DB file to reset.
Internal accounting — Multi-party authorization, reconciliation

📄 Source code

Internal Accounting - Department Budgets
========================================

 [Created new ledger with 3 genesis outputs]

 Total budget: $180,000

 operations transfers $9,000 to marketing (fee: $180)

Security Demonstrations
-----------------------

 Finance tries to reallocate engineering funds...
 BLOCKED
 Marketing tries to overspend...
 BLOCKED

Budget by Department
--------------------

   engineering: $100,000
   marketing: $59,000
   operations: $20,820

Reconciliation
--------------

 * Initial: $180,000
 * Fees: $180
 * Remaining: $179,820
 * Check: BALANCED

Database Stats
--------------

 * Database: example/data/sample:internal-accounting.db
 * Outputs: 5
 * Transactions: 1

 Run again to continue. Delete the DB file to reset.
Crypto wallet — Ed25519 signatures, trustless verification

📄 Source code

Crypto Wallet - Ed25519 Signatures
==================================

 [Created new ledger with 2 genesis outputs]

Keys Generated
--------------

 * Alice: mVfG1xZJmK8QqP2n...
 * Bob: Y+U9UIYQmP5FTJM9...

 Alice -> Bob: 356 (signed)

 Mallory tries to steal with wrong key...
 BLOCKED

Final Balances
--------------

   to-Bob-2: 356
   change-Alice-2: 634

Database Stats
--------------

 * Database: example/data/sample:crypto-wallet.db
 * Outputs: 4
 * Transactions: 1

 Run again to continue. Delete the DB file to reset.
Event sourcing — State machines, immutable history tracing

📄 Source code

Event Sourcing - Order Lifecycle
================================

 Order lifecycle: placed -> paid -> shipped -> delivered
 Each transition spends old state, creates new state

 [Created new empty ledger]

 order-1: placed
 order-1: paid
 order-1: shipped
 order-1: delivered

Event Chain for order-1
-----------------------

   order-1_placed: create_order-1 -> evt_order-1_payment
   order-1_paid: evt_order-1_payment -> evt_order-1_shipped
   order-1_shipped: evt_order-1_shipped -> evt_order-1_delivered
   order-1_delivered: evt_order-1_delivered (current)

All Orders (Current State)
--------------------------

   order-1: delivered

Database Stats
--------------

 * Database: example/data/sample:event-sourcing.db
 * Outputs: 4
 * Transactions: 4

 Run again to continue. Delete the DB file to reset.
Bitcoin simulation — Coinbase mining, fees, UTXO consolidation

📄 Source code

Bitcoin Simulation - Multi-Block Mining
=======================================

 [Created new ledger with 1 genesis outputs]

Mining Block #1
---------------

 Transaction: satoshi-genesis -> recipient-1
 * Sent: 14.549999 BTC
 * Change: 33.95 BTC
 * Fee: 1.5 BTC
 Mined: 50 BTC -> miner-1

Blockchain State
----------------

 * Total minted: 100 BTC
 * Total fees: 1.5 BTC
 * In circulation: 98.5 BTC
 * UTXOs: 3

Database Stats
--------------

 * Database: example/data/sample:bitcoin-simulation.db
 * Outputs: 4
 * Transactions: 2

 Run again to continue. Delete the DB file to reset.
Custom locks — Timelocks, custom lock types, serialization

📄 Source code

Custom Locks - Time-Locked Outputs
==================================

 Registered 'timelock' handler

 [Created new ledger with 2 genesis outputs]

 alice spent unlocked funds -> charlie

Security Demonstrations
-----------------------

 bob tries to spend locked output...
 BLOCKED: Still locked until 2027-01-18
 Eve tries to spend alice's output...
 BLOCKED

Time-Locked Outputs
-------------------

   bob-locked: 500 (bob) - locked until 2027-01-18
   charlie-from-alice-2: 1000 (charlie)

Database Stats
--------------

 * Database: example/data/sample:custom-locks.db
 * Outputs: 3
 * Transactions: 1

 Run again to continue. Delete the DB file to reset.
SQLite persistence — Database storage, querying, Ledger with HistoryRepository

📄 Source code

SQLite Persistence Example
==========================

 Connected to: data/ledger.db
 Ledger ID: sqlite-persistence

 Creating new ledger...
 Created ledger with genesis outputs for alice (1000) and bob (500)

Transaction example-tx-1
------------------------

 Alice spends: alice-initial (1000)
 * Created: charlie-1 (495), alice-change-1 (495)
 * Fee: 10

Query Examples
--------------

 Balances:
 * alice: 495
 * bob: 500
 * charlie: 495

 Outputs >= 100: 3
 Owner-locked outputs: 3

History Tracking
----------------

 alice-initial:
 * Amount: 1000
 * Status: spent
 * Created by: genesis
 * Spent by: example-tx-1

Ledger Summary
--------------

 * Total unspent: 1490
 * Total fees: 10
 * Total minted: 0
 * Unspent count: 3

Database Stats
--------------

 * Outputs in DB: 4
 * Transactions in DB: 1

 Run again to add more transactions.
php example/run game      # Run any example (loyalty, wallet, btc, etc.)
php example/run game      # Run again to continue from previous state

All examples use SQLite persistence. 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 In-memory mode vs store-backed mode for large datasets
Migration Guide Moving from balance-based systems to UTXO
Troubleshooting Common issues and solutions
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 in-memory mode vs store-backed mode?
Scenario Recommendation
< 100k total outputs Ledger::inMemory() or Ledger::withGenesis(...)
> 100k total outputs Ledger::withRepository($repository)
Need full history in memory Ledger::inMemory()
Memory-constrained environment Ledger::withRepository($repository)

Store-backed mode keeps only unspent outputs in memory and delegates history to a HistoryRepository. 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